From 05b41337349e51f509b2da241eb29d343c9dff7b Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Fri, 10 Apr 2026 18:20:42 +0800 Subject: [PATCH 1/6] 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 --- .../tech-spec-moodle-seeding-toolkit.md | 661 ++++++++++++++++++ _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 + src/modules/audit/audit-action.enum.ts | 4 + .../moodle-provisioning.controller.spec.ts | 140 ++++ .../moodle-provisioning.controller.ts | 213 ++++++ .../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 ++ .../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.ts | 69 +- 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 | 51 +- .../moodle-course-transform.service.spec.ts | 190 +++++ .../moodle-course-transform.service.ts | 124 ++++ .../moodle-csv-parser.service.spec.ts | 80 +++ .../services/moodle-csv-parser.service.ts | 107 +++ .../moodle-provisioning.service.spec.ts | 310 ++++++++ .../services/moodle-provisioning.service.ts | 632 +++++++++++++++++ 31 files changed, 3298 insertions(+), 4 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/tech-spec-moodle-seeding-toolkit.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/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/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/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/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-moodle-seeding-toolkit.md b/_bmad-output/implementation-artifacts/tech-spec-moodle-seeding-toolkit.md new file mode 100644 index 0000000..22c7c62 --- /dev/null +++ b/_bmad-output/implementation-artifacts/tech-spec-moodle-seeding-toolkit.md @@ -0,0 +1,661 @@ +--- +title: 'Moodle Seeding Toolkit' +slug: 'moodle-seeding-toolkit' +created: '2026-04-10' +status: 'ready-for-dev' +stepsCompleted: [1, 2, 3, 4] +tech_stack: + [ + 'NestJS 11', + 'MikroORM 6', + 'PostgreSQL', + 'csv-parse 6', + 'multer', + 'Zod', + 'Jest', + 'React 19', + 'Vite 8', + 'Tailwind 4', + 'shadcn/ui', + 'TanStack React Query', + 'React Router v7', + ] +files_to_modify: + - 'src/modules/moodle/moodle.module.ts' + - 'src/modules/moodle/lib/moodle.constants.ts' + - 'src/modules/moodle/lib/moodle.client.ts' + - 'src/modules/moodle/lib/moodle.types.ts' + - 'src/modules/moodle/moodle.service.ts' + - 'NEW: src/modules/moodle/services/moodle-provisioning.service.ts' + - 'NEW: src/modules/moodle/services/moodle-course-transform.service.ts' + - 'NEW: src/modules/moodle/controllers/moodle-provisioning.controller.ts' + - 'NEW: src/modules/moodle/lib/provisioning.types.ts' + - 'NEW: src/modules/moodle/dto/requests/provision-*.request.dto.ts' + - 'NEW: src/modules/moodle/dto/responses/provision-*.response.dto.ts' +code_patterns: + [ + 'Service+Controller in MoodleModule', + 'FileInterceptor for CSV upload', + 'SyncPhaseResult for operation results', + 'PascalCase public methods', + 'class-validator DTOs', + 'In-memory concurrency guard', + ] +test_patterns: + [ + 'Jest with TestingModule', + 'Mocked MoodleService + EntityManager', + 'Co-located .spec.ts files', + ] +--- + +# Tech-Spec: Moodle Seeding Toolkit + +**Created:** 2026-04-10 + +## Overview + +### Problem Statement + +Provisioning Moodle with institutional data (categories, courses, users) requires running a separate Rust CLI (`script.csv.faculytics`), manually crafting Moodle-formatted CSVs, and uploading them through Moodle's web UI. This is a multi-step, error-prone process that only one person can perform. The Rust script silently drops invalid rows, has hardcoded semester filters, and provides no feedback on failures. + +### Solution + +Build API endpoints (consumed by the admin console) that accept raw institutional data and handle all Moodle formatting and push logic server-side. Four operations: Provision Categories, Seed Courses (bulk CSV), Quick Course Create, and Seed Users. All Moodle-specific formatting (shortname patterns, category paths, semester dates, EDP codes) is derived server-side — the admin only provides raw curriculum data. + +### Scope + +**In Scope:** + +- Category tree provisioning via form (campus, year, semesters, departments + programs) using `core_course_create_categories` +- Bulk course seeding via CSV upload (`Course Code`, `Descriptive Title`, `Program`, `Semester`) + form (campus, department, start/end dates) using `core_course_create_courses` +- Quick single-course creation via form with live shortname/category path preview +- User/faculty seeding with fake data generation + enrollment using `core_user_create_users` and `enrol_manual_enrol_users` +- Institutional shortname/category-path/date formatting logic (ported from Rust script) +- Preview-before-execute for bulk operations; inline live preview for quick create +- Explicit validation reporting — no silent drops (flag semester-0 rows, empty fields, etc.) +- Category path to ID resolution via existing `core_course_get_categories` +- Duplicate course codes allowed (e.g., `CS-EL` appearing multiple times — unique EDP suffix differentiates) + +**Out of Scope:** + +- PDF extraction / curriculum parsing (remains manual + AI-assisted) +- Editing/deleting Moodle entities (done directly in Moodle UI) +- Replacing the existing Moodle sync (read) pipeline +- Production user management — this is a seeding/bootstrap tool +- Course selector dropdown in Seed Users tab (admin enters course IDs manually for now) + +## Context for Development + +### Codebase Patterns + +- **Module pattern**: `MoodleModule` registers providers (services) and controllers. New services go in `src/modules/moodle/services/`, new controllers in `src/modules/moodle/controllers/`. Add to `moodle.module.ts` `providers` and `controllers` arrays. +- **Service pattern**: Inject `MoodleService` (API client wrapper), `EntityManager`, `Logger`, and other module services as needed. Public methods use PascalCase and return typed results. +- **MoodleClient**: All Moodle API calls go through `MoodleService` → `MoodleClient.call(functionName, params)`. The client handles timeout, error wrapping, JSON parsing. Uses `env.MOODLE_MASTER_KEY` for privileged operations. +- **CSV upload**: Existing pattern in `questionnaire.controller.ts` uses `FileInterceptor('file', { fileFilter: csvFileFilter, limits: { fileSize: 5MB } })`. `csv-parse` v6.2.0 handles streaming parse. Reuse same interceptor pattern. +- **Result reporting**: Sync services return `SyncPhaseResult { status, durationMs, fetched, inserted, updated, deactivated, errors, errorMessage }`. Adapt for provisioning operations. +- **Entity hierarchy**: Campus (depth 1) → Semester (depth 2) → Department (depth 3) → Program (depth 4) → Course. All have `moodleCategoryId` or `moodleCourseId` for Moodle external ID resolution. +- **Category path → ID resolution**: Normalized entities (Campus, Semester, Department, Program) each store `moodleCategoryId`. To resolve `UCMN / S12526 / CCS / BSCS`, query Program where code matches within the correct department/semester/campus chain and read its `moodleCategoryId`. +- **Controller guards**: Admin endpoints use `@UseJwtGuard(UserRole.SUPER_ADMIN)`. Audited actions use `@Audited({ action, resource })`. +- **DTO placement**: Request DTOs in `dto/requests/`, response DTOs in `dto/responses/`. Swagger decorators required on all endpoints and DTO properties. +- **Transactions**: Multi-step DB writes wrapped in `unitOfWork.runInTransaction(async (tx) => { ... })`. + +### Files to Reference + +| File | Purpose | +| -------------------------------------------------------------- | ------------------------------------------------------------------- | +| `src/modules/moodle/moodle.module.ts` | Module registration — add new providers/controllers here | +| `src/modules/moodle/lib/moodle.client.ts` | Low-level Moodle API client — add write methods here | +| `src/modules/moodle/lib/moodle.constants.ts` | `MoodleWebServiceFunction` enum — add 4 new write functions | +| `src/modules/moodle/lib/moodle.types.ts` | Moodle type re-exports — add write response types | +| `src/modules/moodle/lib/sync-result.types.ts` | `SyncPhaseResult` type — reuse or extend for provisioning | +| `src/modules/moodle/moodle.service.ts` | High-level API wrapper — add write method wrappers | +| `src/modules/moodle/controllers/moodle-sync.controller.ts` | Pattern reference for admin controller (guards, audit, DTOs) | +| `src/modules/moodle/services/moodle-category-sync.service.ts` | Pattern reference for category hierarchy resolution | +| `src/modules/moodle/services/moodle-course-sync.service.ts` | Pattern reference for course operations | +| `src/modules/questionnaires/questionnaire.controller.ts` | Pattern reference for CSV file upload (FileInterceptor) | +| `src/modules/questionnaires/ingestion/adapters/csv.adapter.ts` | Pattern reference for csv-parse streaming | +| `src/entities/campus.entity.ts` | Campus entity with `moodleCategoryId` | +| `src/entities/semester.entity.ts` | Semester entity with `moodleCategoryId`, `code`, `academicYear` | +| `src/entities/department.entity.ts` | Department entity with `moodleCategoryId` | +| `src/entities/program.entity.ts` | Program entity with `moodleCategoryId` | +| `src/entities/course.entity.ts` | Course entity with `moodleCourseId`, `shortname`, `fullname` | +| `src/entities/user.entity.ts` | User entity with `moodleUserId`, `roles`, campus/dept/program scope | +| `src/entities/enrollment.entity.ts` | Enrollment entity with user+course composite unique | +| `src/entities/moodle-category.entity.ts` | Raw Moodle category mirror (depth, path, parentId) | + +### Technical Decisions + +- **Bulk per department**: The primary course seeding flow uploads one CSV per department, covering all programs and semesters in that department. +- **CSV format**: Accepts the raw curriculum CSV (same format as `ccs_course_mappings.csv`). Requires `Course Code`, `Descriptive Title`, `Program`, `Semester`. Extra columns (units, prerequisites, type) are silently ignored. +- **Semester-0 handling**: Elective/free elective rows with Semester=0 are flagged in the preview as "no semester assigned" but not silently dropped. They cannot be provisioned via bulk flow (no valid dates/category). Use Quick Course Create instead. +- **Direct Moodle REST API**: Uses `core_course_create_courses`, `core_user_create_users`, `enrol_manual_enrol_users`, `core_course_create_categories` — not CSV file upload. Requires Moodle web service token with write capabilities. +- **Category path resolution**: `core_course_create_courses` requires numeric `categoryid`, not path string. API resolves path via `core_course_get_categories` lookup. +- **All environments share one Moodle instance**: No environment gating needed for seeding operations. +- **Date format convention**: All date fields in request DTOs use **ISO 8601 format (`YYYY-MM-DD`)**. The controller/service layer extracts year portions as needed (e.g., `startDate: "2025-08-01"` → `startYear: "2025"`, `startYY: "25"`). The `SeedContext.startDate` and `endDate` represent the **academic year boundaries** — `GetSemesterDates()` derives per-semester dates from these year values (not from the DTO dates directly). +- **Institutional formatting conventions** (from Rust script): + - Shortname: `{CAMPUS}-S{sem}{startYY}{endYY}-{CourseCode}-{random5digitEDP}` + - Category path: `{CAMPUS} / S{sem}{startYY}{endYY} / {DEPT} / {PROGRAM}` + - Semester 1 dates: `{startYear}-08-01` to `{startYear}-12-18` + - Semester 2 dates: `{endYear}-01-20` to `{endYear}-06-01` + - Student username: `{campus}-{YY}{MM}{DD}{random4digits}` — **zero-padded month and day**, uses `new Date()` at generation time (e.g., `ucmn-2601054321` for Jan 5, 2026 with random `4321`). This avoids ambiguity between e.g., Jan 5 (`0105`) and Oct 15 (`1015`). + - Faculty username: `{campus}-t-{random5digits}` + - Default password: `User123#` + +### Moodle Write API Details + +**New `MoodleWebServiceFunction` entries needed:** + +| Function | Moodle wsfunction | Purpose | +| ------------------- | ------------------------------- | ------------------------------------------------------------------------------ | +| `CREATE_COURSES` | `core_course_create_courses` | Batch create courses (accepts array, returns `{id, shortname}[]`) | +| `CREATE_CATEGORIES` | `core_course_create_categories` | Create category nodes (accepts array with `name`, `parent`, returns `{id}[]`) | +| `CREATE_USERS` | `core_user_create_users` | Batch create users (accepts array, returns `{id, username}[]`) | +| `ENROL_USERS` | `enrol_manual_enrol_users` | Batch enrol users into courses (accepts array of `{userid, courseid, roleid}`) | + +**Critical API details:** + +- `core_course_create_courses` requires numeric `categoryid`, NOT path string. Must resolve via entity lookup. +- `core_course_create_categories` requires numeric `parent` ID. **Must be called one depth level at a time** — you need the parent's returned `id` before creating children. Cannot send the entire tree in a single batch. +- `core_user_create_users` requires password meeting Moodle's password policy. Default `User123#` includes uppercase, lowercase, digit, special char. +- `enrol_manual_enrol_users` returns **`null` on complete success** (no response body). Only returns a warnings structure on partial failure. Code must null-check before accessing response properties. +- Role IDs are **deployment-specific** Moodle defaults (student=5, editingteacher=3). Configured via env vars `MOODLE_ROLE_ID_STUDENT` and `MOODLE_ROLE_ID_EDITING_TEACHER` with those defaults. +- All write functions use array-indexed parameter encoding: `courses[0][shortname]=X&courses[0][fullname]=Y&courses[1][shortname]=Z...` +- **Error handling**: The existing `MoodleClient.call()` already checks for `data.exception` in the response body, which catches Moodle application errors like `shortnametaken` and `invalidpassword`. For **batch operations**, a single item's error may reject the entire batch (Moodle version-dependent). Each batch call should be wrapped in try-catch to isolate failures to that batch. + +**Moodle token requirement:** The `MOODLE_MASTER_KEY` token must have capabilities: `moodle/course:create`, `moodle/user:create`, `enrol/manual:enrol`, `moodle/category:manage`. + +## Implementation Plan + +### Tasks + +#### Layer 1: Moodle Write API Foundation + +- [ ] Task 1: Add write web service functions to constants + - File: `src/modules/moodle/lib/moodle.constants.ts` + - Action: Add `CREATE_COURSES`, `CREATE_CATEGORIES`, `CREATE_USERS`, `ENROL_USERS` to `MoodleWebServiceFunction` enum + +- [ ] Task 2: Add array parameter serialization helper + - File: `src/modules/moodle/lib/moodle.client.ts` + - Action: Add a `serializeArrayParams(key: string, items: Record[])` private method that converts an array of objects into Moodle's indexed format (e.g., `courses[0][shortname]=X&courses[0][fullname]=Y`). Returns `Record` compatible with existing `call()`. + +- [ ] Task 3: Add write methods to MoodleClient + - File: `src/modules/moodle/lib/moodle.client.ts` + - Action: Add four new public methods: + - `createCourses(courses: MoodleCreateCourseInput[]): Promise` — calls `CREATE_COURSES` with serialized array params + - `createCategories(categories: MoodleCreateCategoryInput[]): Promise` — calls `CREATE_CATEGORIES` with serialized array params. **Caller must send one depth level at a time** — this method sends a single batch, not a multi-depth tree. + - `createUsers(users: MoodleCreateUserInput[]): Promise` — calls `CREATE_USERS` with serialized array params + - `enrolUsers(enrolments: MoodleEnrolmentInput[]): Promise` — calls `ENROL_USERS` with serialized array params. **Returns `null` on complete success** — Moodle does not return a body when all enrolments succeed. Only returns a warnings object on partial failure. + - Notes: All methods reuse existing `call()` with the array serializer. The existing `call()` already checks for `data.exception` in the response, which covers Moodle application-level errors (e.g., `shortnametaken`, `invalidpassword`). For `enrolUsers`, add a null-check on the parsed response before returning. Timeout may need to be increased for large batches (>50 items). + +- [ ] Task 4: Add write types + - File: `src/modules/moodle/lib/moodle.types.ts` + - Action: Add input/result types for write operations: + - `MoodleCreateCourseInput { shortname, fullname, categoryid, startdate?, enddate?, visible? }` + - `MoodleCreateCourseResult { id: number, shortname: string }` + - `MoodleCreateCategoryInput { name, parent?: number, description?, idnumber? }` + - `MoodleCreateCategoryResult { id: number, name: string }` + - `MoodleCreateUserInput { username, password, firstname, lastname, email }` + - `MoodleCreateUserResult { id: number, username: string }` + - `MoodleEnrolmentInput { userid: number, courseid: number, roleid: number }` + - `MoodleEnrolResult { warnings?: Array<{ item, itemid, warningcode, message }> } | null` — **Note: `enrol_manual_enrol_users` returns `null` on complete success.** All code consuming this response must null-check before accessing `.warnings`. + - Moodle role IDs: Do NOT hardcode. Read from env vars `MOODLE_ROLE_ID_STUDENT` (default: `5`) and `MOODLE_ROLE_ID_EDITING_TEACHER` (default: `3`). Add these to the Zod env schema with sensible defaults. These are Moodle install defaults but can be changed by Moodle admins. + +- [ ] Task 5: Add write method wrappers to MoodleService + - File: `src/modules/moodle/moodle.service.ts` + - Action: Add wrapper methods following existing pattern (accept DTO with token, call client method): + - `CreateCourses(dto): Promise` + - `CreateCategories(dto): Promise` + - `CreateUsers(dto): Promise` + - `EnrolUsers(dto): Promise` — mirrors client return type; callers must null-check + +#### Layer 1b: Internal Provisioning Types + +- [ ] Task 5b: Create provisioning types barrel file + - File: NEW `src/modules/moodle/lib/provisioning.types.ts` + - Action: Define all internal types used across provisioning services (follows pattern of `sync-result.types.ts`): + - `SeedContext { campus: string, department: string, startDate: string, endDate: string }` — derived from `SeedCoursesContextDto`; the controller maps DTO → SeedContext, extracting year portions (`startYear`, `endYear`, `startYY`, `endYY`) for the transform service. + - `CurriculumRow { courseCode: string, descriptiveTitle: string, program: string, semester: string }` — output of CSV parser + - `CoursePreviewRow { shortname: string, fullname: string, categoryPath: string, categoryId: number, startDate: string, endDate: string, program: string, semester: string, courseCode: string }` — preview output + - `ConfirmedCourseRow { courseCode: string, descriptiveTitle: string, program: string, semester: string, categoryId: number }` — input to execute endpoint (subset of preview, minus example shortname) + - `SkippedRow { rowNumber: number, courseCode: string, reason: string }` + - `ParseError { rowNumber: number, message: string }` + - `SeedUserRecord { username: string, firstname: string, lastname: string, email: string, password: string }` + - `ProvisionCategoriesInput`, `QuickCourseInput`, `SeedUsersInput` — service-level input types mapped from controller DTOs + +#### Layer 2: Course Transformation Engine + +- [ ] Task 6: Create course transformation service + - File: NEW `src/modules/moodle/services/moodle-course-transform.service.ts` + - Action: Create `MoodleCourseTransformService` (injectable, stateless) with methods: + - `GenerateShortname(campus: string, semester: string, startYear: string, endYear: string, courseCode: string): string` — produces `{CAMPUS}-S{sem}{startYY}{endYY}-{CourseCode}-{random5digitEDP}`. Strips spaces from course code. EDP is `crypto.randomInt(0, 100000)` zero-padded to 5 digits. + - `BuildCategoryPath(campus: string, semester: string, dept: string, program: string, startYear: string, endYear: string): string` — produces `{CAMPUS} / S{sem}{startYY}{endYY} / {DEPT} / {PROGRAM}`. + - `GetSemesterDates(semester: string, startYear: string, endYear: string): { startDate: string, endDate: string } | null` — Semester 1: `{startYear}-08-01` to `{startYear}-12-18`. Semester 2: `{endYear}-01-20` to `{endYear}-06-01`. Returns null for unrecognized semesters. + - `ComputePreview(row: CurriculumRow, context: SeedContext): CoursePreviewRow` — combines all the above for a single row. `SeedContext` holds campus, dept, startDate, endDate. + - `GenerateStudentUsername(campus: string): string` — `{campus}-{YY}{MM}{DD}{random4digits}` (zero-padded month/day) + - `GenerateFacultyUsername(campus: string): string` — `{campus}-t-{random5digits}` + - `GenerateFakeUser(campus: string, role: 'student' | 'faculty'): SeedUserRecord` — generates username (using role-appropriate format), fake first/last name, email, default password `User123#`. + - Notes: Pure functions, no DB access. Uses **`@faker-js/faker`** (install as regular dependency — needed at runtime in all environments). All string formatting ported from Rust `utils.rs`. + +- [ ] Task 7: Add unit tests for transformation service + - File: NEW `src/modules/moodle/services/moodle-course-transform.service.spec.ts` + - Action: Test all transformation methods: + - Shortname format correctness for both semesters + - Category path construction + - Semester date mapping (sem 1, sem 2, invalid) + - Username format for students and faculty + - EDP code is 5 digits zero-padded + - Course code space stripping (e.g., `BSCS 101` → `BSCS101`) + +#### Layer 3: CSV Parsing + +- [ ] Task 8: Create curriculum CSV parser + - File: NEW `src/modules/moodle/services/moodle-csv-parser.service.ts` + - Action: Create `MoodleCsvParserService` that: + - Accepts a `Buffer` (from multer) and parses via `csv-parse` + - Validates required headers exist: `Course Code`, `Descriptive Title`, `Program`, `Semester` + - Ignores extra columns + - Normalizes headers (trim whitespace) + - Returns `{ rows: CurriculumRow[], errors: ParseError[] }` where `CurriculumRow = { courseCode, descriptiveTitle, program, semester }` + - Flags rows with empty required fields in errors (with row number) + - Flags semester-0 rows as warnings (not errors) — included in output but marked + - Notes: Reuse `csv-parse` patterns from `csv.adapter.ts`. No streaming needed — curriculum CSVs are <200 rows. + +#### Layer 4: Provisioning Service + +- [ ] Task 9: Create provisioning service + - File: NEW `src/modules/moodle/services/moodle-provisioning.service.ts` + - Action: Create `MoodleProvisioningService` with: + - Constructor injects: `MoodleService`, `EntityManager`, `MoodleCourseTransformService`, `MoodleCsvParserService`, `MoodleCategorySyncService`, `Logger` + - Note: No `UnitOfWork` — this service writes to Moodle only, no local DB transactions. + - **Concurrency guard**: Maintain an in-memory `Set` of active operation types (e.g., `'categories'`, `'courses'`, `'users'`). Before executing any write operation, check if the operation type is in-flight. If so, throw `ConflictException('A provisioning operation is already in progress')`. Add to set on start, remove in `finally` block. This prevents double-clicks and concurrent admins from creating duplicates. + - `ProvisionCategories(input: ProvisionCategoriesInput): Promise`: + 1. Fetch existing categories from Moodle via `MoodleService.GetCategories()` + 2. Build the desired tree from input (campus × semester-tag × dept × program combinations) + 3. Diff against existing — collect missing nodes, grouped by depth level. **Matching rule**: exact case-sensitive string match on `name` field within the same `parent` ID. Moodle category names are case-sensitive. Since all names are generated from uppercase institutional codes (e.g., `UCMN`, `S12526`, `CCS`, `BSCS`), matching is deterministic. + 4. Create missing nodes **one depth level at a time** (4 sequential API calls max): depth 1 (campuses) → await response to get new IDs → depth 2 (semesters, using parent IDs from depth 1) → await → depth 3 (departments) → await → depth 4 (programs). Each call to `MoodleService.CreateCategories()` sends all nodes at that depth as one batch. + 5. **Auto-sync local entities**: After all categories are created in Moodle, call `MoodleCategorySyncService.SyncAndRebuildHierarchy()` to populate local Campus/Semester/Department/Program entities with the new `moodleCategoryId` values. This eliminates the manual sync step — the admin can proceed directly to course seeding. + 6. Return result with per-item details: each category name + status (`created` | `skipped` | `error`) + reason if error. Include a `syncCompleted: true` flag. + - `PreviewCourses(file: Buffer, context: SeedContext): Promise`: + 1. Parse CSV via `MoodleCsvParserService` + 2. Transform each valid row via `MoodleCourseTransformService.ComputePreview()` — note: shortnames generated here are **examples only** (EDP suffixes will be regenerated at execution time) + 3. Resolve category path → `moodleCategoryId` for each row via entity query (Program → Department → Semester → Campus chain) + 4. Flag rows where category doesn't exist in local DB + 5. Return `{ valid: PreviewRow[], skipped: SkippedRow[], errors: ParseError[] }`. Include a note in the response: `"shortnameNote": "EDP codes are examples. Final codes are generated at execution time."` + - `ExecuteCourseSeeding(confirmedRows: ConfirmedCourseRow[]): Promise`: + - `ConfirmedCourseRow` contains: `courseCode`, `descriptiveTitle`, `program`, `semester`, `categoryId` (from preview resolution). The controller maps incoming `CoursePreviewRowDto[]` to `ConfirmedCourseRow[]`, dropping preview-only fields (example shortname, categoryPath string). + 1. **Regenerate EDP suffixes** — build fresh `MoodleCreateCourseInput[]` from confirmed rows, generating new random EDP codes for each shortname (preview codes are discarded). Reuse `categoryId` from preview (already resolved). This avoids TOCTOU race conditions on shortnames. + 2. Call `MoodleService.CreateCourses()` in batches (max 50 per call). **Per-batch error handling**: wrap each batch call in try-catch. On success, record all items as `created`. On failure (e.g., `shortnametaken`), record all items in that batch as `error` with the Moodle error message. Accumulate results across batches. + 3. Return `ProvisionResult` with per-item `details` array populated: each course shortname + status + moodleCourseId (if created) + error reason (if failed). + - `PreviewQuickCourse(input: QuickCourseInput): CoursePreviewRow`: + 1. Apply transformation to single course input (generates example EDP) + 2. Resolve category ID + 3. Return preview (shortname, category path, dates). Shortname is an example. + - `ExecuteQuickCourse(input: QuickCourseInput): Promise`: + 1. Transform single course (fresh EDP generated). **Re-resolve category ID** from form inputs (campus, dept, program, semester, dates) — do not trust any client-supplied category ID. This avoids stale category references. + 2. Call `MoodleService.CreateCourses()` with single-item array + 3. Return result with per-item detail + - `SeedUsers(input: SeedUsersInput): Promise`: + 1. Generate `input.count` fake users via `MoodleCourseTransformService.GenerateFakeUser()`. If a generated username collides (checked via `Set`), regenerate up to 3 times before marking as failed. + 2. Call `MoodleService.CreateUsers()` in batches (max 50 per call). **Per-batch error handling**: same pattern as course seeding — track created users (with Moodle user IDs) and failed users per batch. + 3. Build enrolments: each **successfully created** user × each target course, with roleid from env config. + 4. Call `MoodleService.EnrolUsers()` in batches. Handle null success response (see F5). + 5. Return `SeedUsersResult` with: `usersCreated`, `usersFailed`, `enrolmentsCreated`, per-user details, any warnings. + +#### Layer 5: DTOs + +- [ ] Task 10: Create request DTOs + - File: NEW `src/modules/moodle/dto/requests/provision-categories.request.dto.ts` + - Action: `ProvisionCategoriesRequestDto` with fields: `campuses: string[]`, `semesters: number[]` (1 and/or 2), `startDate: string` (ISO 8601 `YYYY-MM-DD`), `endDate: string` (ISO 8601 `YYYY-MM-DD`), `departments: { code: string, programs: string[] }[]`. Use **class-validator** decorators (consistent with existing DTOs in the codebase). Swagger decorators. + - File: NEW `src/modules/moodle/dto/requests/seed-courses.request.dto.ts` + - Action: `SeedCoursesContextDto` with fields: `campus: string`, `department: string`, `startDate: string` (ISO 8601), `endDate: string` (ISO 8601). Add cross-field validation: `startDate` must be before `endDate`. Used alongside file upload (multipart form body fields). + - File: NEW `src/modules/moodle/dto/requests/quick-course.request.dto.ts` + - Action: `QuickCourseRequestDto` with fields: `courseCode: string`, `descriptiveTitle: string`, `campus: string`, `department: string`, `program: string`, `semester: number`, `startDate: string` (ISO 8601), `endDate: string` (ISO 8601). Add cross-field validation: `startDate` must be before `endDate`. + - File: NEW `src/modules/moodle/dto/requests/seed-users.request.dto.ts` + - Action: `SeedUsersRequestDto` with fields: `count: number` (1-200), `role: 'student' | 'faculty'`, `campus: string`, `courseIds: number[]` (Moodle course IDs to enrol into, `@ArrayMinSize(1)` — at least one course required). + +- [ ] Task 11: Create response DTOs + - File: NEW `src/modules/moodle/dto/responses/provision-result.response.dto.ts` + - Action: `ProvisionResultDto` with fields: `created: number`, `skipped: number`, `errors: number`, `details: { name: string, status: 'created' | 'skipped' | 'error', reason?: string, moodleId?: number }[]`, `durationMs: number`, `syncCompleted?: boolean` (only present on category provisioning responses). + - File: NEW `src/modules/moodle/dto/responses/course-preview.response.dto.ts` + - Action: `CoursePreviewResultDto` with: `valid: CoursePreviewRowDto[]` (shortname, fullname, categoryPath, categoryId, startDate, endDate, program, semester, courseCode), `skipped: SkippedRowDto[]` (row number, courseCode, reason), `errors: ParseErrorDto[]` (row number, message), `shortnameNote: string` ("EDP codes are examples. Final codes are generated at execution time."). + - File: NEW `src/modules/moodle/dto/responses/seed-users-result.response.dto.ts` + - Action: `SeedUsersResultDto` with: `usersCreated: number`, `usersFailed: number`, `enrolmentsCreated: number`, `warnings: string[]`, `durationMs: number`. + +#### Layer 6: Controller & Module Wiring + +- [ ] Task 12: Create provisioning controller + - File: NEW `src/modules/moodle/controllers/moodle-provisioning.controller.ts` + - Action: Create `MoodleProvisioningController` under `@Controller('moodle/provision')` with endpoints: + - `POST /moodle/provision/categories` — `@UseJwtGuard(UserRole.SUPER_ADMIN)`. Body: `ProvisionCategoriesRequestDto`. Calls `ProvisioningService.ProvisionCategories()`. Returns `ProvisionResultDto`. + - `POST /moodle/provision/courses/preview` — `@UseJwtGuard(UserRole.SUPER_ADMIN)`. Multipart: CSV file + `SeedCoursesContextDto` body fields. Uses `FileInterceptor('file', { fileFilter: csvFileFilter, limits: { fileSize: 2MB } })`. Calls `ProvisioningService.PreviewCourses()`. Returns `CoursePreviewResultDto`. + - `POST /moodle/provision/courses/execute` — `@UseJwtGuard(UserRole.SUPER_ADMIN)`. Body: confirmed preview rows (array of `CoursePreviewRowDto`). Calls `ProvisioningService.ExecuteCourseSeeding()`. Returns `ProvisionResultDto`. + - `POST /moodle/provision/courses/quick` — `@UseJwtGuard(UserRole.SUPER_ADMIN)`. Body: `QuickCourseRequestDto`. Calls `ProvisioningService.ExecuteQuickCourse()`. Returns `ProvisionResultDto`. + - `POST /moodle/provision/courses/quick/preview` — `@UseJwtGuard(UserRole.SUPER_ADMIN)`. Body: `QuickCourseRequestDto`. Calls `ProvisioningService.PreviewQuickCourse()`. Returns single `CoursePreviewRowDto`. (Used for live preview in admin console.) + - `POST /moodle/provision/users` — `@UseJwtGuard(UserRole.SUPER_ADMIN)`. Body: `SeedUsersRequestDto`. Calls `ProvisioningService.SeedUsers()`. Returns `SeedUsersResultDto`. + - Notes: All endpoints require `SUPER_ADMIN` role. Add `@Audited()` decorator on execute endpoints (not preview). Add Swagger decorators on all endpoints and parameters. + +- [ ] Task 13: Register in MoodleModule + - File: `src/modules/moodle/moodle.module.ts` + - Action: + - Add to `providers`: `MoodleCourseTransformService`, `MoodleCsvParserService`, `MoodleProvisioningService` + - Add to `controllers`: `MoodleProvisioningController` + - Add `MoodleProvisioningService` to `exports` (optional, for potential use by other modules) + +#### Layer 7: Testing + +- [ ] Task 14: Unit tests for CSV parser + - File: NEW `src/modules/moodle/services/moodle-csv-parser.service.spec.ts` + - Action: Test: + - Valid CSV with 4 required columns parses correctly + - Extra columns are ignored + - Missing required header throws descriptive error + - Empty required fields flagged per row + - Semester-0 rows flagged as warnings + - Whitespace in headers is trimmed + +- [ ] Task 15: Unit tests for provisioning service + - File: NEW `src/modules/moodle/services/moodle-provisioning.service.spec.ts` + - Action: Test with mocked `MoodleService`, `EntityManager`, `MoodleCourseTransformService`, `MoodleCsvParserService`, `MoodleCategorySyncService`: + - Category provisioning: creates categories one depth level at a time (4 sequential calls), skips existing, triggers auto-sync after creation + - Course preview: valid rows transformed, semester-0 flagged, missing category flagged, preview note present + - Course execution: regenerates EDP suffixes (not reused from preview), batches calls in groups of 50, per-batch error handling populates details array + - Quick course: single course transform + create + - User seeding: generates correct count, handles username collisions with retry, enrols only successfully created users, handles null enrol response + - Concurrency guard: second concurrent request throws ConflictException + - Partial failure: batch 1 succeeds + batch 2 fails → response shows both created and failed items + +- [ ] Task 16: Unit tests for provisioning controller + - File: NEW `src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts` + - Action: Test endpoint routing, guard enforcement, DTO validation, file upload handling. Mock `MoodleProvisioningService`. + +### Acceptance Criteria + +#### Category Provisioning + +- [ ] AC-1: Given valid campus/semester/dept/program inputs, when `POST /moodle/provision/categories` is called, then categories are created depth-first in Moodle and the response shows created count and names. +- [ ] AC-2: Given some categories already exist in Moodle, when provisioning the same tree, then existing categories are skipped and only missing nodes are created. +- [ ] AC-3: Given a campus code that does not match any existing top-level category in Moodle (checked via `GetCategories()` in step 1), when provisioning categories, then the campus is created as a new top-level category in Moodle (not rejected). Validation is limited to DTO format — the provisioning service creates whatever the admin requests. + +#### Bulk Course Seeding + +- [ ] AC-4: Given a valid curriculum CSV with `Course Code`, `Descriptive Title`, `Program`, `Semester` columns and valid form context (campus, dept, dates), when `POST /moodle/provision/courses/preview` is called, then a preview is returned with transformed shortnames, category paths, and dates for each valid row. +- [ ] AC-5: Given a CSV containing semester-0 rows (electives), when previewing, then those rows appear in the `skipped` array with reason "No semester assigned — use Quick Course Create". +- [ ] AC-6: Given a CSV with extra columns (units, prerequisites, type), when previewing, then extra columns are silently ignored and only required columns are processed. +- [ ] AC-7: Given a CSV with rows where the category path cannot be resolved (category doesn't exist in Moodle), when previewing, then those rows appear in `skipped` with reason "Category not found: {path}. Provision categories first." +- [ ] AC-8: Given confirmed preview rows, when `POST /moodle/provision/courses/execute` is called, then courses are created in Moodle and the response includes Moodle course IDs and shortnames. +- [ ] AC-9: Given duplicate course codes (e.g., `CS-EL` appearing 3 times), when previewing, then each gets a unique shortname (different EDP suffix) and all are included in the valid preview. +- [ ] AC-10: Given a file that is not CSV or exceeds 2MB, when uploading, then a 400 error is returned. + +#### Quick Course Create + +- [ ] AC-11: Given all required fields (course code, title, campus, dept, program, semester, dates), when `POST /moodle/provision/courses/quick/preview` is called, then the response includes the generated shortname, category path, and dates. +- [ ] AC-12: Given the same fields, when `POST /moodle/provision/courses/quick` is called, then the course is created in Moodle and the response includes the Moodle course ID. +- [ ] AC-13: Given a semester value that doesn't produce valid dates (not 1 or 2), when calling quick create, then a 400 error with descriptive message is returned. + +#### User Seeding + +- [ ] AC-14: Given count=10, role=student, campus=ucmn, and a list of Moodle course IDs, when `POST /moodle/provision/users` is called, then 10 fake users are created in Moodle with `ucmn-{date-based}` usernames and enrolled into all specified courses with student role. +- [ ] AC-15: Given role=faculty, when seeding users, then usernames follow the `{campus}-t-{5digits}` pattern and enrolments use `editingteacher` role (roleid=3). +- [ ] AC-16: Given count exceeds 200, when seeding users, then a 400 validation error is returned. + +#### Cross-Cutting + +- [ ] AC-17: Given a non-SUPER_ADMIN user, when calling any provisioning endpoint, then a 403 Forbidden response is returned. +- [ ] AC-18: Given the Moodle instance is unreachable, when any provisioning endpoint is called, then a `MoodleConnectivityError` is raised and returned as a clear error response. +- [ ] AC-19: Given a batch of 80 courses where batch 2 (items 51-80) fails due to a Moodle error, when executing course seeding, then the response reports items 1-50 as `created` with their Moodle IDs and items 51-80 as `error` with the failure reason. The admin knows exactly what succeeded and what didn't. +- [ ] AC-20: Given a provisioning operation is already in progress (e.g., courses being seeded), when another admin hits the same execute endpoint, then a 409 Conflict response is returned. +- [ ] AC-21: Given category provisioning completes successfully, when the response is returned, then the local entities (Campus, Semester, Department, Program) are already populated with `moodleCategoryId` values (auto-sync completed). The admin can proceed directly to course seeding without manual sync. +- [ ] AC-22: Given category provisioning creates categories in Moodle but `SyncAndRebuildHierarchy()` fails, when the response is returned, then the response includes `syncCompleted: false` and a warning: "Categories created in Moodle but local sync failed. Trigger a manual sync before seeding courses." The Moodle-side creation is not rolled back. +- [ ] AC-23: Given a request with `startDate` after `endDate`, when calling any provisioning endpoint that accepts dates, then a 400 validation error is returned. +- [ ] AC-24: Given a CSV with headers but zero data rows, when calling the preview endpoint, then a 200 response is returned with `valid: [], skipped: [], errors: []`. This is not an error — just nothing to process. +- [ ] AC-25: Given `courseIds: []` (empty array) in the seed users request, when calling `POST /moodle/provision/users`, then a 400 validation error is returned ("At least one course ID is required"). + +## Admin Console Implementation + +**Project:** `admin.faculytics` (React 19, Vite 8, Tailwind 4, shadcn/ui, TanStack React Query, Zustand, native `fetch`) + +### Admin Console Context + +- **API client**: `apiClient(path, options)` in `src/lib/api-client.ts` — native `fetch` wrapper with Bearer token injection and silent 401 refresh. **Important for file uploads**: `apiClient` auto-sets `Content-Type: application/json` when body is present. For `FormData` bodies (CSV upload), the auto Content-Type must be skipped so the browser sets the multipart boundary. **Check `apiClient` implementation**: if it always sets `Content-Type`, add a guard: `if (!(options.body instanceof FormData))` before setting the header. If it only sets Content-Type for string bodies, it already works. Document which case applies and patch if needed (Task 18a). +- **React Query**: `useQuery` for reads, `useMutation` for writes. Hooks live alongside feature components. +- **Forms**: Simple `useState` — no react-hook-form or Zod. Controlled inputs with shadcn/ui components. +- **Feature folder pattern**: `src/features/moodle-provision/` with hooks, components, and the page. +- **Routing**: React Router v7 in `src/routes.tsx`. Protected routes inside `AuthGuard` children. +- **Navigation**: `navItems` array in `src/components/layout/app-shell.tsx`. +- **Types**: All API DTOs in `src/types/api.ts`. +- **Pattern reference**: `src/features/moodle-sync/` (sync dashboard) and `src/features/submission-generator/` (multi-step form with preview → confirm). +- **Toast notifications**: `toast` from `sonner` for success/error feedback. + +### Admin Console Files to Reference + +| File | Purpose | +| ---------------------------------------------------------------- | ------------------------------------------------------------------- | +| `src/lib/api-client.ts` | Fetch wrapper — understand auth injection and Content-Type behavior | +| `src/routes.tsx` | Add new route here | +| `src/components/layout/app-shell.tsx` | Add nav item to `navItems` array | +| `src/types/api.ts` | Add provisioning response/request types here | +| `src/features/moodle-sync/sync-dashboard.tsx` | Pattern reference: Moodle feature page layout | +| `src/features/moodle-sync/use-trigger-sync.ts` | Pattern reference: useMutation with toast + error handling | +| `src/features/submission-generator/generator-page.tsx` | Pattern reference: multi-step form (selection → preview → commit) | +| `src/features/submission-generator/components/preview-panel.tsx` | Pattern reference: preview table with confirm button | + +### Admin Console Tasks + +#### Layer 8: Types & API Hooks + +- [ ] Task 17: Add provisioning types to admin console + - File: `admin.faculytics/src/types/api.ts` + - Action: Add TypeScript types mirroring the API response/request DTOs exactly: + - `ProvisionCategoriesRequest { campuses: string[], semesters: number[], startDate: string, endDate: string, departments: { code: string, programs: string[] }[] }` + - `ProvisionResultResponse { created: number, skipped: number, errors: number, details: ProvisionDetailItem[], durationMs: number, syncCompleted?: boolean }` + - `ProvisionDetailItem { name: string, status: 'created' | 'skipped' | 'error', reason?: string, moodleId?: number }` + - `SeedCoursesContext { campus: string, department: string, startDate: string, endDate: string }` + - `CoursePreviewResponse { valid: CoursePreviewRow[], skipped: SkippedRow[], errors: ParseError[], shortnameNote: string }` + - `CoursePreviewRow { shortname: string, fullname: string, categoryPath: string, categoryId: number, startDate: string, endDate: string, program: string, semester: string, courseCode: string }` + - `SkippedRow { rowNumber: number, courseCode: string, reason: string }` + - `ParseError { rowNumber: number, message: string }` + - `ExecuteCoursesRequest { rows: ConfirmedCourseRow[] }` — sent to the execute endpoint + - `ConfirmedCourseRow { courseCode: string, descriptiveTitle: string, program: string, semester: string, categoryId: number }` — subset of `CoursePreviewRow`, minus preview-only fields (shortname, categoryPath, dates). The admin frontend maps checked `CoursePreviewRow` objects to `ConfirmedCourseRow` by picking these 5 fields. + - `QuickCourseRequest { courseCode: string, descriptiveTitle: string, campus: string, department: string, program: string, semester: number, startDate: string, endDate: string }` + - `SeedUsersRequest { count: number, role: 'student' | 'faculty', campus: string, courseIds: number[] }` + - `SeedUsersResponse { usersCreated: number, usersFailed: number, enrolmentsCreated: number, warnings: string[], durationMs: number }` + +- [ ] Task 18: Create React Query hooks + - File: NEW `admin.faculytics/src/features/moodle-provision/use-provision-categories.ts` + - Action: `useMutation` calling `POST /moodle/provision/categories`. On success: `toast.success()`, invalidate relevant queries. On 409: `toast.error('Operation already in progress')`. +- [ ] Task 18a: Patch apiClient for FormData support (if needed) + - File: `admin.faculytics/src/lib/api-client.ts` + - Action: Check how `apiClient` sets `Content-Type`. If it unconditionally sets `application/json` when body is present, add a guard: `if (options.body && !(options.body instanceof FormData)) { headers['Content-Type'] = 'application/json' }`. This lets the browser set the multipart boundary for FormData bodies automatically. If it already only sets Content-Type for string bodies, no change needed — just document this in a comment. + + - File: NEW `admin.faculytics/src/features/moodle-provision/use-preview-courses.ts` + - Action: `useMutation` calling `POST /moodle/provision/courses/preview`. Must send `FormData` (file + context fields). The `apiClient` (patched in Task 18a) will not set Content-Type for FormData bodies. Example: + ``` + const formData = new FormData() + formData.append('file', csvFile) + formData.append('campus', context.campus) + formData.append('department', context.department) + formData.append('startDate', context.startDate) + formData.append('endDate', context.endDate) + return apiClient('/moodle/provision/courses/preview', { + method: 'POST', + body: formData, + }) + ``` + - File: NEW `admin.faculytics/src/features/moodle-provision/use-execute-courses.ts` + - Action: `useMutation` calling `POST /moodle/provision/courses/execute` with `ExecuteCoursesRequest` body. The component maps checked `CoursePreviewRow[]` to `ConfirmedCourseRow[]` by picking `{ courseCode, descriptiveTitle, program, semester, categoryId }` from each row. The preview-only fields (shortname, categoryPath, dates) are discarded. + - File: NEW `admin.faculytics/src/features/moodle-provision/use-quick-course.ts` + - Action: Two hooks in one file: + - `useQuickCoursePreview()` — `useMutation` calling `POST /moodle/provision/courses/quick/preview`. The component debounces by holding form values in a debounced state (300ms) and calling `mutation.mutate(debouncedValues)` in a `useEffect` when all required fields are filled. Pattern: + ``` + const [debounced] = useDebouncedValue(formValues, 300) + const preview = useQuickCoursePreview() + useEffect(() => { + if (allFieldsFilled(debounced)) preview.mutate(debounced) + }, [debounced]) + // preview.data holds the current preview result + ``` + Implement `useDebouncedValue` as a small utility hook (useState + useEffect with setTimeout/clearTimeout) in the same file or in `src/lib/utils.ts`. + - `useQuickCourseCreate()` — `useMutation` calling `POST /moodle/provision/courses/quick`. + - File: NEW `admin.faculytics/src/features/moodle-provision/use-seed-users.ts` + - Action: `useMutation` calling `POST /moodle/provision/users`. + +#### Layer 9: Shared Components + +- [ ] Task 19: Create CSV drop zone component + - File: NEW `admin.faculytics/src/features/moodle-provision/components/csv-drop-zone.tsx` + - Action: Reusable file upload component that: + - Accepts drag-and-drop or click-to-browse + - Filters to `.csv` files only + - Shows selected filename + size + - Has a "Remove" button to clear selection + - Passes `File` object to parent via `onFileSelect(file: File | null)` callback + - Uses shadcn/ui `Button` and basic Tailwind styling (dashed border drop area) + - Notes: Keep it simple — no progress bars (file is sent in one shot). Style with Tailwind dashed border, upload icon, drag-over highlight. + +- [ ] Task 20: Create provision result dialog + - File: NEW `admin.faculytics/src/features/moodle-provision/components/provision-result-dialog.tsx` + - Action: Shared dialog for showing operation results. Props: `result: ProvisionResultResponse`, `open`, `onClose`. Shows: + - Summary: "{created} created, {skipped} skipped, {errors} errors" with color badges + - Duration: "Completed in {durationMs}ms" + - Details table: scrollable list of per-item results (name, status badge, reason if error) + - If `syncCompleted === false`: warning alert "Local sync failed. Trigger manual sync." + - Close button + - Uses shadcn/ui `Dialog`, `Table`, `Badge`, `Alert`. + +#### Layer 10: Tab Components + +- [ ] Task 21: Create Categories tab + - File: NEW `admin.faculytics/src/features/moodle-provision/components/categories-tab.tsx` + - Action: Form with: + - Campus multi-select (checkboxes for known campuses). Define `CAMPUSES = ['UCMN', 'UCLM', 'UCB', 'UCMETC', 'UCPT']` as a constant in `admin.faculytics/src/lib/constants.ts` — these are institutional constants. Add a comment: `// Update when new campuses are added to the institution`. + - Semester checkboxes (1, 2) + - Start date + end date inputs (ISO 8601, with validation that start < end) + - Department + program builder: a list where admin adds department codes, and under each adds program codes. Use a simple input + "Add" button pattern with removable chips/tags. + - "Provision" button — calls `useProvisionCategories` mutation. Button shows loading spinner and is disabled during mutation. + - Shows `ProvisionResultDialog` on completion + - **On result dialog close**: reset form to initial state (all selections cleared) + - Notes: Follow the Submission Generator's selection → confirm pattern. No preview table needed — the form inputs are explicit enough. + +- [ ] Task 22: Create Courses Bulk tab + - File: NEW `admin.faculytics/src/features/moodle-provision/components/courses-bulk-tab.tsx` + - Action: Two-step flow: + - **Step 1 (Upload + Preview)**: Context form (campus, department, start date, end date) + `CsvDropZone`. "Preview" button calls `usePreviewCourses` mutation. On success, show preview results. + - **Step 2 (Review + Confirm)**: Show `shortnameNote` banner. Split display: + - Green table: valid rows (shortname, fullname, categoryPath, dates). Each row has a checkbox (all selected by default). Admin can deselect rows. + - Yellow section: skipped rows with reasons + - Red section: parse errors with row numbers + - "Create {n} Courses" button — sends only checked valid rows to `useExecuteCourses` mutation + - Shows `ProvisionResultDialog` on completion + - **Interaction states**: + - "Preview" button disabled if any required field is empty or no file selected. Shows loading spinner during mutation. + - On preview mutation error (400 — invalid file, bad headers): stay on Step 1, show toast error with the API error message. Do NOT transition to Step 2. + - "Create N Courses" button disabled during execution. Shows loading spinner. + - Tab navigation disabled during in-flight mutations (prevent abandonment). + - **On result dialog close**: reset to Step 1 (clear form, clear file, clear preview data). + - Notes: Use shadcn/ui `Table`, `Checkbox`, `Badge`. Back button to return to Step 1. + +- [ ] Task 23: Create Quick Course tab + - File: NEW `admin.faculytics/src/features/moodle-provision/components/quick-course-tab.tsx` + - Action: Single form with live preview: + - Fields: courseCode, descriptiveTitle, campus (select), department (input), program (input), semester (select: 1 or 2), startDate, endDate + - **Live preview card** below the form: shows generated shortname, category path, start/end dates. Updates automatically as fields change (debounced via `useQuickCoursePreview` hook). Shows loading spinner while fetching. Shows nothing if required fields are incomplete. + - **Preview card states**: Loading spinner while fetching. Empty if required fields incomplete. On error (e.g., category not found): show red inline error message in the preview card (NOT a toast — the admin is watching the card). On success: show shortname, categoryPath, dates. + - "Create Course" button — calls `useQuickCourseCreate` mutation. Disabled until all fields filled and preview is successful. Shows loading spinner during mutation. + - Shows `ProvisionResultDialog` on completion + - **On result dialog close**: keep form filled (admin may want to create another similar course). Only the result dialog closes. + - Notes: Preview card uses shadcn/ui `Card` with monospace font for shortname. + +- [ ] Task 24: Create Seed Users tab + - File: NEW `admin.faculytics/src/features/moodle-provision/components/seed-users-tab.tsx` + - Action: Form with: + - Count: number input (1-200) + - Role: toggle or select (Student / Faculty) + - Campus: select dropdown + - Course IDs: text input where admin enters comma-separated Moodle course IDs. **Parsing logic**: trim whitespace around commas, filter empty strings, parse each to `parseInt()`, reject `NaN` values, deduplicate. Show inline validation error below the input if any value is non-numeric (e.g., "Invalid course ID: 'abc'"). Validate at least one valid ID present. + - "Generate & Enrol" button — calls `useSeedUsers` mutation. Show confirmation dialog before executing ("Generate {count} {role} users and enrol into {n} courses?"). Button disabled during mutation, shows loading spinner. + - Shows result: users created, users failed, enrolments created, any warnings + - **On result dialog close**: clear form to initial state + - Notes: Use shadcn/ui `Input`, `Select`, `AlertDialog` for confirmation. Future improvement: replace manual course ID input with a course selector dropdown (out of scope per spec). + +#### Layer 11: Page & Routing + +- [ ] Task 25: Create provision page with tabs + - File: NEW `admin.faculytics/src/features/moodle-provision/provision-page.tsx` + - Action: Page component with: + - Page title: "Moodle Provisioning" + - Tabbed layout using shadcn/ui `Tabs` (add via `bunx shadcn add tabs` if not installed) + - 4 tabs: Categories, Courses (Bulk), Quick Course, Seed Users + - Each tab renders its respective component + - Default tab: Categories (first step in the workflow) + +- [ ] Task 26: Add route and navigation + - File: `admin.faculytics/src/routes.tsx` + - Action: Add `{ path: '/moodle-provision', element: }` inside AuthGuard children, after the `/sync` route. + - File: `admin.faculytics/src/components/layout/app-shell.tsx` + - Action: Add to `navItems` array: `{ to: '/moodle-provision', label: 'Moodle Provision', icon: Hammer }` (or `Wrench`, `Database` — pick an appropriate lucide icon). Place it after "Moodle Sync" in the nav order. + +### Admin Console Acceptance Criteria + +#### Navigation & Layout + +- [ ] AC-26: Given an authenticated SUPER_ADMIN, when viewing the sidebar, then "Moodle Provision" appears as a nav item below "Moodle Sync" and navigating to it shows a tabbed page with 4 tabs. + +#### Categories Tab + +- [ ] AC-27: Given the admin selects campuses, semesters, dates, and adds departments with programs, when clicking "Provision", then the API is called and a result dialog shows created/skipped counts per category. +- [ ] AC-28: Given the result includes `syncCompleted: false`, when the result dialog is shown, then a warning alert is displayed about manual sync needed. + +#### Courses Bulk Tab + +- [ ] AC-29: Given the admin fills context fields and uploads a CSV, when clicking "Preview", then a split table shows valid rows (green), skipped rows (yellow with reason), and errors (red with row number). +- [ ] AC-30: Given a preview with valid rows, when the admin deselects some rows and clicks "Create", then only the checked rows are sent to the execute endpoint. +- [ ] AC-31: Given the preview response, then a `shortnameNote` banner is displayed above the table explaining that EDP codes are examples. + +#### Quick Course Tab + +- [ ] AC-32: Given the admin fills all required fields, when typing, then a live preview card shows the generated shortname and category path (debounced, updates automatically). +- [ ] AC-33: Given the admin clicks "Create Course", when the API returns success, then a result dialog shows the created course with its Moodle ID. + +#### Seed Users Tab + +- [ ] AC-34: Given the admin enters count, role, campus, and course IDs, when clicking "Generate & Enrol", then a confirmation dialog appears before executing. +- [ ] AC-35: Given the operation completes, when the result is shown, then it displays users created, enrolments created, and any warnings. + +#### Error Handling + +- [ ] AC-36: Given a 409 Conflict response (operation in progress), when any execute button is clicked, then a toast error "A provisioning operation is already in progress" is shown. +- [ ] AC-37: Given a Moodle connectivity error, when any operation fails, then a toast error with the failure message is shown. + +## Additional Context + +### Dependencies + +- **`@faker-js/faker`**: Install as a **regular dependency** (not devDependency — needed at runtime for user seeding in all environments). Used for generating realistic first/last names and emails. +- **New env vars** (add to Zod env schema with defaults): + - `MOODLE_ROLE_ID_STUDENT` (default: `5`) — Moodle role ID for student enrolment + - `MOODLE_ROLE_ID_EDITING_TEACHER` (default: `3`) — Moodle role ID for faculty enrolment +- **Moodle web service capabilities**: The `MOODLE_MASTER_KEY` token must have write capabilities enabled: `moodle/course:create`, `moodle/user:create`, `enrol/manual:enrol`, `moodle/category:manage`. This is a Moodle admin configuration step, not a code change. +- **Existing local entities**: The `ProvisionCategories` endpoint **auto-syncs** local entities after creating categories in Moodle (calls `MoodleCategorySyncService.SyncAndRebuildHierarchy()`). No manual sync step needed. Course seeding preview depends on these local entities existing (for category path → ID resolution). Order of operations: provision categories → (auto-sync happens) → seed courses. +- **No new database migrations**: This feature only writes to Moodle via REST API. No new local entities or DB schema changes needed. + +### Testing Strategy + +- **Unit tests** (primary): All three new services (`MoodleCourseTransformService`, `MoodleCsvParserService`, `MoodleProvisioningService`) and the controller get co-located `.spec.ts` files. Mock `MoodleService` and `EntityManager` — never hit real Moodle in unit tests. +- **Manual integration testing**: After implementation, manually test against the real Moodle instance: + 1. Provision a small category tree (1 campus × 1 semester × 1 dept × 1 program) + 2. Upload a curriculum CSV with 5 courses and verify preview + 3. Execute and verify courses appear in Moodle + 4. Quick-create an elective course + 5. Seed 5 students, verify they appear enrolled in Moodle +- **No E2E tests**: Moodle dependency makes E2E unreliable. Unit test coverage + manual verification is sufficient. + +### Notes + +- **Batch size**: Define a named constant `MOODLE_PROVISION_BATCH_SIZE = 50` in `provisioning.types.ts`. All batch operations reference this constant. Adjust if the Moodle instance rejects larger batches. +- **Idempotency**: Course creation is NOT idempotent — Moodle will reject duplicate shortnames. The random EDP suffix makes collisions very unlikely (1 in 100,000), but the API handles `MoodleException` with `shortnametaken` gracefully via per-batch error catching and reports which courses failed in the `details` array. +- **Preview vs. execution shortnames**: Preview generates example EDP codes for display purposes. Execution regenerates fresh EDP codes. The admin is warned in the preview response that final shortnames will differ slightly. +- **Username collision handling**: Student usernames use zero-padded date + 4 random digits (10,000 combinations per day). For large seeding runs (>100 on same day), collisions are possible. The service retries generation up to 3 times per user on collision. +- **Future consideration**: If the admin console needs to show available courses for user enrollment (in the Seed Users form), a `GET /moodle/provision/courses` endpoint that lists recently-created courses could be useful. Out of scope for now — the admin can get course IDs from the course seeding response. +- **Concurrency guard limitation**: The in-memory `Set` guard assumes a single API instance. If the API runs behind a load balancer with multiple instances, the guard is ineffective. This is acceptable for a seeding tool used by 1-2 super admins. The guard auto-clears on process restart. +- **Rust script retirement**: Once this feature is stable, `script.csv.faculytics` can be archived. The API fully replaces its functionality with better validation and error reporting. diff --git a/_bmad/_config/agent-manifest.csv b/_bmad/_config/agent-manifest.csv index 1cd6ba3..7a5050f 100644 --- a/_bmad/_config/agent-manifest.csv +++ b/_bmad/_config/agent-manifest.csv @@ -15,3 +15,4 @@ name,displayName,title,icon,role,identity,communicationStyle,principles,module,p "innovation-strategist","Victor","Disruptive Innovation Oracle","⚡","Business Model Innovator + Strategic Disruption Expert","Legendary strategist who architected billion-dollar pivots. Expert in Jobs-to-be-Done, Blue Ocean Strategy. Former McKinsey consultant.","Speaks like a chess grandmaster - bold declarations, strategic silences, devastatingly simple questions","Markets reward genuine new value. Innovation without business model thinking is theater. Incremental thinking means obsolete.","cis","_bmad/cis/agents/innovation-strategist.md" "presentation-master","Caravaggio","Visual Communication + Presentation Expert","🎨","Visual Communication Expert + Presentation Designer + Educator","Master presentation designer who's dissected thousands of successful presentations—from viral YouTube explainers to funded pitch decks to TED talks. Understands visual hierarchy, audience psychology, and information design. Knows when to be bold and casual, when to be polished and professional. Expert in Excalidraw's frame-based presentation capabilities and visual storytelling across all contexts.","Energetic creative director with sarcastic wit and experimental flair. Talks like you're in the editing room together—dramatic reveals, visual metaphors, "what if we tried THIS?!" energy. Treats every project like a creative challenge, celebrates bold choices, roasts bad design decisions with humor.","- Know your audience - pitch decks ≠ YouTube thumbnails ≠ conference talks - Visual hierarchy drives attention - design the eye's journey deliberately - Clarity over cleverness - unless cleverness serves the message - Every frame needs a job - inform, persuade, transition, or cut it - Test the 3-second rule - can they grasp the core idea that fast? - White space builds focus - cramming kills comprehension - Consistency signals professionalism - establish and maintain visual language - Story structure applies everywhere - hook, build tension, deliver payoff","cis","_bmad/cis/agents/presentation-master.md" "storyteller","Sophia","Master Storyteller","📖","Expert Storytelling Guide + Narrative Strategist","Master storyteller with 50+ years across journalism, screenwriting, and brand narratives. Expert in emotional psychology and audience engagement.","Speaks like a bard weaving an epic tale - flowery, whimsical, every sentence enraptures and draws you deeper","Powerful narratives leverage timeless human truths. Find the authentic story. Make the abstract concrete through vivid details.","cis","_bmad/cis/agents/storyteller/storyteller.md" +"moodle-integrator","Midge","Moodle Integration Specialist","🔌","Moodle Web Service API Specialist + LMS Integration Expert","Integration specialist with deep expertise in Moodle's Web Service API layer, REST parameter encoding, and the Faculytics NestJS integration patterns. Knows every wsfunction already integrated and can advise on which API functions to use for any LMS data need.","Precise about API contracts, pragmatic about what Moodle actually returns vs what the docs say. Cuts through ambiguity by referencing specific wsfunction names. Occasionally dry-humored about Moodle's inconsistencies.","Always check what's already integrated before proposing new work. Test against the real Moodle instance before writing TypeScript. Follow the existing scaffolding pattern exactly — consistency is non-negotiable.","bmm","_bmad/bmm/agents/moodle-integrator.md" diff --git a/_bmad/_config/agents/bmm-moodle-integrator.customize.yaml b/_bmad/_config/agents/bmm-moodle-integrator.customize.yaml new file mode 100644 index 0000000..b8cc648 --- /dev/null +++ b/_bmad/_config/agents/bmm-moodle-integrator.customize.yaml @@ -0,0 +1,41 @@ +# Agent Customization +# Customize any section below - all are optional + +# Override agent name +agent: + metadata: + name: "" + +# Replace entire persona (not merged) +persona: + role: "" + identity: "" + communication_style: "" + principles: [] + +# Add custom critical actions (appended after standard config loading) +critical_actions: [] + +# Add persistent memories for the agent +memories: [] +# Example: +# memories: +# - "User prefers detailed technical explanations" +# - "Current project uses React and TypeScript" + +# Add custom menu items (appended to base menu) +# Don't include * prefix or help/exit - auto-injected +menu: [] +# Example: +# menu: +# - trigger: my-workflow +# workflow: "{project-root}/custom/my.yaml" +# description: My custom workflow + +# Add custom prompts (for action="#id" handlers) +prompts: [] +# Example: +# prompts: +# - id: my-prompt +# content: | +# Prompt instructions here diff --git a/_bmad/bmm/agents/moodle-integrator.md b/_bmad/bmm/agents/moodle-integrator.md new file mode 100644 index 0000000..20ca0c9 --- /dev/null +++ b/_bmad/bmm/agents/moodle-integrator.md @@ -0,0 +1,103 @@ +--- +name: 'moodle-integrator' +description: 'Moodle Integration Specialist' +--- + +You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. + +```xml + + + Load persona from this current agent file (already in context) + 🚨 IMMEDIATE ACTION REQUIRED - BEFORE ANY OUTPUT: + - Load and read {project-root}/_bmad/bmm/config.yaml NOW + - Store ALL fields as session variables: {user_name}, {communication_language}, {output_folder} + - VERIFY: If config not loaded, STOP and report error to user + - DO NOT PROCEED to step 3 until config is successfully loaded and variables stored + + Remember: user's name is {user_name} + + Show greeting using {user_name} from config, communicate in {communication_language}, then display numbered list of ALL menu items from menu section + Let {user_name} know they can type command `/bmad-help` at any time to get advice on what to do next, and that they can combine that with what they need help with `/bmad-help where should I start with an idea I have that does XYZ` + STOP and WAIT for user input - do NOT execute menu items automatically - accept number or cmd trigger or fuzzy command match + On user input: Number → process menu item[n] | Text → case-insensitive substring match | Multiple matches → ask user to clarify | No match → show "Not recognized" + When processing a menu item: Check menu-handlers section below - extract any attributes from the selected menu item (workflow, exec, tmpl, data, action, validate-workflow) and follow the corresponding handler instructions + + + + + When menu item or handler has: exec="path/to/file.md": + 1. Read fully and follow the file at that path + 2. Process the complete file and follow all instructions within it + 3. If there is data="some/path/data-foo.md" with the same item, pass that data path to the executed file as context. + + + + + + ALWAYS communicate in {communication_language} UNLESS contradicted by communication_style. + Stay in character until exit selected + Display Menu items as the item dictates and in the order given. + Load files ONLY when executing a user chosen workflow or a command requires it, EXCEPTION: agent activation step 2 config.yaml + + + Moodle Web Service API Specialist + LMS Integration Expert + Integration specialist with deep expertise in Moodle's Web Service API layer, REST parameter encoding, and the Faculytics NestJS integration patterns. Knows every wsfunction already integrated, understands the MoodleClient architecture, and can advise on which API functions to use for any LMS data need. Has hands-on experience with Moodle's quirky parameter encoding (bracket-indexed arrays, options patterns) and knows the common pitfalls — access exceptions, token scoping, version-dependent response fields. References the Moodle API documentation index and existing codebase patterns as the source of truth. + Speaks like a seasoned integration engineer — precise about API contracts, pragmatic about what Moodle actually returns vs what the docs say. Cuts through ambiguity by referencing specific wsfunction names and parameter shapes. Occasionally dry-humored about Moodle's inconsistencies. + + - Always check what's already integrated before proposing new work. The existing integration inventory is the starting point for any discussion. + - Moodle docs and live API responses are both sources of truth — when they disagree, trust the response and make fields optional. + - Parameter encoding is where most Moodle integrations break. Get the bracket notation right or nothing works. + - The MoodleClient base `call()` method handles all error cases — don't layer extra error handling on top. + - Test against the real Moodle instance with curl before writing any TypeScript. A working curl command is the specification. + - Follow the existing NestJS scaffolding pattern exactly: enum constant → response DTO → types barrel → client method → request DTO → service method. Consistency is non-negotiable. + - Scope integration work tightly — one wsfunction per integration pass. Don't bundle unrelated API functions. + + + +
+ | Enum Name | wsfunction | Client Method | + | -------------------------- | ------------------------------------ | ---------------------------- | + | GET_SITE_INFO | core_webservice_get_site_info | getSiteInfo() | + | GET_USER_COURSES | core_enrol_get_users_courses | getEnrolledCourses() | + | GET_ENROLLED_USERS | core_enrol_get_enrolled_users | getEnrolledUsersByCourse() | + | GET_COURSE_USER_PROFILES | core_user_get_course_user_profiles | getCourseUserProfiles() | + | GET_ALL_COURSES | core_course_get_courses | getCourses() | + | GET_COURSE_CATEGORIES | core_course_get_categories | getCategories() | + | GET_COURSES_BY_FIELD | core_course_get_courses_by_field | getCoursesByField() | +
+
+ | PHP Structure | REST POST Encoding | + | ------------------------------------------ | -------------------------------------------- | + | $param (scalar) | param=value | + | $param[] (indexed array) | param[0]=val1&param[1]=val2 | + | $param[key] (assoc array) | param[key]=value | + | $params[0][field] (array of objects) | params[0][field]=value | + | $options[0][name] + $options[0][value] | options[0][name]=key&options[0][value]=val | +
+
+ 1. Add enum constant to src/modules/moodle/lib/moodle.constants.ts + 2. Create response DTO in src/modules/moodle/dto/responses/ + 3. Re-export from src/modules/moodle/lib/moodle.types.ts + 4. Add client method to src/modules/moodle/lib/moodle.client.ts + 5. Create request DTO in src/modules/moodle/dto/requests/ + 6. Add service method to src/modules/moodle/moodle.service.ts + 7. (Optional) Add controller endpoint to src/modules/moodle/moodle.controller.ts +
+
+ - Moodle API index: docs/moodle/moodle_api_index.md + - Moodle API PDF: docs/moodle/moodle_api_documentation.pdf + - MoodleClient: src/modules/moodle/lib/moodle.client.ts + - Constants: src/modules/moodle/lib/moodle.constants.ts + - Types barrel: src/modules/moodle/lib/moodle.types.ts + - Service: src/modules/moodle/moodle.service.ts +
+
+ + [MH] Redisplay Menu Help + [CH] Chat with the Agent about anything + [PM] Start Party Mode + [DA] Dismiss Agent + +
+``` diff --git a/package-lock.json b/package-lock.json index 6817703..368cb71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1021.0", "@aws-sdk/s3-request-presigner": "^3.1021.0", + "@faker-js/faker": "^10.4.0", "@keyv/redis": "^5.1.6", "@mikro-orm/core": "^6.6.11", "@mikro-orm/migrations": "^6.6.11", @@ -1882,6 +1883,22 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@faker-js/faker": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.4.0.tgz", + "integrity": "sha512-sDBWI3yLy8EcDzgobvJTWq1MJYzAkQdpjXuPukga9wXonhpMRvd1Izuo2Qgwey2OiEoRIBr35RMU9HJRoOHzpw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/@fast-csv/format": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", diff --git a/package.json b/package.json index ac58ed8..83126f1 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.1021.0", "@aws-sdk/s3-request-presigner": "^3.1021.0", + "@faker-js/faker": "^10.4.0", "@keyv/redis": "^5.1.6", "@mikro-orm/core": "^6.6.11", "@mikro-orm/migrations": "^6.6.11", @@ -154,7 +155,7 @@ "^src/(.*)$": "/$1" }, "transformIgnorePatterns": [ - "/node_modules/(?!(uuid|p-limit|yocto-queue|cache-manager|cache-manager-redis-yet)/)" + "/node_modules/(?!(uuid|p-limit|yocto-queue|cache-manager|cache-manager-redis-yet|@faker-js/faker)/)" ] } } diff --git a/src/configurations/env/moodle.env.ts b/src/configurations/env/moodle.env.ts index da86bf3..453a577 100644 --- a/src/configurations/env/moodle.env.ts +++ b/src/configurations/env/moodle.env.ts @@ -5,6 +5,8 @@ export const moodleEnvSchema = z.object({ MOODLE_MASTER_KEY: z.string(), MOODLE_SYNC_CONCURRENCY: z.coerce.number().min(1).max(20).default(3), MOODLE_SYNC_INTERVAL_MINUTES: z.coerce.number().min(30).optional(), + MOODLE_ROLE_ID_STUDENT: z.coerce.number().default(5), + MOODLE_ROLE_ID_EDITING_TEACHER: z.coerce.number().default(3), }); export type MoodleEnv = z.infer; diff --git a/src/modules/audit/audit-action.enum.ts b/src/modules/audit/audit-action.enum.ts index d057556..1859f50 100644 --- a/src/modules/audit/audit-action.enum.ts +++ b/src/modules/audit/audit-action.enum.ts @@ -11,6 +11,10 @@ export const AuditAction = { ANALYSIS_PIPELINE_CREATE: 'analysis.pipeline.create', ANALYSIS_PIPELINE_CONFIRM: 'analysis.pipeline.confirm', ANALYSIS_PIPELINE_CANCEL: 'analysis.pipeline.cancel', + MOODLE_PROVISION_CATEGORIES: 'moodle.provision.categories', + MOODLE_PROVISION_COURSES: 'moodle.provision.courses', + MOODLE_PROVISION_QUICK_COURSE: 'moodle.provision.quick-course', + MOODLE_PROVISION_USERS: 'moodle.provision.users', } as const; export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction]; diff --git a/src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts b/src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts new file mode 100644 index 0000000..c42b96d --- /dev/null +++ b/src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts @@ -0,0 +1,140 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from 'src/security/guards/roles.guard'; +import { CurrentUserInterceptor } from 'src/modules/common/interceptors/current-user.interceptor'; +import { + auditTestProviders, + overrideAuditInterceptors, +} from 'src/modules/audit/testing/audit-test.helpers'; +import { MoodleProvisioningController } from './moodle-provisioning.controller'; +import { MoodleProvisioningService } from '../services/moodle-provisioning.service'; + +describe('MoodleProvisioningController', () => { + let controller: MoodleProvisioningController; + let provisioningService: jest.Mocked; + + beforeEach(async () => { + const builder = Test.createTestingModule({ + controllers: [MoodleProvisioningController], + providers: [ + { + provide: MoodleProvisioningService, + useValue: { + ProvisionCategories: jest.fn(), + PreviewCourses: jest.fn(), + ExecuteCourseSeeding: jest.fn(), + PreviewQuickCourse: jest.fn(), + ExecuteQuickCourse: jest.fn(), + SeedUsers: jest.fn(), + }, + }, + ...auditTestProviders(), + ], + }); + + const module: TestingModule = await overrideAuditInterceptors( + builder + .overrideGuard(AuthGuard('jwt')) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .overrideInterceptor(CurrentUserInterceptor) + .useValue({ + intercept: (_ctx: unknown, next: { handle: () => unknown }) => + next.handle(), + }), + ).compile(); + + controller = module.get(MoodleProvisioningController); + provisioningService = module.get(MoodleProvisioningService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('ProvisionCategories', () => { + it('should delegate to service', async () => { + const mockResult = { + created: 4, + skipped: 0, + errors: 0, + details: [], + durationMs: 100, + syncCompleted: true, + }; + provisioningService.ProvisionCategories.mockResolvedValue(mockResult); + + const result = await controller.ProvisionCategories({ + campuses: ['UCMN'], + semesters: [1, 2], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [{ code: 'CCS', programs: ['BSCS'] }], + }); + + expect(result).toEqual(mockResult); + }); + }); + + describe('PreviewCourses', () => { + it('should throw when no file provided', async () => { + await expect( + controller.PreviewCourses(undefined as any, { + campus: 'UCMN', + department: 'CCS', + startDate: '2025-08-01', + endDate: '2026-06-01', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should delegate to service with context', async () => { + const mockResult = { + valid: [], + skipped: [], + errors: [], + shortnameNote: 'note', + }; + provisioningService.PreviewCourses.mockResolvedValue(mockResult); + + const file = { + buffer: Buffer.from('test'), + originalname: 'test.csv', + } as Express.Multer.File; + const result = await controller.PreviewCourses(file, { + campus: 'UCMN', + department: 'CCS', + startDate: '2025-08-01', + endDate: '2026-06-01', + }); + + expect(result).toEqual(mockResult); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(provisioningService.PreviewCourses).toHaveBeenCalled(); + }); + }); + + describe('SeedUsers', () => { + it('should delegate to service', async () => { + const mockResult = { + usersCreated: 5, + usersFailed: 0, + enrolmentsCreated: 10, + warnings: [], + durationMs: 500, + }; + provisioningService.SeedUsers.mockResolvedValue(mockResult); + + const result = await controller.SeedUsers({ + count: 5, + role: 'student', + campus: 'ucmn', + courseIds: [42, 43], + }); + + expect(result).toEqual(mockResult); + }); + }); +}); diff --git a/src/modules/moodle/controllers/moodle-provisioning.controller.ts b/src/modules/moodle/controllers/moodle-provisioning.controller.ts new file mode 100644 index 0000000..8265508 --- /dev/null +++ b/src/modules/moodle/controllers/moodle-provisioning.controller.ts @@ -0,0 +1,213 @@ +import { + BadRequestException, + Body, + Controller, + HttpCode, + HttpStatus, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { + ApiBody, + ApiConsumes, + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { UseJwtGuard } from 'src/security/decorators'; +import { UserRole } from 'src/modules/auth/roles.enum'; +import { Audited } from 'src/modules/audit/decorators/audited.decorator'; +import { AuditAction } from 'src/modules/audit/audit-action.enum'; +import { AuditInterceptor } from 'src/modules/audit/interceptors/audit.interceptor'; +import { MetaDataInterceptor } from 'src/modules/common/interceptors/metadata.interceptor'; +import { CurrentUserInterceptor } from 'src/modules/common/interceptors/current-user.interceptor'; +import { MoodleProvisioningService } from '../services/moodle-provisioning.service'; +import { ProvisionCategoriesRequestDto } from '../dto/requests/provision-categories.request.dto'; +import { SeedCoursesContextDto } from '../dto/requests/seed-courses.request.dto'; +import { ExecuteCoursesRequestDto } from '../dto/requests/execute-courses.request.dto'; +import { QuickCourseRequestDto } from '../dto/requests/quick-course.request.dto'; +import { SeedUsersRequestDto } from '../dto/requests/seed-users.request.dto'; +import { ProvisionResultDto } from '../dto/responses/provision-result.response.dto'; +import { CoursePreviewResultDto } from '../dto/responses/course-preview.response.dto'; +import { CoursePreviewRowResponseDto } from '../dto/responses/course-preview.response.dto'; +import { SeedUsersResultDto } from '../dto/responses/seed-users-result.response.dto'; +import { SeedContext } from '../lib/provisioning.types'; + +function csvFileFilter( + _req: any, + file: Express.Multer.File, + callback: (error: Error | null, acceptFile: boolean) => void, +) { + if (!file.originalname.toLowerCase().endsWith('.csv')) { + callback( + new BadRequestException( + 'Invalid file type. Only CSV files are accepted.', + ), + false, + ); + return; + } + callback(null, true); +} + +function buildSeedContext(dto: SeedCoursesContextDto): SeedContext { + const startYear = dto.startDate.slice(0, 4); + const endYear = dto.endDate.slice(0, 4); + return { + campus: dto.campus, + department: dto.department, + startDate: dto.startDate, + endDate: dto.endDate, + startYear, + endYear, + startYY: startYear.slice(-2), + endYY: endYear.slice(-2), + }; +} + +@ApiTags('Moodle Provisioning') +@Controller('moodle/provision') +export class MoodleProvisioningController { + constructor( + private readonly provisioningService: MoodleProvisioningService, + ) {} + + @Post('categories') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @Audited({ + action: AuditAction.MOODLE_PROVISION_CATEGORIES, + resource: 'MoodleCategory', + }) + @UseInterceptors( + MetaDataInterceptor, + CurrentUserInterceptor, + AuditInterceptor, + ) + @ApiOperation({ summary: 'Provision Moodle category tree' }) + @ApiResponse({ status: 200, type: ProvisionResultDto }) + async ProvisionCategories( + @Body() dto: ProvisionCategoriesRequestDto, + ): Promise { + return await this.provisioningService.ProvisionCategories(dto); + } + + @Post('courses/preview') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @UseInterceptors( + MetaDataInterceptor, + FileInterceptor('file', { + fileFilter: csvFileFilter, + limits: { fileSize: 2 * 1024 * 1024 }, + }), + ) + @ApiOperation({ summary: 'Preview bulk course seeding from CSV' }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file', 'campus', 'department', 'startDate', 'endDate'], + properties: { + file: { type: 'string', format: 'binary' }, + campus: { type: 'string' }, + department: { type: 'string' }, + startDate: { type: 'string', format: 'date' }, + endDate: { type: 'string', format: 'date' }, + }, + }, + }) + @ApiResponse({ status: 200, type: CoursePreviewResultDto }) + async PreviewCourses( + @UploadedFile() file: Express.Multer.File, + @Body() dto: SeedCoursesContextDto, + ): Promise { + if (!file) { + throw new BadRequestException('CSV file is required'); + } + const context = buildSeedContext(dto); + return await this.provisioningService.PreviewCourses(file.buffer, context); + } + + @Post('courses/execute') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @Audited({ + action: AuditAction.MOODLE_PROVISION_COURSES, + resource: 'MoodleCourse', + }) + @UseInterceptors( + MetaDataInterceptor, + CurrentUserInterceptor, + AuditInterceptor, + ) + @ApiOperation({ summary: 'Execute bulk course creation in Moodle' }) + @ApiResponse({ status: 200, type: ProvisionResultDto }) + async ExecuteCourses( + @Body() dto: ExecuteCoursesRequestDto, + ): Promise { + const context = buildSeedContext({ + campus: dto.campus, + department: dto.department, + startDate: dto.startDate, + endDate: dto.endDate, + }); + return await this.provisioningService.ExecuteCourseSeeding( + dto.rows, + context, + ); + } + + @Post('courses/quick/preview') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiOperation({ summary: 'Preview quick course creation' }) + @ApiResponse({ status: 200, type: CoursePreviewRowResponseDto }) + PreviewQuickCourse( + @Body() dto: QuickCourseRequestDto, + ): CoursePreviewRowResponseDto { + return this.provisioningService.PreviewQuickCourse(dto); + } + + @Post('courses/quick') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @Audited({ + action: AuditAction.MOODLE_PROVISION_QUICK_COURSE, + resource: 'MoodleCourse', + }) + @UseInterceptors( + MetaDataInterceptor, + CurrentUserInterceptor, + AuditInterceptor, + ) + @ApiOperation({ summary: 'Create a single course in Moodle' }) + @ApiResponse({ status: 200, type: ProvisionResultDto }) + async QuickCourse( + @Body() dto: QuickCourseRequestDto, + ): Promise { + return await this.provisioningService.ExecuteQuickCourse(dto); + } + + @Post('users') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @Audited({ + action: AuditAction.MOODLE_PROVISION_USERS, + resource: 'MoodleUser', + }) + @UseInterceptors( + MetaDataInterceptor, + CurrentUserInterceptor, + AuditInterceptor, + ) + @ApiOperation({ summary: 'Generate and enrol fake users in Moodle' }) + @ApiResponse({ status: 200, type: SeedUsersResultDto }) + async SeedUsers( + @Body() dto: SeedUsersRequestDto, + ): Promise { + return await this.provisioningService.SeedUsers(dto); + } +} diff --git a/src/modules/moodle/dto/requests/execute-courses.request.dto.ts b/src/modules/moodle/dto/requests/execute-courses.request.dto.ts new file mode 100644 index 0000000..d786a89 --- /dev/null +++ b/src/modules/moodle/dto/requests/execute-courses.request.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsDateString, + IsInt, + IsString, + ArrayNotEmpty, + ValidateNested, + Validate, +} from 'class-validator'; +import { IsBeforeEndDate } from '../validators/is-before-end-date.validator'; + +export class CoursePreviewRowDto { + @ApiProperty({ example: 'CS101' }) + @IsString() + courseCode: string; + + @ApiProperty({ example: 'Introduction to Computer Science' }) + @IsString() + descriptiveTitle: string; + + @ApiProperty({ example: 'BSCS' }) + @IsString() + program: string; + + @ApiProperty({ example: '1' }) + @IsString() + semester: string; + + @ApiProperty({ example: 42 }) + @IsInt() + categoryId: number; +} + +export class ExecuteCoursesRequestDto { + @ApiProperty({ type: [CoursePreviewRowDto] }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => CoursePreviewRowDto) + rows: CoursePreviewRowDto[]; + + @ApiProperty({ description: 'Campus code', example: 'UCMN' }) + @IsString() + campus: string; + + @ApiProperty({ description: 'Department code', example: 'CCS' }) + @IsString() + department: string; + + @ApiProperty({ + description: 'Academic year start date (ISO 8601)', + example: '2025-08-01', + }) + @IsDateString() + @Validate(IsBeforeEndDate) + startDate: string; + + @ApiProperty({ + description: 'Academic year end date (ISO 8601)', + example: '2026-06-01', + }) + @IsDateString() + endDate: string; +} diff --git a/src/modules/moodle/dto/requests/provision-categories.request.dto.ts b/src/modules/moodle/dto/requests/provision-categories.request.dto.ts new file mode 100644 index 0000000..8cd3f31 --- /dev/null +++ b/src/modules/moodle/dto/requests/provision-categories.request.dto.ts @@ -0,0 +1,73 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsDateString, + IsIn, + IsString, + ArrayMinSize, + ValidateNested, + ArrayNotEmpty, + Validate, +} from 'class-validator'; +import { IsBeforeEndDate } from '../validators/is-before-end-date.validator'; + +class DepartmentDto { + @ApiProperty({ description: 'Department code', example: 'CCS' }) + @IsString() + code: string; + + @ApiProperty({ + description: 'Program codes under this department', + example: ['BSCS', 'BSIT'], + }) + @IsArray() + @ArrayMinSize(1) + @IsString({ each: true }) + programs: string[]; +} + +export class ProvisionCategoriesRequestDto { + @ApiProperty({ + description: 'Campus codes', + example: ['UCMN'], + }) + @IsArray() + @ArrayNotEmpty() + @IsString({ each: true }) + campuses: string[]; + + @ApiProperty({ + description: 'Semester numbers (1 and/or 2)', + example: [1, 2], + }) + @IsArray() + @ArrayNotEmpty() + @IsIn([1, 2], { each: true }) + semesters: number[]; + + @ApiProperty({ + description: 'Academic year start date (ISO 8601)', + example: '2025-08-01', + }) + @IsDateString() + @Validate(IsBeforeEndDate) + startDate: string; + + @ApiProperty({ + description: 'Academic year end date (ISO 8601)', + example: '2026-06-01', + }) + @IsDateString() + endDate: string; + + @ApiProperty({ + description: 'Departments with their programs', + type: [DepartmentDto], + }) + @IsArray() + @ArrayNotEmpty() + @ValidateNested({ each: true }) + @Type(() => DepartmentDto) + departments: DepartmentDto[]; +} diff --git a/src/modules/moodle/dto/requests/quick-course.request.dto.ts b/src/modules/moodle/dto/requests/quick-course.request.dto.ts new file mode 100644 index 0000000..bd49ab2 --- /dev/null +++ b/src/modules/moodle/dto/requests/quick-course.request.dto.ts @@ -0,0 +1,48 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsIn, IsInt, IsString, Validate } from 'class-validator'; +import { IsBeforeEndDate } from '../validators/is-before-end-date.validator'; + +export class QuickCourseRequestDto { + @ApiProperty({ description: 'Course code', example: 'CS 101' }) + @IsString() + courseCode: string; + + @ApiProperty({ + description: 'Course descriptive title', + example: 'Introduction to Computer Science', + }) + @IsString() + descriptiveTitle: string; + + @ApiProperty({ description: 'Campus code', example: 'UCMN' }) + @IsString() + campus: string; + + @ApiProperty({ description: 'Department code', example: 'CCS' }) + @IsString() + department: string; + + @ApiProperty({ description: 'Program code', example: 'BSCS' }) + @IsString() + program: string; + + @ApiProperty({ description: 'Semester number (1 or 2)', example: 1 }) + @IsInt() + @IsIn([1, 2]) + semester: number; + + @ApiProperty({ + description: 'Academic year start date (ISO 8601)', + example: '2025-08-01', + }) + @IsDateString() + @Validate(IsBeforeEndDate) + startDate: string; + + @ApiProperty({ + description: 'Academic year end date (ISO 8601)', + example: '2026-06-01', + }) + @IsDateString() + endDate: string; +} diff --git a/src/modules/moodle/dto/requests/seed-courses.request.dto.ts b/src/modules/moodle/dto/requests/seed-courses.request.dto.ts new file mode 100644 index 0000000..6d2aae1 --- /dev/null +++ b/src/modules/moodle/dto/requests/seed-courses.request.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsDateString, IsString, Validate } from 'class-validator'; +import { IsBeforeEndDate } from '../validators/is-before-end-date.validator'; + +export class SeedCoursesContextDto { + @ApiProperty({ description: 'Campus code', example: 'UCMN' }) + @IsString() + campus: string; + + @ApiProperty({ description: 'Department code', example: 'CCS' }) + @IsString() + department: string; + + @ApiProperty({ + description: 'Academic year start date (ISO 8601)', + example: '2025-08-01', + }) + @IsDateString() + @Validate(IsBeforeEndDate) + startDate: string; + + @ApiProperty({ + description: 'Academic year end date (ISO 8601)', + example: '2026-06-01', + }) + @IsDateString() + endDate: string; +} diff --git a/src/modules/moodle/dto/requests/seed-users.request.dto.ts b/src/modules/moodle/dto/requests/seed-users.request.dto.ts new file mode 100644 index 0000000..c5ef3f1 --- /dev/null +++ b/src/modules/moodle/dto/requests/seed-users.request.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsArray, + IsIn, + IsInt, + IsString, + Max, + Min, + ArrayMinSize, +} from 'class-validator'; + +export class SeedUsersRequestDto { + @ApiProperty({ + description: 'Number of users to generate', + example: 10, + minimum: 1, + maximum: 200, + }) + @IsInt() + @Min(1) + @Max(200) + count: number; + + @ApiProperty({ + description: 'User role', + enum: ['student', 'faculty'], + example: 'student', + }) + @IsIn(['student', 'faculty']) + role: 'student' | 'faculty'; + + @ApiProperty({ description: 'Campus code', example: 'ucmn' }) + @IsString() + campus: string; + + @ApiProperty({ + description: 'Moodle course IDs to enrol users into', + example: [42, 43], + }) + @IsArray() + @ArrayMinSize(1, { message: 'At least one course ID is required' }) + @IsInt({ each: true }) + courseIds: number[]; +} diff --git a/src/modules/moodle/dto/responses/course-preview.response.dto.ts b/src/modules/moodle/dto/responses/course-preview.response.dto.ts new file mode 100644 index 0000000..de94bd1 --- /dev/null +++ b/src/modules/moodle/dto/responses/course-preview.response.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class CoursePreviewRowResponseDto { + @ApiProperty() shortname: string; + @ApiProperty() fullname: string; + @ApiProperty() categoryPath: string; + @ApiProperty() categoryId: number; + @ApiProperty() startDate: string; + @ApiProperty() endDate: string; + @ApiProperty() program: string; + @ApiProperty() semester: string; + @ApiProperty() courseCode: string; +} + +export class SkippedRowDto { + @ApiProperty() rowNumber: number; + @ApiProperty() courseCode: string; + @ApiProperty() reason: string; +} + +export class ParseErrorDto { + @ApiProperty() rowNumber: number; + @ApiProperty() message: string; +} + +export class CoursePreviewResultDto { + @ApiProperty({ type: [CoursePreviewRowResponseDto] }) + valid: CoursePreviewRowResponseDto[]; + + @ApiProperty({ type: [SkippedRowDto] }) + skipped: SkippedRowDto[]; + + @ApiProperty({ type: [ParseErrorDto] }) + errors: ParseErrorDto[]; + + @ApiProperty({ + example: + 'EDP codes are examples. Final codes are generated at execution time.', + }) + shortnameNote: string; +} diff --git a/src/modules/moodle/dto/responses/provision-result.response.dto.ts b/src/modules/moodle/dto/responses/provision-result.response.dto.ts new file mode 100644 index 0000000..4cae140 --- /dev/null +++ b/src/modules/moodle/dto/responses/provision-result.response.dto.ts @@ -0,0 +1,35 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ProvisionDetailItemDto { + @ApiProperty({ example: 'UCMN' }) + name: string; + + @ApiProperty({ enum: ['created', 'skipped', 'error'] }) + status: 'created' | 'skipped' | 'error'; + + @ApiPropertyOptional({ example: 'Already exists' }) + reason?: string; + + @ApiPropertyOptional({ example: 42 }) + moodleId?: number; +} + +export class ProvisionResultDto { + @ApiProperty({ example: 4 }) + created: number; + + @ApiProperty({ example: 1 }) + skipped: number; + + @ApiProperty({ example: 0 }) + errors: number; + + @ApiProperty({ type: [ProvisionDetailItemDto] }) + details: ProvisionDetailItemDto[]; + + @ApiProperty({ example: 1234 }) + durationMs: number; + + @ApiPropertyOptional({ example: true }) + syncCompleted?: boolean; +} diff --git a/src/modules/moodle/dto/responses/seed-users-result.response.dto.ts b/src/modules/moodle/dto/responses/seed-users-result.response.dto.ts new file mode 100644 index 0000000..e56d860 --- /dev/null +++ b/src/modules/moodle/dto/responses/seed-users-result.response.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SeedUsersResultDto { + @ApiProperty({ example: 10 }) + usersCreated: number; + + @ApiProperty({ example: 0 }) + usersFailed: number; + + @ApiProperty({ example: 20 }) + enrolmentsCreated: number; + + @ApiProperty({ type: [String], example: [] }) + warnings: string[]; + + @ApiProperty({ example: 3500 }) + durationMs: number; +} diff --git a/src/modules/moodle/dto/validators/is-before-end-date.validator.ts b/src/modules/moodle/dto/validators/is-before-end-date.validator.ts new file mode 100644 index 0000000..7355f4b --- /dev/null +++ b/src/modules/moodle/dto/validators/is-before-end-date.validator.ts @@ -0,0 +1,17 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; + +@ValidatorConstraint({ name: 'isBeforeEndDate', async: false }) +export class IsBeforeEndDate implements ValidatorConstraintInterface { + validate(_: unknown, args: ValidationArguments) { + const obj = args.object as { startDate?: string; endDate?: string }; + if (!obj.startDate || !obj.endDate) return true; + return obj.startDate < obj.endDate; + } + defaultMessage() { + return 'startDate must be before endDate'; + } +} diff --git a/src/modules/moodle/lib/moodle.client.ts b/src/modules/moodle/lib/moodle.client.ts index 4130226..1ba2592 100644 --- a/src/modules/moodle/lib/moodle.client.ts +++ b/src/modules/moodle/lib/moodle.client.ts @@ -8,6 +8,14 @@ import { MoodleCategoryResponse, MoodleCourseGroup, MoodleCourseUserGroupsResponse, + MoodleCreateCourseInput, + MoodleCreateCourseResult, + MoodleCreateCategoryInput, + MoodleCreateCategoryResult, + MoodleCreateUserInput, + MoodleCreateUserResult, + MoodleEnrolmentInput, + MoodleEnrolResult, } from './moodle.types'; import { MoodleUserProfile } from '../dto/responses/user-profile.response.dto'; @@ -23,6 +31,7 @@ export class MoodleConnectivityError extends Error { } const MOODLE_REQUEST_TIMEOUT_MS = 10000; +const MOODLE_WRITE_TIMEOUT_MS = 60000; export class MoodleClient { private baseUrl: string; @@ -75,6 +84,7 @@ export class MoodleClient { async call( functionName: string, params: Record = {}, + timeoutMs: number = MOODLE_REQUEST_TIMEOUT_MS, ): Promise { if (!this.token) { throw new Error( @@ -93,7 +103,7 @@ export class MoodleClient { moodlewsrestformat: 'json', ...params, }), - signal: AbortSignal.timeout(MOODLE_REQUEST_TIMEOUT_MS), + signal: AbortSignal.timeout(timeoutMs), }); } catch (error) { this.handleFetchError(error, functionName); @@ -123,6 +133,8 @@ export class MoodleClient { ); } + if (data == null) return data; + const moodleError = data as { exception?: string; message?: string }; if (moodleError.exception) { throw new Error( @@ -235,6 +247,61 @@ export class MoodleClient { ); } + async createCourses( + courses: MoodleCreateCourseInput[], + ): Promise { + return await this.call( + MoodleWebServiceFunction.CREATE_COURSES, + this.serializeArrayParams('courses', courses), + MOODLE_WRITE_TIMEOUT_MS, + ); + } + + async createCategories( + categories: MoodleCreateCategoryInput[], + ): Promise { + return await this.call( + MoodleWebServiceFunction.CREATE_CATEGORIES, + this.serializeArrayParams('categories', categories), + MOODLE_WRITE_TIMEOUT_MS, + ); + } + + async createUsers( + users: MoodleCreateUserInput[], + ): Promise { + return await this.call( + MoodleWebServiceFunction.CREATE_USERS, + this.serializeArrayParams('users', users), + MOODLE_WRITE_TIMEOUT_MS, + ); + } + + async enrolUsers( + enrolments: MoodleEnrolmentInput[], + ): Promise { + return await this.call( + MoodleWebServiceFunction.ENROL_USERS, + this.serializeArrayParams('enrolments', enrolments), + MOODLE_WRITE_TIMEOUT_MS, + ); + } + + private serializeArrayParams( + key: string, + items: object[], + ): Record { + const params: Record = {}; + items.forEach((item, index) => { + for (const [field, value] of Object.entries(item)) { + if (value !== undefined) { + params[`${key}[${index}][${field}]`] = String(value); + } + } + }); + return params; + } + private handleFetchError(error: unknown, operation: string): never { const originalError = error instanceof Error ? error : new Error(String(error)); diff --git a/src/modules/moodle/lib/moodle.constants.ts b/src/modules/moodle/lib/moodle.constants.ts index e937c7d..0c1259d 100644 --- a/src/modules/moodle/lib/moodle.constants.ts +++ b/src/modules/moodle/lib/moodle.constants.ts @@ -14,4 +14,8 @@ export enum MoodleWebServiceFunction { GET_COURSES_BY_FIELD = 'core_course_get_courses_by_field', GET_COURSE_GROUPS = 'core_group_get_course_groups', GET_COURSE_USER_GROUPS = 'core_group_get_course_user_groups', + CREATE_COURSES = 'core_course_create_courses', + CREATE_CATEGORIES = 'core_course_create_categories', + CREATE_USERS = 'core_user_create_users', + ENROL_USERS = 'enrol_manual_enrol_users', } diff --git a/src/modules/moodle/lib/moodle.types.ts b/src/modules/moodle/lib/moodle.types.ts index 65550c4..476bf34 100644 --- a/src/modules/moodle/lib/moodle.types.ts +++ b/src/modules/moodle/lib/moodle.types.ts @@ -14,3 +14,58 @@ export { MoodleCourseGroup, MoodleCourseUserGroupsResponse, } from '../dto/responses/course-groups.response.dto'; + +// Write operation types +export interface MoodleCreateCourseInput { + shortname: string; + fullname: string; + categoryid: number; + startdate?: number; + enddate?: number; + visible?: number; +} + +export interface MoodleCreateCourseResult { + id: number; + shortname: string; +} + +export interface MoodleCreateCategoryInput { + name: string; + parent?: number; + description?: string; + idnumber?: string; +} + +export interface MoodleCreateCategoryResult { + id: number; + name: string; +} + +export interface MoodleCreateUserInput { + username: string; + password: string; + firstname: string; + lastname: string; + email: string; +} + +export interface MoodleCreateUserResult { + id: number; + username: string; +} + +export interface MoodleEnrolmentInput { + userid: number; + courseid: number; + roleid: number; +} + +export interface MoodleEnrolResult { + warnings?: Array<{ + item: string; + itemid: number; + warningcode: string; + message: string; + }>; +} diff --git a/src/modules/moodle/lib/provisioning.types.ts b/src/modules/moodle/lib/provisioning.types.ts new file mode 100644 index 0000000..2baad30 --- /dev/null +++ b/src/modules/moodle/lib/provisioning.types.ts @@ -0,0 +1,115 @@ +export const MOODLE_PROVISION_BATCH_SIZE = 50; + +export interface SeedContext { + campus: string; + department: string; + startDate: string; + endDate: string; + startYear: string; + endYear: string; + startYY: string; + endYY: string; +} + +export interface CurriculumRow { + courseCode: string; + descriptiveTitle: string; + program: string; + semester: string; +} + +export interface CoursePreviewRow { + shortname: string; + fullname: string; + categoryPath: string; + categoryId: number; + startDate: string; + endDate: string; + program: string; + semester: string; + courseCode: string; +} + +export interface ConfirmedCourseRow { + courseCode: string; + descriptiveTitle: string; + program: string; + semester: string; + categoryId: number; +} + +export interface SkippedRow { + rowNumber: number; + courseCode: string; + reason: string; +} + +export interface ParseError { + rowNumber: number; + message: string; +} + +export interface SeedUserRecord { + username: string; + firstname: string; + lastname: string; + email: string; + password: string; +} + +export interface ProvisionCategoriesInput { + campuses: string[]; + semesters: number[]; + startDate: string; + endDate: string; + departments: { code: string; programs: string[] }[]; +} + +export interface QuickCourseInput { + courseCode: string; + descriptiveTitle: string; + campus: string; + department: string; + program: string; + semester: number; + startDate: string; + endDate: string; +} + +export interface SeedUsersInput { + count: number; + role: 'student' | 'faculty'; + campus: string; + courseIds: number[]; +} + +export interface ProvisionDetailItem { + name: string; + status: 'created' | 'skipped' | 'error'; + reason?: string; + moodleId?: number; +} + +export interface ProvisionResult { + created: number; + skipped: number; + errors: number; + details: ProvisionDetailItem[]; + durationMs: number; + syncCompleted?: boolean; +} + +export interface CoursePreviewResult { + valid: CoursePreviewRow[]; + skipped: SkippedRow[]; + errors: ParseError[]; + shortnameNote: string; +} + +export interface SeedUsersResult { + usersCreated: number; + usersFailed: number; + enrolmentsCreated: number; + warnings: string[]; + durationMs: number; +} diff --git a/src/modules/moodle/moodle.module.ts b/src/modules/moodle/moodle.module.ts index abc9bf5..f99f7d0 100644 --- a/src/modules/moodle/moodle.module.ts +++ b/src/modules/moodle/moodle.module.ts @@ -23,6 +23,10 @@ import { MoodleSyncProcessor } from './processors/moodle-sync.processor'; import { MoodleSyncScheduler } from './schedulers/moodle-sync.scheduler'; import { MoodleStartupService } from './services/moodle-startup.service'; import { MoodleSyncController } from './controllers/moodle-sync.controller'; +import { MoodleProvisioningController } from './controllers/moodle-provisioning.controller'; +import { MoodleCourseTransformService } from './services/moodle-course-transform.service'; +import { MoodleCsvParserService } from './services/moodle-csv-parser.service'; +import { MoodleProvisioningService } from './services/moodle-provisioning.service'; import DataLoaderModule from '../common/data-loaders/index.module'; @Module({ @@ -43,7 +47,7 @@ import DataLoaderModule from '../common/data-loaders/index.module'; CommonModule, DataLoaderModule, ], - controllers: [MoodleSyncController], + controllers: [MoodleSyncController, MoodleProvisioningController], providers: [ MoodleService, MoodleSyncService, @@ -54,6 +58,9 @@ import DataLoaderModule from '../common/data-loaders/index.module'; MoodleSyncProcessor, MoodleSyncScheduler, MoodleStartupService, + MoodleCourseTransformService, + MoodleCsvParserService, + MoodleProvisioningService, ], exports: [ MoodleService, @@ -63,6 +70,7 @@ import DataLoaderModule from '../common/data-loaders/index.module'; EnrollmentSyncService, MoodleUserHydrationService, MoodleStartupService, + MoodleProvisioningService, ], }) export default class MoodleModule {} diff --git a/src/modules/moodle/moodle.service.ts b/src/modules/moodle/moodle.service.ts index 6f77e2e..5f72b47 100644 --- a/src/modules/moodle/moodle.service.ts +++ b/src/modules/moodle/moodle.service.ts @@ -9,7 +9,17 @@ import { GetCourseUserProfilesRequest } from './dto/requests/get-course-user-pro import { GetMoodleCoursesRequest } from './dto/requests/get-courses-request.dto'; import { GetCourseCategoriesRequest } from './dto/requests/get-course-categories.request.dto'; import { GetCoursesByFieldRequest } from './dto/requests/get-courses-by-field-request.dto'; -import { MoodleEnrolledUser } from './lib/moodle.types'; +import { + MoodleEnrolledUser, + MoodleCreateCourseInput, + MoodleCreateCourseResult, + MoodleCreateCategoryInput, + MoodleCreateCategoryResult, + MoodleCreateUserInput, + MoodleCreateUserResult, + MoodleEnrolmentInput, + MoodleEnrolResult, +} from './lib/moodle.types'; @Injectable() export class MoodleService { @@ -17,6 +27,12 @@ export class MoodleService { return new MoodleClient(env.MOODLE_BASE_URL); } + private BuildMasterClient(): MoodleClient { + const client = this.BuildMoodleClient(); + client.setToken(env.MOODLE_MASTER_KEY); + return client; + } + async Login(dto: LoginMoodleRequest) { const client = this.BuildMoodleClient(); return await client.login(dto.username, dto.password); @@ -105,4 +121,37 @@ export class MoodleService { if (!user || !user.roles?.length) return 'student'; return user.roles[0].shortname; } + + async CreateCourses( + courses: MoodleCreateCourseInput[], + ): Promise { + const client = this.BuildMasterClient(); + return await client.createCourses(courses); + } + + async CreateCategories( + categories: MoodleCreateCategoryInput[], + ): Promise { + const client = this.BuildMasterClient(); + return await client.createCategories(categories); + } + + async CreateUsers( + users: MoodleCreateUserInput[], + ): Promise { + const client = this.BuildMasterClient(); + return await client.createUsers(users); + } + + async EnrolUsers( + enrolments: MoodleEnrolmentInput[], + ): Promise { + const client = this.BuildMasterClient(); + return await client.enrolUsers(enrolments); + } + + async GetCategoriesWithMasterKey() { + const client = this.BuildMasterClient(); + return await client.getCategories(); + } } diff --git a/src/modules/moodle/services/moodle-course-transform.service.spec.ts b/src/modules/moodle/services/moodle-course-transform.service.spec.ts new file mode 100644 index 0000000..d8693d9 --- /dev/null +++ b/src/modules/moodle/services/moodle-course-transform.service.spec.ts @@ -0,0 +1,190 @@ +import { MoodleCourseTransformService } from './moodle-course-transform.service'; + +describe('MoodleCourseTransformService', () => { + let service: MoodleCourseTransformService; + + beforeEach(() => { + service = new MoodleCourseTransformService(); + }); + + describe('GenerateShortname', () => { + it('should produce correct format for semester 1', () => { + const result = service.GenerateShortname( + 'UCMN', + '1', + '25', + '26', + 'CS101', + ); + expect(result).toMatch(/^UCMN-S12526-CS101-\d{5}$/); + }); + + it('should produce correct format for semester 2', () => { + const result = service.GenerateShortname( + 'UCMN', + '2', + '25', + '26', + 'CS102', + ); + expect(result).toMatch(/^UCMN-S22526-CS102-\d{5}$/); + }); + + it('should strip spaces from course code', () => { + const result = service.GenerateShortname( + 'UCMN', + '1', + '25', + '26', + 'BSCS 101', + ); + expect(result).toMatch(/^UCMN-S12526-BSCS101-\d{5}$/); + }); + + it('should produce 5-digit zero-padded EDP code', () => { + const result = service.GenerateShortname( + 'UCMN', + '1', + '25', + '26', + 'CS101', + ); + const edp = result.split('-').pop()!; + expect(edp).toHaveLength(5); + expect(edp).toMatch(/^\d{5}$/); + }); + + it('should uppercase campus', () => { + const result = service.GenerateShortname( + 'ucmn', + '1', + '25', + '26', + 'CS101', + ); + expect(result).toMatch(/^UCMN-/); + }); + }); + + describe('BuildCategoryPath', () => { + it('should build correct category path', () => { + const result = service.BuildCategoryPath( + 'UCMN', + '1', + 'CCS', + 'BSCS', + '25', + '26', + ); + expect(result).toBe('UCMN / S12526 / CCS / BSCS'); + }); + + it('should uppercase all components', () => { + const result = service.BuildCategoryPath( + 'ucmn', + '2', + 'ccs', + 'bscs', + '25', + '26', + ); + expect(result).toBe('UCMN / S22526 / CCS / BSCS'); + }); + }); + + describe('GetSemesterDates', () => { + it('should return semester 1 dates', () => { + const result = service.GetSemesterDates('1', '2025', '2026'); + expect(result).toEqual({ + startDate: '2025-08-01', + endDate: '2025-12-18', + }); + }); + + it('should return semester 2 dates', () => { + const result = service.GetSemesterDates('2', '2025', '2026'); + expect(result).toEqual({ + startDate: '2026-01-20', + endDate: '2026-06-01', + }); + }); + + it('should return null for invalid semester', () => { + expect(service.GetSemesterDates('0', '2025', '2026')).toBeNull(); + expect(service.GetSemesterDates('3', '2025', '2026')).toBeNull(); + }); + }); + + describe('GenerateStudentUsername', () => { + it('should produce correct format with zero-padded date', () => { + const result = service.GenerateStudentUsername('ucmn'); + expect(result).toMatch(/^ucmn-\d{10}$/); + }); + + it('should lowercase campus', () => { + const result = service.GenerateStudentUsername('UCMN'); + expect(result).toMatch(/^ucmn-/); + }); + }); + + describe('GenerateFacultyUsername', () => { + it('should produce correct format', () => { + const result = service.GenerateFacultyUsername('ucmn'); + expect(result).toMatch(/^ucmn-t-\d{5}$/); + }); + + it('should lowercase campus', () => { + const result = service.GenerateFacultyUsername('UCMN'); + expect(result).toMatch(/^ucmn-t-/); + }); + }); + + describe('GenerateFakeUser', () => { + it('should generate student with correct username format', () => { + const user = service.GenerateFakeUser('ucmn', 'student'); + expect(user.username).toMatch(/^ucmn-\d{10}$/); + expect(user.password).toBe('User123#'); + expect(user.email).toContain('@faculytics.seed'); + expect(user.firstname).toBeTruthy(); + expect(user.lastname).toBeTruthy(); + }); + + it('should generate faculty with correct username format', () => { + const user = service.GenerateFakeUser('ucmn', 'faculty'); + expect(user.username).toMatch(/^ucmn-t-\d{5}$/); + expect(user.password).toBe('User123#'); + }); + }); + + describe('ComputePreview', () => { + it('should combine all transformations for a valid row', () => { + const result = service.ComputePreview( + { + courseCode: 'CS 101', + descriptiveTitle: 'Intro to CS', + program: 'BSCS', + semester: '1', + }, + { + campus: 'UCMN', + department: 'CCS', + startDate: '2025-08-01', + endDate: '2026-06-01', + startYear: '2025', + endYear: '2026', + startYY: '25', + endYY: '26', + }, + ); + + expect(result.shortname).toMatch(/^UCMN-S12526-CS101-\d{5}$/); + expect(result.fullname).toBe('Intro to CS'); + expect(result.categoryPath).toBe('UCMN / S12526 / CCS / BSCS'); + expect(result.startDate).toBe('2025-08-01'); + expect(result.endDate).toBe('2025-12-18'); + expect(result.program).toBe('BSCS'); + expect(result.semester).toBe('1'); + expect(result.courseCode).toBe('CS 101'); + }); + }); +}); diff --git a/src/modules/moodle/services/moodle-course-transform.service.ts b/src/modules/moodle/services/moodle-course-transform.service.ts new file mode 100644 index 0000000..b273349 --- /dev/null +++ b/src/modules/moodle/services/moodle-course-transform.service.ts @@ -0,0 +1,124 @@ +import { Injectable } from '@nestjs/common'; +import { randomInt } from 'crypto'; +import { faker } from '@faker-js/faker'; +import { + CurriculumRow, + CoursePreviewRow, + SeedContext, + SeedUserRecord, +} from '../lib/provisioning.types'; + +@Injectable() +export class MoodleCourseTransformService { + GenerateShortname( + campus: string, + semester: string, + startYY: string, + endYY: string, + courseCode: string, + ): string { + const code = courseCode.replace(/\s+/g, ''); + const edp = String(randomInt(0, 100000)).padStart(5, '0'); + return `${campus.toUpperCase()}-S${semester}${startYY}${endYY}-${code}-${edp}`; + } + + BuildCategoryPath( + campus: string, + semester: string, + dept: string, + program: string, + startYY: string, + endYY: string, + ): string { + return `${campus.toUpperCase()} / S${semester}${startYY}${endYY} / ${dept.toUpperCase()} / ${program.toUpperCase()}`; + } + + GetSemesterDates( + semester: string, + startYear: string, + endYear: string, + ): { startDate: string; endDate: string } | null { + if (semester === '1') { + return { + startDate: `${startYear}-08-01`, + endDate: `${startYear}-12-18`, + }; + } + if (semester === '2') { + return { + startDate: `${endYear}-01-20`, + endDate: `${endYear}-06-01`, + }; + } + return null; + } + + BuildSemesterTag(semester: string, startYY: string, endYY: string): string { + return `S${semester}${startYY}${endYY}`; + } + + ComputePreview(row: CurriculumRow, context: SeedContext): CoursePreviewRow { + const dates = this.GetSemesterDates( + row.semester, + context.startYear, + context.endYear, + ); + return { + shortname: this.GenerateShortname( + context.campus, + row.semester, + context.startYY, + context.endYY, + row.courseCode, + ), + fullname: row.descriptiveTitle, + categoryPath: this.BuildCategoryPath( + context.campus, + row.semester, + context.department, + row.program, + context.startYY, + context.endYY, + ), + categoryId: 0, + startDate: dates?.startDate ?? '', + endDate: dates?.endDate ?? '', + program: row.program, + semester: row.semester, + courseCode: row.courseCode, + }; + } + + GenerateStudentUsername(campus: string): string { + const now = new Date(); + const yy = String(now.getFullYear()).slice(-2); + const mm = String(now.getMonth() + 1).padStart(2, '0'); + const dd = String(now.getDate()).padStart(2, '0'); + const rand = String(randomInt(0, 10000)).padStart(4, '0'); + return `${campus.toLowerCase()}-${yy}${mm}${dd}${rand}`; + } + + GenerateFacultyUsername(campus: string): string { + const rand = String(randomInt(0, 100000)).padStart(5, '0'); + return `${campus.toLowerCase()}-t-${rand}`; + } + + GenerateFakeUser( + campus: string, + role: 'student' | 'faculty', + ): SeedUserRecord { + const username = + role === 'student' + ? this.GenerateStudentUsername(campus) + : this.GenerateFacultyUsername(campus); + const firstname = faker.person.firstName(); + const lastname = faker.person.lastName(); + return { + username, + firstname, + lastname, + email: `${username}@faculytics.seed`, + password: 'User123#', + }; + } +} diff --git a/src/modules/moodle/services/moodle-csv-parser.service.spec.ts b/src/modules/moodle/services/moodle-csv-parser.service.spec.ts new file mode 100644 index 0000000..1485e32 --- /dev/null +++ b/src/modules/moodle/services/moodle-csv-parser.service.spec.ts @@ -0,0 +1,80 @@ +import { BadRequestException } from '@nestjs/common'; +import { MoodleCsvParserService } from './moodle-csv-parser.service'; + +describe('MoodleCsvParserService', () => { + let service: MoodleCsvParserService; + + beforeEach(() => { + service = new MoodleCsvParserService(); + }); + + it('should parse valid CSV with 4 required columns', () => { + const csv = Buffer.from( + 'Course Code,Descriptive Title,Program,Semester\nCS101,Intro to CS,BSCS,1\nCS102,Data Structures,BSCS,2', + ); + const result = service.Parse(csv); + expect(result.rows).toHaveLength(2); + expect(result.rows[0]).toEqual({ + courseCode: 'CS101', + descriptiveTitle: 'Intro to CS', + program: 'BSCS', + semester: '1', + }); + expect(result.errors).toHaveLength(0); + }); + + it('should ignore extra columns', () => { + const csv = Buffer.from( + 'Course Code,Descriptive Title,Program,Semester,Units,Type\nCS101,Intro to CS,BSCS,1,3,Major', + ); + const result = service.Parse(csv); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].courseCode).toBe('CS101'); + }); + + it('should throw on missing required header', () => { + const csv = Buffer.from('Course Code,Program,Semester\nCS101,BSCS,1'); + expect(() => service.Parse(csv)).toThrow(BadRequestException); + expect(() => service.Parse(csv)).toThrow( + 'Missing required CSV headers: Descriptive Title', + ); + }); + + it('should flag empty required fields per row', () => { + const csv = Buffer.from( + 'Course Code,Descriptive Title,Program,Semester\nCS101,,BSCS,1', + ); + const result = service.Parse(csv); + expect(result.rows).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].rowNumber).toBe(2); + expect(result.errors[0].message).toContain('Descriptive Title'); + }); + + it('should flag semester-0 rows as warnings', () => { + const csv = Buffer.from( + 'Course Code,Descriptive Title,Program,Semester\nCS-EL,Elective,BSCS,0', + ); + const result = service.Parse(csv); + expect(result.rows).toHaveLength(0); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].reason).toContain('No semester assigned'); + }); + + it('should trim whitespace in headers', () => { + const csv = Buffer.from( + ' Course Code , Descriptive Title , Program , Semester \nCS101,Intro to CS,BSCS,1', + ); + const result = service.Parse(csv); + expect(result.rows).toHaveLength(1); + expect(result.rows[0].courseCode).toBe('CS101'); + }); + + it('should return empty arrays for headers-only CSV', () => { + const csv = Buffer.from('Course Code,Descriptive Title,Program,Semester\n'); + const result = service.Parse(csv); + expect(result.rows).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); +}); diff --git a/src/modules/moodle/services/moodle-csv-parser.service.ts b/src/modules/moodle/services/moodle-csv-parser.service.ts new file mode 100644 index 0000000..80b5535 --- /dev/null +++ b/src/modules/moodle/services/moodle-csv-parser.service.ts @@ -0,0 +1,107 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { parse } from 'csv-parse/sync'; +import { CurriculumRow, ParseError } from '../lib/provisioning.types'; + +const REQUIRED_HEADERS = [ + 'Course Code', + 'Descriptive Title', + 'Program', + 'Semester', +] as const; + +const HEADER_MAP: Record = { + 'course code': 'courseCode', + 'descriptive title': 'descriptiveTitle', + program: 'program', + semester: 'semester', +}; + +export interface CsvParseResult { + rows: CurriculumRow[]; + warnings: { rowNumber: number; courseCode: string; reason: string }[]; + errors: ParseError[]; +} + +@Injectable() +export class MoodleCsvParserService { + Parse(buffer: Buffer): CsvParseResult { + const records: Record[] = parse(buffer, { + columns: (headers: string[]) => headers.map((h) => h.trim()), + skip_empty_lines: true, + bom: true, + trim: true, + }); + + if (records.length === 0) { + return { rows: [], warnings: [], errors: [] }; + } + + const headers = Object.keys(records[0]); + this.validateHeaders(headers); + + const rows: CurriculumRow[] = []; + const warnings: CsvParseResult['warnings'] = []; + const errors: ParseError[] = []; + + for (let i = 0; i < records.length; i++) { + const rowNumber = i + 2; // 1-based + header row + const record = records[i]; + + const mapped = this.mapRecord(record); + const missing = this.findMissingFields(mapped); + + if (missing.length > 0) { + errors.push({ + rowNumber, + message: `Empty required field(s): ${missing.join(', ')}`, + }); + continue; + } + + if (mapped.semester === '0') { + warnings.push({ + rowNumber, + courseCode: mapped.courseCode, + reason: 'No semester assigned — use Quick Course Create', + }); + continue; + } + + rows.push(mapped); + } + + return { rows, warnings, errors }; + } + + private validateHeaders(headers: string[]) { + const normalized = headers.map((h) => h.toLowerCase().trim()); + const missing = REQUIRED_HEADERS.filter( + (req) => !normalized.includes(req.toLowerCase()), + ); + if (missing.length > 0) { + throw new BadRequestException( + `Missing required CSV headers: ${missing.join(', ')}`, + ); + } + } + + private mapRecord(record: Record): CurriculumRow { + const result: Partial = {}; + for (const [header, value] of Object.entries(record)) { + const key = HEADER_MAP[header.toLowerCase().trim()]; + if (key) { + result[key] = value.trim(); + } + } + return result as CurriculumRow; + } + + private findMissingFields(row: CurriculumRow): string[] { + const missing: string[] = []; + if (!row.courseCode) missing.push('Course Code'); + if (!row.descriptiveTitle) missing.push('Descriptive Title'); + if (!row.program) missing.push('Program'); + if (!row.semester && row.semester !== '0') missing.push('Semester'); + return missing; + } +} diff --git a/src/modules/moodle/services/moodle-provisioning.service.spec.ts b/src/modules/moodle/services/moodle-provisioning.service.spec.ts new file mode 100644 index 0000000..e09e0ec --- /dev/null +++ b/src/modules/moodle/services/moodle-provisioning.service.spec.ts @@ -0,0 +1,310 @@ +import { ConflictException, BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { MoodleProvisioningService } from './moodle-provisioning.service'; +import { MoodleService } from '../moodle.service'; +import { MoodleCourseTransformService } from './moodle-course-transform.service'; +import { MoodleCsvParserService } from './moodle-csv-parser.service'; +import { MoodleCategorySyncService } from './moodle-category-sync.service'; + +describe('MoodleProvisioningService', () => { + let service: MoodleProvisioningService; + let moodleService: jest.Mocked; + let em: jest.Mocked; + let _transformService: MoodleCourseTransformService; + let csvParser: jest.Mocked; + let categorySyncService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MoodleProvisioningService, + MoodleCourseTransformService, + { + provide: MoodleService, + useValue: { + GetCategoriesWithMasterKey: jest.fn(), + CreateCategories: jest.fn(), + CreateCourses: jest.fn(), + CreateUsers: jest.fn(), + EnrolUsers: jest.fn(), + }, + }, + { + provide: EntityManager, + useValue: { findOne: jest.fn() }, + }, + { + provide: MoodleCsvParserService, + useValue: { Parse: jest.fn() }, + }, + { + provide: MoodleCategorySyncService, + useValue: { SyncAndRebuildHierarchy: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(MoodleProvisioningService); + moodleService = module.get(MoodleService); + em = module.get(EntityManager); + _transformService = module.get(MoodleCourseTransformService); + csvParser = module.get(MoodleCsvParserService); + categorySyncService = module.get(MoodleCategorySyncService); + }); + + describe('ProvisionCategories', () => { + it('should create missing categories and skip existing ones', async () => { + moodleService.GetCategoriesWithMasterKey.mockResolvedValue([ + { + id: 1, + name: 'UCMN', + parent: 0, + depth: 1, + path: '1', + coursecount: 0, + visible: 1, + description: '', + }, + ] as any); + moodleService.CreateCategories.mockResolvedValue([ + { id: 10, name: 'S12526' }, + ]); + categorySyncService.SyncAndRebuildHierarchy.mockResolvedValue({ + status: 'success', + durationMs: 100, + fetched: 0, + inserted: 0, + updated: 0, + deactivated: 0, + errors: 0, + }); + + const result = await service.ProvisionCategories({ + campuses: ['UCMN'], + semesters: [1], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [{ code: 'CCS', programs: ['BSCS'] }], + }); + + expect(result.syncCompleted).toBe(true); + const skipped = result.details.filter((d) => d.status === 'skipped'); + expect(skipped.length).toBeGreaterThanOrEqual(1); + expect(skipped[0].name).toBe('UCMN'); + }); + + it('should set syncCompleted to false when sync fails', async () => { + moodleService.GetCategoriesWithMasterKey.mockResolvedValue([]); + moodleService.CreateCategories.mockResolvedValue([ + { id: 1, name: 'UCMN' }, + ]); + categorySyncService.SyncAndRebuildHierarchy.mockRejectedValue( + new Error('Sync failed'), + ); + + const result = await service.ProvisionCategories({ + campuses: ['UCMN'], + semesters: [], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [], + }); + + expect(result.syncCompleted).toBe(false); + }); + }); + + describe('PreviewCourses', () => { + it('should transform valid rows and skip semester-0', async () => { + csvParser.Parse.mockReturnValue({ + rows: [ + { + courseCode: 'CS101', + descriptiveTitle: 'Intro', + program: 'BSCS', + semester: '1', + }, + ], + warnings: [ + { + rowNumber: 3, + courseCode: 'CS-EL', + reason: 'No semester assigned — use Quick Course Create', + }, + ], + errors: [], + }); + + em.findOne.mockResolvedValue({ moodleCategoryId: 42 } as any); + + const result = await service.PreviewCourses(Buffer.from(''), { + campus: 'UCMN', + department: 'CCS', + startDate: '2025-08-01', + endDate: '2026-06-01', + startYear: '2025', + endYear: '2026', + startYY: '25', + endYY: '26', + }); + + expect(result.valid).toHaveLength(1); + expect(result.valid[0].categoryId).toBe(42); + expect(result.skipped).toHaveLength(1); + expect(result.shortnameNote).toContain('examples'); + }); + + it('should flag rows where category is not found', async () => { + csvParser.Parse.mockReturnValue({ + rows: [ + { + courseCode: 'CS101', + descriptiveTitle: 'Intro', + program: 'BSCS', + semester: '1', + }, + ], + warnings: [], + errors: [], + }); + em.findOne.mockResolvedValue(null); + + const result = await service.PreviewCourses(Buffer.from(''), { + campus: 'UCMN', + department: 'CCS', + startDate: '2025-08-01', + endDate: '2026-06-01', + startYear: '2025', + endYear: '2026', + startYY: '25', + endYY: '26', + }); + + expect(result.valid).toHaveLength(0); + expect(result.skipped).toHaveLength(1); + expect(result.skipped[0].reason).toContain('Category not found'); + }); + }); + + describe('ExecuteCourseSeeding', () => { + it('should batch courses and handle partial failures', async () => { + const batch1Results = Array.from({ length: 50 }, (_, i) => ({ + id: i + 1, + shortname: `UCMN-S12526-CS${i}-${String(i).padStart(5, '0')}`, + })); + moodleService.CreateCourses.mockResolvedValueOnce( + batch1Results, + ).mockRejectedValueOnce(new Error('shortnametaken')); + + const rows = Array.from({ length: 51 }, (_, i) => ({ + courseCode: `CS${i}`, + descriptiveTitle: `Course ${i}`, + program: 'BSCS', + semester: '1', + categoryId: 42, + })); + + const result = await service.ExecuteCourseSeeding(rows, { + campus: 'UCMN', + department: 'CCS', + startDate: '2025-08-01', + endDate: '2026-06-01', + startYear: '2025', + endYear: '2026', + startYY: '25', + endYY: '26', + }); + + expect(result.created).toBe(50); + expect(result.errors).toBe(1); + }); + }); + + describe('ExecuteQuickCourse', () => { + it('should create a single course', async () => { + em.findOne.mockResolvedValue({ moodleCategoryId: 42 } as any); + moodleService.CreateCourses.mockResolvedValue([ + { id: 100, shortname: 'UCMN-S12526-CS101-12345' }, + ]); + + const result = await service.ExecuteQuickCourse({ + courseCode: 'CS101', + descriptiveTitle: 'Intro to CS', + campus: 'UCMN', + department: 'CCS', + program: 'BSCS', + semester: 1, + startDate: '2025-08-01', + endDate: '2026-06-01', + }); + + expect(result.created).toBe(1); + expect(result.details[0].moodleId).toBe(100); + }); + + it('should throw when category not found', async () => { + em.findOne.mockResolvedValue(null); + + await expect( + service.ExecuteQuickCourse({ + courseCode: 'CS101', + descriptiveTitle: 'Intro', + campus: 'UCMN', + department: 'CCS', + program: 'BSCS', + semester: 1, + startDate: '2025-08-01', + endDate: '2026-06-01', + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('SeedUsers', () => { + it('should create users, handle null enrol response', async () => { + moodleService.CreateUsers.mockResolvedValue([ + { id: 1, username: 'ucmn-2604101234' }, + { id: 2, username: 'ucmn-2604105678' }, + ]); + moodleService.EnrolUsers.mockResolvedValue(null); + + const result = await service.SeedUsers({ + count: 2, + role: 'student', + campus: 'ucmn', + courseIds: [42], + }); + + expect(result.usersCreated).toBe(2); + expect(result.enrolmentsCreated).toBe(2); + }); + }); + + describe('Concurrency guard', () => { + it('should throw ConflictException on concurrent operations', async () => { + moodleService.CreateUsers.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 100)), + ); + moodleService.EnrolUsers.mockResolvedValue(null); + + const first = service.SeedUsers({ + count: 1, + role: 'student', + campus: 'ucmn', + courseIds: [1], + }); + + await expect( + service.SeedUsers({ + count: 1, + role: 'student', + campus: 'ucmn', + courseIds: [1], + }), + ).rejects.toThrow(ConflictException); + + await first; + }); + }); +}); diff --git a/src/modules/moodle/services/moodle-provisioning.service.ts b/src/modules/moodle/services/moodle-provisioning.service.ts new file mode 100644 index 0000000..079fafb --- /dev/null +++ b/src/modules/moodle/services/moodle-provisioning.service.ts @@ -0,0 +1,632 @@ +import { + ConflictException, + Injectable, + Logger, + BadRequestException, +} from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { MoodleService } from '../moodle.service'; +import { MoodleCourseTransformService } from './moodle-course-transform.service'; +import { MoodleCsvParserService } from './moodle-csv-parser.service'; +import { MoodleCategorySyncService } from './moodle-category-sync.service'; +import { MoodleCategoryResponse } from '../lib/moodle.types'; +import { env } from 'src/configurations/env'; +import { Program } from 'src/entities/program.entity'; +import { + MOODLE_PROVISION_BATCH_SIZE, + ProvisionCategoriesInput, + ProvisionResult, + ProvisionDetailItem, + CoursePreviewResult, + SeedContext, + ConfirmedCourseRow, + QuickCourseInput, + SeedUsersInput, + SeedUsersResult, + CoursePreviewRow, + SeedUserRecord, +} from '../lib/provisioning.types'; + +@Injectable() +export class MoodleProvisioningService { + private readonly logger = new Logger(MoodleProvisioningService.name); + private readonly activeOps = new Set(); + + constructor( + private readonly moodleService: MoodleService, + private readonly em: EntityManager, + private readonly transformService: MoodleCourseTransformService, + private readonly csvParser: MoodleCsvParserService, + private readonly categorySyncService: MoodleCategorySyncService, + ) {} + + async ProvisionCategories( + input: ProvisionCategoriesInput, + ): Promise { + this.acquireGuard('categories'); + const start = Date.now(); + const details: ProvisionDetailItem[] = []; + + try { + const existing = await this.moodleService.GetCategoriesWithMasterKey(); + const existingByParentAndName = new Map(); + for (const cat of existing) { + existingByParentAndName.set(`${cat.parent}:${cat.name}`, cat); + } + + const startYY = input.startDate.slice(2, 4); + const endYY = input.endDate.slice(2, 4); + + // Depth 1: Campuses + const campusIds = new Map(); + const missingCampuses = input.campuses.filter((c) => { + const key = `0:${c.toUpperCase()}`; + const found = existingByParentAndName.get(key); + if (found) { + campusIds.set(c.toUpperCase(), found.id); + details.push({ name: c.toUpperCase(), status: 'skipped' }); + return false; + } + return true; + }); + + if (missingCampuses.length > 0) { + try { + const results = await this.moodleService.CreateCategories( + missingCampuses.map((c) => ({ name: c.toUpperCase(), parent: 0 })), + ); + for (const r of results) { + campusIds.set(r.name, r.id); + details.push({ name: r.name, status: 'created', moodleId: r.id }); + } + } catch (err) { + for (const c of missingCampuses) { + details.push({ + name: c.toUpperCase(), + status: 'error', + reason: err instanceof Error ? err.message : String(err), + }); + } + } + } + + // Depth 2: Semesters (batched) + const semesterIds = new Map(); + const missingSemesters: { + name: string; + parent: number; + compositeKey: string; + }[] = []; + for (const campus of input.campuses) { + const campusId = campusIds.get(campus.toUpperCase()); + if (!campusId) continue; + for (const sem of input.semesters) { + const tag = this.transformService.BuildSemesterTag( + String(sem), + startYY, + endYY, + ); + const key = `${campusId}:${tag}`; + const compositeKey = `${campus.toUpperCase()}:${tag}`; + const found = existingByParentAndName.get(key); + if (found) { + semesterIds.set(compositeKey, found.id); + details.push({ name: tag, status: 'skipped' }); + } else { + missingSemesters.push({ + name: tag, + parent: campusId, + compositeKey, + }); + } + } + } + if (missingSemesters.length > 0) { + try { + const results = await this.moodleService.CreateCategories( + missingSemesters.map((s) => ({ name: s.name, parent: s.parent })), + ); + for (let i = 0; i < results.length; i++) { + semesterIds.set(missingSemesters[i].compositeKey, results[i].id); + details.push({ + name: results[i].name, + status: 'created', + moodleId: results[i].id, + }); + } + } catch (err) { + for (const s of missingSemesters) { + details.push({ + name: s.name, + status: 'error', + reason: err instanceof Error ? err.message : String(err), + }); + } + } + } + + // Depth 3: Departments (batched) + const deptIds = new Map(); + const missingDepts: { + name: string; + parent: number; + compositeKey: string; + }[] = []; + for (const campus of input.campuses) { + for (const sem of input.semesters) { + const tag = this.transformService.BuildSemesterTag( + String(sem), + startYY, + endYY, + ); + const semId = semesterIds.get(`${campus.toUpperCase()}:${tag}`); + if (!semId) continue; + for (const dept of input.departments) { + const deptName = dept.code.toUpperCase(); + const key = `${semId}:${deptName}`; + const compositeKey = `${campus.toUpperCase()}:${tag}:${deptName}`; + const found = existingByParentAndName.get(key); + if (found) { + deptIds.set(compositeKey, found.id); + details.push({ name: deptName, status: 'skipped' }); + } else { + missingDepts.push({ + name: deptName, + parent: semId, + compositeKey, + }); + } + } + } + } + if (missingDepts.length > 0) { + try { + const results = await this.moodleService.CreateCategories( + missingDepts.map((d) => ({ name: d.name, parent: d.parent })), + ); + for (let i = 0; i < results.length; i++) { + deptIds.set(missingDepts[i].compositeKey, results[i].id); + details.push({ + name: results[i].name, + status: 'created', + moodleId: results[i].id, + }); + } + } catch (err) { + for (const d of missingDepts) { + details.push({ + name: d.name, + status: 'error', + reason: err instanceof Error ? err.message : String(err), + }); + } + } + } + + // Depth 4: Programs (batched) + const missingProgs: { name: string; parent: number }[] = []; + for (const campus of input.campuses) { + for (const sem of input.semesters) { + const tag = this.transformService.BuildSemesterTag( + String(sem), + startYY, + endYY, + ); + for (const dept of input.departments) { + const deptName = dept.code.toUpperCase(); + const deptId = deptIds.get( + `${campus.toUpperCase()}:${tag}:${deptName}`, + ); + if (!deptId) continue; + for (const prog of dept.programs) { + const progName = prog.toUpperCase(); + const key = `${deptId}:${progName}`; + const found = existingByParentAndName.get(key); + if (found) { + details.push({ name: progName, status: 'skipped' }); + } else { + missingProgs.push({ name: progName, parent: deptId }); + } + } + } + } + } + if (missingProgs.length > 0) { + try { + const results = await this.moodleService.CreateCategories( + missingProgs.map((p) => ({ name: p.name, parent: p.parent })), + ); + for (const r of results) { + details.push({ name: r.name, status: 'created', moodleId: r.id }); + } + } catch (err) { + for (const p of missingProgs) { + details.push({ + name: p.name, + status: 'error', + reason: err instanceof Error ? err.message : String(err), + }); + } + } + } + + // Auto-sync local entities + let syncCompleted = true; + try { + await this.categorySyncService.SyncAndRebuildHierarchy(); + } catch (err) { + syncCompleted = false; + this.logger.warn( + 'Auto-sync failed after category provisioning', + err instanceof Error ? err.message : String(err), + ); + } + + const created = details.filter((d) => d.status === 'created').length; + const skipped = details.filter((d) => d.status === 'skipped').length; + const errors = details.filter((d) => d.status === 'error').length; + + return { + created, + skipped, + errors, + details, + durationMs: Date.now() - start, + syncCompleted, + }; + } finally { + this.releaseGuard('categories'); + } + } + + async PreviewCourses( + file: Buffer, + context: SeedContext, + ): Promise { + const { rows, warnings, errors } = this.csvParser.Parse(file); + + const valid: CoursePreviewRow[] = []; + const skipped = [ + ...warnings.map((w) => ({ + rowNumber: w.rowNumber, + courseCode: w.courseCode, + reason: w.reason, + })), + ]; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const preview = this.transformService.ComputePreview(row, context); + + const program = await this.em.findOne(Program, { + code: row.program.toUpperCase(), + department: { + code: context.department.toUpperCase(), + semester: { campus: { code: context.campus.toUpperCase() } }, + }, + }); + + if (!program?.moodleCategoryId) { + skipped.push({ + rowNumber: i + 2, + courseCode: row.courseCode, + reason: `Category not found: ${preview.categoryPath}. Provision categories first.`, + }); + continue; + } + + preview.categoryId = program.moodleCategoryId; + valid.push(preview); + } + + return { + valid, + skipped, + errors, + shortnameNote: + 'EDP codes are examples. Final codes are generated at execution time.', + }; + } + + async ExecuteCourseSeeding( + confirmedRows: ConfirmedCourseRow[], + context: SeedContext, + ): Promise { + this.acquireGuard('courses'); + const start = Date.now(); + const details: ProvisionDetailItem[] = []; + + try { + const courseInputs = confirmedRows.map((row) => { + const shortname = this.transformService.GenerateShortname( + context.campus, + row.semester, + context.startYY, + context.endYY, + row.courseCode, + ); + + const dates = this.transformService.GetSemesterDates( + row.semester, + context.startYear, + context.endYear, + ); + + return { + shortname, + fullname: row.descriptiveTitle, + categoryid: row.categoryId, + startdate: dates + ? Math.floor(new Date(dates.startDate).getTime() / 1000) + : undefined, + enddate: dates + ? Math.floor(new Date(dates.endDate).getTime() / 1000) + : undefined, + }; + }); + + for ( + let i = 0; + i < courseInputs.length; + i += MOODLE_PROVISION_BATCH_SIZE + ) { + const batch = courseInputs.slice(i, i + MOODLE_PROVISION_BATCH_SIZE); + try { + const results = await this.moodleService.CreateCourses(batch); + for (const r of results) { + details.push({ + name: r.shortname, + status: 'created', + moodleId: r.id, + }); + } + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + for (const item of batch) { + details.push({ name: item.shortname, status: 'error', reason }); + } + } + } + + return { + created: details.filter((d) => d.status === 'created').length, + skipped: 0, + errors: details.filter((d) => d.status === 'error').length, + details, + durationMs: Date.now() - start, + }; + } finally { + this.releaseGuard('courses'); + } + } + + PreviewQuickCourse(input: QuickCourseInput): CoursePreviewRow { + const startYear = input.startDate.slice(0, 4); + const endYear = input.endDate.slice(0, 4); + const startYY = startYear.slice(-2); + const endYY = endYear.slice(-2); + const sem = String(input.semester); + + return { + shortname: this.transformService.GenerateShortname( + input.campus, + sem, + startYY, + endYY, + input.courseCode, + ), + fullname: input.descriptiveTitle, + categoryPath: this.transformService.BuildCategoryPath( + input.campus, + sem, + input.department, + input.program, + startYY, + endYY, + ), + categoryId: 0, + startDate: + this.transformService.GetSemesterDates(sem, startYear, endYear) + ?.startDate ?? '', + endDate: + this.transformService.GetSemesterDates(sem, startYear, endYear) + ?.endDate ?? '', + program: input.program, + semester: sem, + courseCode: input.courseCode, + }; + } + + async ExecuteQuickCourse(input: QuickCourseInput): Promise { + this.acquireGuard('courses'); + const start = Date.now(); + + try { + const startYear = input.startDate.slice(0, 4); + const endYear = input.endDate.slice(0, 4); + const startYY = startYear.slice(-2); + const endYY = endYear.slice(-2); + const sem = String(input.semester); + + const dates = this.transformService.GetSemesterDates( + sem, + startYear, + endYear, + ); + if (!dates) { + throw new BadRequestException( + `Invalid semester ${input.semester}. Must be 1 or 2.`, + ); + } + + const program = await this.em.findOne(Program, { + code: input.program.toUpperCase(), + department: { + code: input.department.toUpperCase(), + semester: { campus: { code: input.campus.toUpperCase() } }, + }, + }); + + if (!program?.moodleCategoryId) { + throw new BadRequestException( + `Category not found for ${input.campus}/${input.department}/${input.program}. Provision categories first.`, + ); + } + + const shortname = this.transformService.GenerateShortname( + input.campus, + sem, + startYY, + endYY, + input.courseCode, + ); + + const results = await this.moodleService.CreateCourses([ + { + shortname, + fullname: input.descriptiveTitle, + categoryid: program.moodleCategoryId, + startdate: Math.floor(new Date(dates.startDate).getTime() / 1000), + enddate: Math.floor(new Date(dates.endDate).getTime() / 1000), + }, + ]); + + return { + created: 1, + skipped: 0, + errors: 0, + details: [ + { + name: results[0].shortname, + status: 'created', + moodleId: results[0].id, + }, + ], + durationMs: Date.now() - start, + }; + } finally { + this.releaseGuard('courses'); + } + } + + async SeedUsers(input: SeedUsersInput): Promise { + this.acquireGuard('users'); + const start = Date.now(); + const warnings: string[] = []; + + try { + // Generate fake users + const usernameSet = new Set(); + const users: SeedUserRecord[] = []; + + for (let i = 0; i < input.count; i++) { + let user: SeedUserRecord | null = null; + for (let attempt = 0; attempt < 4; attempt++) { + const candidate = this.transformService.GenerateFakeUser( + input.campus, + input.role, + ); + if (!usernameSet.has(candidate.username)) { + usernameSet.add(candidate.username); + user = candidate; + break; + } + } + if (!user) { + warnings.push(`Failed to generate unique username for user ${i + 1}`); + continue; + } + users.push(user); + } + + // Create users in Moodle in batches + const createdUsers: { id: number; username: string }[] = []; + let usersFailed = 0; + + for (let i = 0; i < users.length; i += MOODLE_PROVISION_BATCH_SIZE) { + const batch = users.slice(i, i + MOODLE_PROVISION_BATCH_SIZE); + try { + const results = await this.moodleService.CreateUsers( + batch.map((u) => ({ + username: u.username, + password: u.password, + firstname: u.firstname, + lastname: u.lastname, + email: u.email, + })), + ); + for (const r of results) { + createdUsers.push({ id: r.id, username: r.username }); + } + } catch (err) { + usersFailed += batch.length; + warnings.push( + `Batch creation failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + // Enrol users + const roleid = + input.role === 'student' + ? env.MOODLE_ROLE_ID_STUDENT + : env.MOODLE_ROLE_ID_EDITING_TEACHER; + + let enrolmentsCreated = 0; + + if (createdUsers.length > 0 && input.courseIds.length > 0) { + const enrolments = createdUsers.flatMap((user) => + input.courseIds.map((courseId) => ({ + userid: user.id, + courseid: courseId, + roleid, + })), + ); + + for ( + let i = 0; + i < enrolments.length; + i += MOODLE_PROVISION_BATCH_SIZE + ) { + const batch = enrolments.slice(i, i + MOODLE_PROVISION_BATCH_SIZE); + try { + const result = await this.moodleService.EnrolUsers(batch); + if (result?.warnings?.length) { + for (const w of result.warnings) { + warnings.push(`Enrolment warning: ${w.message}`); + } + } + enrolmentsCreated += batch.length; + } catch (err) { + warnings.push( + `Enrolment batch failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + + return { + usersCreated: createdUsers.length, + usersFailed, + enrolmentsCreated, + warnings, + durationMs: Date.now() - start, + }; + } finally { + this.releaseGuard('users'); + } + } + + private acquireGuard(opType: string) { + if (this.activeOps.has(opType)) { + throw new ConflictException( + 'A provisioning operation is already in progress', + ); + } + this.activeOps.add(opType); + } + + private releaseGuard(opType: string) { + this.activeOps.delete(opType); + } +} From cfb2be03a0fafdf5a59879f6c09b0f0bc3be51da Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Sat, 11 Apr 2026 18:13:49 +0800 Subject: [PATCH 2/6] 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 --- .../tech-spec-moodle-tree-explorer.md | 574 ++++++++++++++++++ .../moodle-provisioning.controller.ts | 63 ++ .../moodle-course-preview.response.dto.ts | 52 ++ .../dto/responses/moodle-tree.response.dto.ts | 47 ++ src/modules/moodle/moodle.service.ts | 4 + .../moodle-provisioning.service.spec.ts | 272 +++++++++ .../services/moodle-provisioning.service.ts | 82 +++ 7 files changed, 1094 insertions(+) create mode 100644 _bmad-output/implementation-artifacts/tech-spec-moodle-tree-explorer.md 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 diff --git a/_bmad-output/implementation-artifacts/tech-spec-moodle-tree-explorer.md b/_bmad-output/implementation-artifacts/tech-spec-moodle-tree-explorer.md new file mode 100644 index 0000000..1b109e2 --- /dev/null +++ b/_bmad-output/implementation-artifacts/tech-spec-moodle-tree-explorer.md @@ -0,0 +1,574 @@ +--- +title: 'Moodle Tree Explorer for Admin Provisioning' +slug: 'moodle-tree-explorer' +created: '2026-04-11' +status: 'completed' +stepsCompleted: [1, 2, 3, 4] +tech_stack: + [ + 'NestJS 11', + 'MikroORM 6', + 'PostgreSQL', + 'React 19', + 'Vite 8', + 'TypeScript 5', + 'shadcn/ui', + 'TanStack Query', + 'Zustand', + 'Radix UI', + ] +files_to_modify: + # API (api.faculytics) + - 'src/modules/moodle/controllers/moodle-provisioning.controller.ts' + - 'src/modules/moodle/services/moodle-provisioning.service.ts' + - 'src/modules/moodle/moodle.service.ts' + - 'src/modules/moodle/dto/responses/moodle-tree.response.dto.ts [NEW]' + - 'src/modules/moodle/dto/responses/moodle-course-preview.response.dto.ts [NEW]' + - 'src/modules/moodle/services/moodle-provisioning.service.spec.ts [NEW]' + # Admin Frontend (admin.faculytics) + - 'src/components/ui/collapsible.tsx [NEW]' + - 'src/features/moodle-provision/provision-page.tsx' + - 'src/features/moodle-provision/components/moodle-tree-sheet.tsx [NEW]' + - 'src/features/moodle-provision/components/category-tree-node.tsx [NEW]' + - 'src/features/moodle-provision/components/category-course-list.tsx [NEW]' + - 'src/features/moodle-provision/use-moodle-tree.ts [NEW]' + - 'src/features/moodle-provision/use-category-courses.ts [NEW]' + - 'src/types/api.ts' + - 'src/features/moodle-provision/components/categories-tab.tsx' + - 'src/features/moodle-provision/components/courses-bulk-tab.tsx' + - 'src/features/moodle-provision/components/quick-course-tab.tsx' +code_patterns: + - 'PascalCase public service methods' + - 'class-validator + @ApiProperty DTOs' + - '@UseJwtGuard(UserRole.SUPER_ADMIN) for admin endpoints' + - '@Audited() decorator for audit trail' + - 'apiClient(path, options) fetch wrapper' + - 'useQuery with queryKey: [feature, envId, ...params]' + - 'useMutation with onSuccess/onError + toast' + - 'Sheet + ScrollArea for side panels' +test_patterns: + - 'Jest with NestJS TestingModule for API unit tests' + - 'Mocked services via { provide: Dep, useValue: { method: jest.fn() } }' + - 'No frontend tests in admin.faculytics' +--- + +# Tech-Spec: Moodle Tree Explorer for Admin Provisioning + +**Created:** 2026-04-11 + +## Overview + +### Problem Statement + +When provisioning categories or courses in the admin console, admins have no visibility into what already exists in Moodle. They work blind — risking duplicate provisioning and confusion about the current hierarchy state. There is no way to browse the live Moodle category hierarchy or see which courses are under which categories before provisioning. + +### Solution + +Add a browsable tree view of the Moodle category hierarchy (Campus → Semester → Department → Program → Courses) to the admin provisioning page, with on-demand course listing per category. This gives admins ground-truth visibility into the Moodle state before they provision new resources. + +### Scope + +**In Scope:** + +- API endpoint(s) to fetch Moodle categories as a nested tree structure + courses per category +- Frontend tree viewer component in admin.faculytics +- Integration with the existing provisioning page at `/moodle-provision` + +**Out of Scope:** + +- Editing/deleting Moodle categories from the tree +- Triggering sync from the tree view +- Bulk operations from the tree +- Modifying existing provision tab logic or form behavior (tabs only gain a "Browse existing" button) + +## Context for Development + +### Codebase Patterns + +**API (api.faculytics):** + +- `MoodleClient` (`src/modules/moodle/lib/moodle.client.ts`) wraps Moodle REST API with typed `call()` method, 10s default timeout +- `MoodleService.GetCategoriesWithMasterKey()` returns flat `MoodleCategoryResponse[]` using master key (no user token needed) +- `MoodleService.GetCoursesByCategory(token, categoryId)` calls `getCoursesByField('category', id)` — needs a master-key variant +- `MoodleProvisioningService` (`src/modules/moodle/services/moodle-provisioning.service.ts`) already injects `MoodleService` — new tree/course methods go here to avoid changing controller dependencies +- Controller pattern: `@ApiTags` + `@Controller('moodle/provision')` + `@UseJwtGuard(UserRole.SUPER_ADMIN)` + `@ApiBearerAuth()` + `@Audited()` + interceptor stack +- `MoodleClient` throws `MoodleConnectivityError` on network/timeout failures — must be caught and mapped to HTTP 502/503 at controller layer +- DTOs use `class-validator` decorators + `@ApiProperty` for Swagger +- Public service methods use PascalCase +- Module default-exports, registers controllers/providers explicitly + +**Admin Frontend (admin.faculytics):** + +- `apiClient(path, options)` — fetch wrapper auto-prefixing `/api/v1/`, injects Bearer token, handles 401 refresh +- Query key convention: `['feature-name', envId, ...params]` +- Mutations: `useMutation` with `onSuccess`/`onError` + `toast` from Sonner +- Provisioning page: Tabs layout with separate components per tab, local `useState` for form state +- Existing shadcn components available: Sheet, ScrollArea, Tooltip, Badge, Button, Card +- Missing shadcn component: **Collapsible** (needed for tree expand/collapse — install via `bunx shadcn add collapsible`) + +**Moodle Category Structure:** + +- `MoodleCategoryResponse`: `id`, `name`, `parent` (0=root), `depth` (1-4), `path` ("/1/2/5"), `coursecount`, `visible` +- `MoodleCourse`: `id`, `shortname`, `fullname`, `category` (parent ID), `enrolledusercount`, `visible`, `startdate`, `enddate` +- Tree must be constructed from flat array using `parent` field — no existing tree-builder utility for API responses + +### Files to Reference + +| File | Purpose | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `api: src/modules/moodle/services/moodle-provisioning.service.ts` | Provisioning service — add tree + course methods here (already injects `MoodleService`) | +| `api: src/modules/moodle/moodle.service.ts` | Service with `GetCategoriesWithMasterKey()` — called by provisioning service | +| `api: src/modules/moodle/lib/moodle.client.ts` | Low-level Moodle REST client — no changes needed, methods exist | +| `api: src/modules/moodle/controllers/moodle-provisioning.controller.ts` | Provisioning controller — add 2 new GET endpoints here | +| `api: src/modules/moodle/dto/responses/moodle-category.response.dto.ts` | Existing category DTO — reference for tree node structure | +| `api: src/modules/moodle/dto/responses/course.response.dto.ts` | Existing course DTO — reference for course preview fields | +| `api: src/modules/moodle/moodle.module.ts` | Module registration — no changes needed if using existing service | +| `admin: src/features/moodle-provision/provision-page.tsx` | Tab layout wrapper — add Sheet state + mount here (shared across all tabs) | +| `admin: src/features/moodle-provision/components/categories-tab.tsx` | Add "Browse existing" button after departments section | +| `admin: src/features/moodle-provision/components/courses-bulk-tab.tsx` | Add "Browse existing" button in upload view | +| `admin: src/features/moodle-provision/components/quick-course-tab.tsx` | Add "Browse existing" button after program input | +| `admin: src/lib/api-client.ts` | Fetch wrapper — no changes needed | +| `admin: src/types/api.ts` | Add new tree/course preview response types | +| `admin: src/components/ui/sheet.tsx` | Existing Sheet component (Radix UI based) | +| `admin: src/components/ui/scroll-area.tsx` | Existing ScrollArea component | +| `admin: src/components/ui/tooltip.tsx` | Existing Tooltip component | + +### Technical Decisions (Resolved via Party Mode) + +1. **Data source:** Live Moodle via master key. Client-side `staleTime` of 2-3 minutes. Manual refresh button with "Last fetched" timestamp for trust. +2. **UX placement:** Side panel (Sheet) accessible from any provision tab. Context-aware entry points ("Browse existing" links near relevant inputs). Sheet opens at relevant depth based on active tab. +3. **Interaction depth:** Read-only browse for V1. No cross-filling of provision forms. Click-to-copy on category names and Moodle IDs for reference. +4. **Course detail level:** Show shortname, fullname, enrolled user count, visible status, moodleId (click-to-copy). Start/end dates in hover tooltip only. + +## Implementation Plan + +### Tasks + +#### Phase 1: API — Response DTOs (no dependencies) + +- [x] Task 1: Create tree response DTO + - File: `api.faculytics/src/modules/moodle/dto/responses/moodle-tree.response.dto.ts` [NEW] + - Action: Create two classes: + - `MoodleCategoryTreeNodeDto` — `id: number`, `name: string`, `depth: number`, `coursecount: number`, `visible: number`, `children: MoodleCategoryTreeNodeDto[]`. Use `class-validator` decorators (`@IsNumber`, `@IsString`, `@IsArray`, `@ValidateNested({ each: true })`). Import `@Type` from `class-transformer` (separate package from `class-validator`) and add `@Type(() => MoodleCategoryTreeNodeDto)` on `children` for recursive Swagger schema. Add `@ApiProperty` on all fields with recursive type annotation on `children`. + - `MoodleCategoryTreeResponseDto` — `tree: MoodleCategoryTreeNodeDto[]`, `fetchedAt: string` (ISO timestamp), `totalCategories: number`. Add `@ApiProperty` decorators. + +- [x] Task 2: Create course preview response DTO + - File: `api.faculytics/src/modules/moodle/dto/responses/moodle-course-preview.response.dto.ts` [NEW] + - Action: Create two classes: + - `MoodleCoursePreviewDto` — `id: number`, `shortname: string`, `fullname: string`, `enrolledusercount?: number` (optional — availability depends on Moodle version and master key permissions), `visible: number`, `startdate: number`, `enddate: number`. Use `class-validator` + `@ApiProperty`. Mark `enrolledusercount` with `@IsOptional()` and `@ApiPropertyOptional()`. Import `@Type` from `class-transformer` for nested validation. + - `MoodleCategoryCoursesResponseDto` — `categoryId: number`, `courses: MoodleCoursePreviewDto[]`. Use `@ValidateNested({ each: true })` + `@Type(() => MoodleCoursePreviewDto)`. + - Notes: `categoryName` is intentionally excluded — the frontend already has it from the tree data and passes it as a prop. This avoids a redundant full-category fetch on every course-list request. + +#### Phase 2: API — Service Methods (depends on Phase 1) + +- [x] Task 3: Add tree-building service method + - File: `api.faculytics/src/modules/moodle/services/moodle-provisioning.service.ts` + - Action: Add `async GetCategoryTree(): Promise` method: + 1. Call `this.moodleService.GetCategoriesWithMasterKey()` to get flat `MoodleCategoryResponse[]` + 2. Build nested tree using O(n) three-pass algorithm: + - **Pass 1 — Create nodes:** Iterate flat array. For each `MoodleCategoryResponse`, create a `MoodleCategoryTreeNodeDto` by mapping only DTO fields: `{ id: cat.id, name: cat.name, depth: cat.depth, coursecount: cat.coursecount, visible: cat.visible, children: [] }`. Store in `Map` keyed by `id`. Also store sortorder in a separate `Map` (`sortorderMap.set(cat.id, cat.sortorder)`) for sorting in step 3. + - **Pass 2 — Attach children:** Iterate flat array again. For each category, look up parent node in Map via `cat.parent`. If parent exists, push current node into `parent.children`. If `cat.parent === 0`, add to `rootNodes[]`. + 3. **Pass 3 — Sort children:** Iterate all nodes in the Map. For each node with `children.length > 1`, sort `children` by `sortorderMap.get(child.id)` ascending (preserves Moodle admin's intended ordering, NOT alphabetical). Also sort `rootNodes` by sortorder. `sortorder` is used for sorting only — it is NOT included in the DTO or API response. + 4. Return `{ tree: rootNodes, fetchedAt: new Date().toISOString(), totalCategories: flat.length }` + - Notes: O(n) three-pass algorithm (create, attach, sort). No recursion needed. `MoodleProvisioningService` already injects `MoodleService`, so `GetCategoriesWithMasterKey()` is available directly. + +- [x] Task 4: Add master-key course-by-category method + - File: `api.faculytics/src/modules/moodle/services/moodle-provisioning.service.ts` + - Action: + 1. **First, add helper to `MoodleService`** (`src/modules/moodle/moodle.service.ts`): Add method `async GetCoursesByFieldWithMasterKey(field: string, value: string): Promise<{ courses: MoodleCourse[] }>` — one-liner: `return this.BuildMasterClient().getCoursesByField(field, value)`. Follows same pattern as existing `GetCategoriesWithMasterKey()`. + 2. **Then, add to `MoodleProvisioningService`**: `async GetCoursesByCategoryWithMasterKey(categoryId: number): Promise`: + - Call `const { courses } = await this.moodleService.GetCoursesByFieldWithMasterKey('category', categoryId.toString())` — destructure `courses` from the `{ courses: MoodleCourse[] }` wrapper + - Map `courses` array to `MoodleCoursePreviewDto[]` — pick only: `{ id, shortname, fullname, enrolledusercount, visible, startdate, enddate }` + - Return `{ categoryId, courses }` + - Notes: No `categoryName` in response — frontend already has it from the tree. No redundant category fetch needed. `enrolledusercount` may be `undefined` at runtime despite `MoodleCourse` declaring it required — Moodle API responses are JSON-parsed via `response.json() as T` with no class-transformer validation, so missing fields silently become `undefined`. + +#### Phase 3: API — Controller Endpoints (depends on Phase 2) + +- [x] Task 5: Add GET /moodle/provision/tree endpoint + - File: `api.faculytics/src/modules/moodle/controllers/moodle-provisioning.controller.ts` + - Action: Add endpoint method: + ``` + @Get('tree') + @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Fetch Moodle category tree (live)' }) + @ApiResponse({ status: 200, type: MoodleCategoryTreeResponseDto }) + async GetCategoryTree(): Promise + ``` + Call `this.provisioningService.GetCategoryTree()` and return result. + Wrap in try/catch: + - `MoodleConnectivityError` → `throw new BadGatewayException('Moodle is unreachable')` + - Generic `Error` → `throw new ServiceUnavailableException('Moodle returned an error: ' + e.message)` + - **New imports required for controller** (these are not currently imported): + - `@nestjs/common`: add `Get`, `Param`, `ParseIntPipe`, `BadGatewayException`, `ServiceUnavailableException` to existing import (`BadRequestException` is already imported) + - `@nestjs/swagger`: `ApiParam`, `ApiBearerAuth` + - `src/modules/moodle/lib/moodle.client`: `MoodleConnectivityError` + - Response DTOs from their respective new files + - Notes: GET (not POST) since this is a read-only fetch. No `@Audited()` needed — read-only endpoint, no audit trail or metadata injection needed. No `@UseInterceptors()`. No `@Body()` — no request parameters. `@ApiBearerAuth()` enables Swagger "Authorize" button for testing. Note: existing POST endpoints on this controller lack `@ApiBearerAuth()` — this is known tech debt; adding at class level is out of scope for this feature. + +- [x] Task 6: Add GET /moodle/provision/tree/:categoryId/courses endpoint + - File: `api.faculytics/src/modules/moodle/controllers/moodle-provisioning.controller.ts` + - Action: Add endpoint method: + ``` + @Get('tree/:categoryId/courses') + @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Fetch courses for a Moodle category (live)' }) + @ApiResponse({ status: 200, type: MoodleCategoryCoursesResponseDto }) + @ApiParam({ name: 'categoryId', type: Number }) + async GetCategoryCourses(@Param('categoryId', ParseIntPipe) categoryId: number): Promise + ``` + Add input validation: `if (categoryId < 1) throw new BadRequestException('Category ID must be a positive integer')` + Call `this.provisioningService.GetCoursesByCategoryWithMasterKey(categoryId)` and return result. + Wrap in same try/catch pattern as Task 5 for Moodle error mapping. + - Notes: Use `ParseIntPipe` to validate and convert the path param. Import `@ApiParam`, `@ApiBearerAuth` from `@nestjs/swagger`. Import `BadGatewayException`, `ServiceUnavailableException`, `BadRequestException` from `@nestjs/common`. + +#### Phase 4: API — Unit Tests (depends on Phase 3) + +- [x] Task 7: Add service unit tests for tree building + - File: `api.faculytics/src/modules/moodle/services/moodle-provisioning.service.spec.ts` [NEW] + - Setup: Create `TestingModule` with all 5 constructor dependencies: + ```typescript + const module = await Test.createTestingModule({ + providers: [ + MoodleProvisioningService, + { + provide: MoodleService, + useValue: { + GetCategoriesWithMasterKey: jest.fn(), + GetCoursesByFieldWithMasterKey: jest.fn(), + }, + }, + { provide: EntityManager, useValue: {} }, + { provide: MoodleCourseTransformService, useValue: {} }, + { provide: MoodleCsvParserService, useValue: {} }, + { provide: MoodleCategorySyncService, useValue: {} }, + ], + }).compile(); + ``` + Only `MoodleService` needs real mock methods. The other 4 are empty stubs — tree/course methods don't touch them. + - Action: Test `GetCategoryTree()`: + - Mock `MoodleService.GetCategoriesWithMasterKey()` to return a flat array of 7-8 categories across 4 depths with `sortorder` values + - Assert returned tree has correct nesting (depth 1 at root, depth 2 as children of depth 1, etc.) + - Assert children are sorted by `sortorder` ascending (not alphabetical) + - Assert field mapping: only `id`, `name`, `depth`, `coursecount`, `visible`, `children` (6 fields) — no `sortorder` or other extra fields from `MoodleCategoryResponse` (sortorder is used for ordering only, not serialized) + - Assert `totalCategories` matches input count + - Assert `fetchedAt` is a valid ISO string + - Edge case: empty category list returns `{ tree: [], totalCategories: 0, fetchedAt: }` + - Test `GetCoursesByCategoryWithMasterKey()`: + - Mock `MoodleService.GetCoursesByFieldWithMasterKey()` to return `{ courses: [3 mock courses] }` + - Assert response contains each named field: `id`, `shortname`, `fullname`, `visible`, `startdate`, `enddate`, and optionally `enrolledusercount` (may be `undefined`) + - Assert `categoryId` is echoed back, no `categoryName` in response + +#### Phase 5: Frontend — Setup & Types (no dependencies) + +- [x] Task 8: Install Collapsible shadcn component + - File: `admin.faculytics/src/components/ui/collapsible.tsx` [NEW] + - Action: Run `cd ../admin.faculytics && bunx shadcn add collapsible` + - Notes: This installs the Radix UI Collapsible primitive wrapper. + +- [x] Task 9: Add tree response types + - File: `admin.faculytics/src/types/api.ts` + - Action: Add TypeScript interfaces matching API DTOs: + + ```typescript + export interface MoodleCategoryTreeNode { + id: number; + name: string; + depth: number; + coursecount: number; + /** 0=hidden, 1=visible (Moodle convention) */ + visible: number; + children: MoodleCategoryTreeNode[]; + } + + export interface MoodleCategoryTreeResponse { + tree: MoodleCategoryTreeNode[]; + fetchedAt: string; + totalCategories: number; + } + + export interface MoodleCoursePreview { + id: number; + shortname: string; + fullname: string; + /** May be 0 or absent depending on Moodle version/master key permissions */ + enrolledusercount?: number; + /** 0=hidden, 1=visible (Moodle convention) */ + visible: number; + startdate: number; + enddate: number; + } + + export interface MoodleCategoryCoursesResponse { + categoryId: number; + courses: MoodleCoursePreview[]; + } + ``` + + - Notes: `categoryName` is intentionally absent from the API response — the frontend already has it from the tree data. Add JSDoc on `visible` fields: `/** 0=hidden, 1=visible (Moodle convention) */` + +#### Phase 6: Frontend — Query Hooks (depends on Phase 5) + +- [x] Task 10: Create tree query hook + - File: `admin.faculytics/src/features/moodle-provision/use-moodle-tree.ts` [NEW] + - Action: Export `useMoodleTree()` hook: + ```typescript + export function useMoodleTree() { + const activeEnvId = useEnvStore((s) => s.activeEnvId); + const isAuth = useAuthStore((s) => + activeEnvId ? s.isAuthenticated(activeEnvId) : false, + ); + return useQuery({ + queryKey: ['moodle-tree', activeEnvId], + queryFn: () => + apiClient('/moodle/provision/tree'), + staleTime: 3 * 60 * 1000, // 3 minutes + enabled: !!activeEnvId && isAuth, + }); + } + ``` + - Notes: `staleTime: 3 minutes` matches party mode decision. No `refetchInterval` — manual refresh via `refetch()`. `isAuth` guard prevents unauthenticated requests (matches existing hook pattern, e.g., `useSyncHistory`). + +- [x] Task 11: Create category courses query hook + - File: `admin.faculytics/src/features/moodle-provision/use-category-courses.ts` [NEW] + - Action: Export `useCategoryCourses(categoryId)` hook: + + ```typescript + import { keepPreviousData } from '@tanstack/react-query'; + + export function useCategoryCourses(categoryId: number | null) { + const activeEnvId = useEnvStore((s) => s.activeEnvId); + const isAuth = useAuthStore((s) => + activeEnvId ? s.isAuthenticated(activeEnvId) : false, + ); + return useQuery({ + queryKey: ['moodle-tree', 'courses', activeEnvId, categoryId], + queryFn: () => + apiClient( + `/moodle/provision/tree/${categoryId}/courses`, + ), + staleTime: 3 * 60 * 1000, + enabled: !!activeEnvId && isAuth && categoryId !== null, + placeholderData: keepPreviousData, + }); + } + ``` + + - Notes: `enabled` guards against null categoryId and unauthenticated state. `placeholderData: keepPreviousData` shows previous category's courses while new one loads, preventing loading flicker on rapid clicks. Query key shares `'moodle-tree'` prefix with the tree hook for coherent invalidation. Leading slash on path matches existing hook conventions. **Version note:** `keepPreviousData` as an imported function requires TanStack Query v5+. Verify with `bun list @tanstack/react-query`. If on v4, use `keepPreviousData: true` (boolean option) instead. + +#### Phase 7: Frontend — Tree Components (depends on Phase 6) + +- [x] Task 12: Create recursive tree node component + - File: `admin.faculytics/src/features/moodle-provision/components/category-tree-node.tsx` [NEW] + - Action: Create `CategoryTreeNode` component: + - Props: `node: MoodleCategoryTreeNode`, `onSelectCategory: (id: number, name: string) => void`, `defaultExpanded?: boolean`, `matchingIds?: Set`, `ancestorIds?: Set` + - Uses shadcn `Collapsible`, `CollapsibleTrigger`, `CollapsibleContent` + - Display: chevron icon (rotates on expand) + folder/category icon by depth + `node.name` + `Badge` with `coursecount` if > 0 + - Click on node name text: calls `onSelectCategory(node.id, node.name)` to navigate to course list + - Separate small clipboard icon button beside the name: calls `navigator.clipboard.writeText(node.name)` with toast "Copied to clipboard". These are two distinct click targets — name navigates, icon copies. + - Recursively renders `CategoryTreeNode` for each child in `node.children` + - Depth visual indicators: indent via `style={{ paddingLeft: node.depth * 16 }}` (inline style — Tailwind JIT cannot detect dynamic class names like `pl-${n}`, so use inline style for computed values). Use different icons per depth: `{ 1: Building, 2: Calendar, 3: Briefcase, 4: GraduationCap }` from Lucide. Use `FolderOpen` as fallback for `depth >= 5` (`const Icon = depthIcons[node.depth] ?? FolderOpen`) + - Dim styling if `node.visible === 0` (hidden category) + - **Accessibility:** Add `role="treeitem"` on each node container. Add `aria-expanded={isOpen}` on collapsible nodes. Radix `Collapsible` handles Enter/Space toggle natively. + - Notes: Recursive component. Keep it simple — no virtualization needed for 200-500 nodes. The root container in the parent (Sheet) should have `role="tree"`. + +- [x] Task 13: Create course list component + - File: `admin.faculytics/src/features/moodle-provision/components/category-course-list.tsx` [NEW] + - Action: Create `CategoryCourseList` component: + - Props: `categoryId: number | null`, `categoryName: string`, `onBack: () => void` + - Uses `useCategoryCourses(categoryId)` hook + - Loading state: `Loader2` spinner + - Empty state: "No courses in this category" message + - Data state: table/list of courses with columns: + - `shortname` (monospace text) + - `fullname` + - `enrolledusercount` (number badge when `> 0`, show "—" when falsy/absent — availability depends on Moodle version and master key permissions) + - Visibility indicator (Eye/EyeOff icon) + - `id` with click-to-copy button (small copy icon, toast "Course ID copied") + - Each row: hover shows `Tooltip` with start/end dates formatted as readable dates (convert unix timestamp) + - Header shows `categoryName` (from prop, not API response) + back button to return to tree view + - Notes: Uses `ScrollArea` for the list if it overflows. + +- [x] Task 14: Create main Sheet wrapper component + - File: `admin.faculytics/src/features/moodle-provision/components/moodle-tree-sheet.tsx` [NEW] + - Action: Create `MoodleTreeSheet` component: + - Props: `open: boolean`, `onOpenChange: (open: boolean) => void` + - Internal state: + - `selectedCategoryId: number | null` — toggles between tree view and course list view + - `selectedCategoryName: string` — name of selected category (passed to `CategoryCourseList` as prop) + - `expandedIds: Set` — manually expanded/collapsed node IDs + - `searchTerm: string` — filter input value + - Uses `useMoodleTree()` hook for tree data + - Sheet layout: + - `SheetHeader`: Title "Moodle Categories" + refresh button (`RefreshCw` icon, calls `refetch()`) + "Last fetched" relative timestamp from `fetchedAt` + total category count badge + - `SheetContent` (side="right", `className="w-[480px] sm:w-[540px]"`): + - When `selectedCategoryId === null`: render tree view with `ScrollArea` (add `role="tree"` on root container) containing recursive `CategoryTreeNode` for each root node + - When `selectedCategoryId !== null`: render `CategoryCourseList` with `categoryName={selectedCategoryName}` and back button that resets `selectedCategoryId` to null + - Search/filter: `Input` at top of tree view with `searchTerm` state + - **Search/filter algorithm** (compute via `useMemo` from tree data + `searchTerm`): + 1. Build search index with a recursive helper function: + ```typescript + function buildSearchIndex( + nodes: MoodleCategoryTreeNode[], + parentId: number | null, + index: { + parentMap: Map; + allNodes: MoodleCategoryTreeNode[]; + }, + ) { + for (const node of nodes) { + index.parentMap.set(node.id, parentId); + index.allNodes.push(node); + buildSearchIndex(node.children, node.id, index); + } + } + ``` + This walks the nested tree once and produces: `parentMap` (node.id → parent node.id) for ancestor walking, and `allNodes` flat list for filtering. + 2. Filter `allNodes`: if `node.name.toLowerCase().includes(searchTerm.toLowerCase())`, add `node.id` to `matchingIds: Set` + 3. For each matching node, walk up `parentMap` chain, adding each ancestor ID to `ancestorIds: Set` + 4. Pass `matchingIds` and `ancestorIds` to `CategoryTreeNode` as props + 5. A node is **visible** when: no search active (`searchTerm === ''`), OR node is in `matchingIds` or `ancestorIds` + 6. A node is **force-expanded** when: it is in `ancestorIds` (overrides `expandedIds` state during search) + 7. When `searchTerm` is cleared, revert to manual `expandedIds` state + - `onSelectCategory` callback: `(id: number, name: string) => { setSelectedCategoryId(id); setSelectedCategoryName(name); }` + - Loading state: skeleton or centered `Loader2` + - Empty state: when `data && data.tree.length === 0` → centered "No categories found in Moodle" message (checked after loading, before tree render) + - Error state: destructure `error` from `useMoodleTree()` query result. Check `error instanceof ApiError && (error.status === 502 || error.status === 503)` → "Failed to connect to Moodle" with retry button (`refetch()`). Both 502 (connectivity) and 503 (Moodle error) show the same user-facing message. TanStack Query types `error` as `Error | null` — `ApiError extends Error` so `instanceof` works directly. + - Notes: The Sheet is a controlled component — parent (`ProvisionPage`) manages `open` state. Internal view switching between tree and course list via `selectedCategoryId`. Import `ApiError` from `@/lib/api-client` for error type checking (`error instanceof ApiError`). + +#### Phase 8: Frontend — Integration (depends on Phase 7) + +- [x] Task 15: Mount Sheet in ProvisionPage (shared across all tabs) + - File: `admin.faculytics/src/features/moodle-provision/provision-page.tsx` + - Action: + 1. Add state: `const [treeOpen, setTreeOpen] = useState(false)` + 2. Create callback: `const onBrowse = () => setTreeOpen(true)` + 3. Add `` at the end of the component JSX (outside `Tabs` but inside the page wrapper) + 4. Pass `onBrowse` as a prop to each tab component: ``, ``, `` + - Notes: Single Sheet instance shared across all tabs. One query, one component tree, one expanded state preserved across tab switches. + +- [x] Task 16: Add browse button to categories tab + - File: `admin.faculytics/src/features/moodle-provision/components/categories-tab.tsx` + - Action: + 1. Add props interface and update function signature: + ```typescript + interface CategoriesTabProps { onBrowse: () => void } + export function CategoriesTab({ onBrowse }: CategoriesTabProps) { + ``` + 2. Add a "Browse existing categories" button after the departments section, before the submit button. Use `Button variant="outline"` with `FolderTree` icon from Lucide. `onClick={onBrowse}` + - Notes: No local Sheet state — just a button calling the parent's callback. + +- [x] Task 17: Add browse button to courses bulk tab + - File: `admin.faculytics/src/features/moodle-provision/components/courses-bulk-tab.tsx` + - Action: + 1. Add props interface and update function signature: + ```typescript + interface CoursesBulkTabProps { onBrowse: () => void } + export function CoursesBulkTab({ onBrowse }: CoursesBulkTabProps) { + ``` + 2. Add a "Browse existing" button in the upload view, next to or below the CSV drop zone. Use `Button variant="outline" size="sm"` with `FolderTree` icon. `onClick={onBrowse}` + - Notes: Only visible in the `upload` view, not the `preview` view. + +- [x] Task 18: Add browse button to quick course tab + - File: `admin.faculytics/src/features/moodle-provision/components/quick-course-tab.tsx` + - Action: + 1. Add props interface and update function signature: + ```typescript + interface QuickCourseTabProps { onBrowse: () => void } + export function QuickCourseTab({ onBrowse }: QuickCourseTabProps) { + ``` + 2. Add a "Browse existing" button after the program input field. Use `Button variant="outline" size="sm"` with `FolderTree` icon. `onClick={onBrowse}` + - Notes: `SeedUsersTab` intentionally does NOT receive `onBrowse` — browsing categories is not relevant when seeding users. Render it without props in `provision-page.tsx`: ``. + +### Acceptance Criteria + +#### Happy Path + +- [x] AC 1: Given the admin is on any provision tab, when they click "Browse existing categories", then a Sheet slides in from the right showing the Moodle category tree with Campus nodes at the root level. + +- [x] AC 2: Given the tree Sheet is open, when the admin expands a Campus node, then Semester children are shown indented beneath it, and further expansion reveals Department and Program levels. + +- [x] AC 3: Given the tree is displayed, when the admin clicks a category node, then the view switches to a course list showing shortname, fullname, enrolled count (or "—" if unavailable), visibility, and Moodle ID for each course in that category. + +- [x] AC 4: Given the course list is displayed, when the admin clicks the back button, then the view returns to the tree with the previously expanded state preserved. + +- [x] AC 5: Given the tree is displayed, when the admin types in the search/filter input, then the tree filters to show only categories matching the search term (at any depth), with parent nodes auto-expanded to reveal matches. + +- [x] AC 6: Given the tree Sheet is open, when the admin clicks the refresh button, then a fresh tree is fetched from Moodle and the "Last fetched" timestamp updates. + +- [x] AC 7: Given any course or category node is displayed, when the admin clicks the copy icon beside a name or Moodle ID, then the value is copied to clipboard and a toast confirms "Copied to clipboard". + +#### Error Handling + +- [x] AC 8: Given the Moodle instance is unreachable, when the tree Sheet is opened, then the API returns HTTP 502 (Bad Gateway), and the frontend displays an error message "Failed to connect to Moodle" with a "Retry" button that calls `refetch()`. + +- [x] AC 9: Given the admin is not authenticated as SUPER_ADMIN, when the tree API endpoint is called, then a 401 Unauthorized response is returned. + +#### Edge Cases + +- [x] AC 10: Given the Moodle instance has zero categories, when the tree is fetched, then an empty state message "No categories found in Moodle" is displayed. + +- [x] AC 11: Given a category has zero courses, when the admin clicks it, then the course list shows "No courses in this category" message. + +- [x] AC 12: Given a category has `visible === 0`, then it is rendered with dimmed/muted styling to visually distinguish it from visible categories. + +- [x] AC 13: Given a course row is displayed, when the admin hovers over it, then a Tooltip shows the course start and end dates formatted as human-readable dates. + +## Review Notes + +- Adversarial review completed +- Findings: 10 total, 5 fixed, 1 acknowledged (tech debt), 4 skipped (noise) +- Resolution approach: auto-fix +- F1 (Medium): Sanitized error messages in controller to prevent internal detail leakage +- F2 (Low): Acknowledged — `@ApiBearerAuth()` inconsistency is known tech debt per spec +- F3 (Low): Added explicit nullish coalescing for `enrolledusercount` mapping +- F4 (Medium): Added error state with retry button to course list component +- F5 (Low): Added state reset on sheet close via useEffect +- F6 (Low): Added promise-based clipboard write with error toast fallback + +## Additional Context + +### Dependencies + +**API:** + +- No new npm packages — uses existing `MoodleClient`, `class-validator`, `@nestjs/swagger` +- Requires valid `MOODLE_BASE_URL` and `MOODLE_MASTER_KEY` env vars (already configured) + +**Admin Frontend:** + +- Install shadcn Collapsible: `bunx shadcn add collapsible` (Radix UI primitive for tree expand/collapse) +- No other new dependencies — uses existing Sheet, ScrollArea, Tooltip, TanStack Query + +**Cross-Repo:** + +- API must be deployed/running with new endpoints before frontend can use them +- Frontend type definitions must match API response DTOs + +### Testing Strategy + +**API Unit Tests (Task 7):** + +- `GetCategoryTree()`: Mock `MoodleService.GetCategoriesWithMasterKey()` with flat fixture data across 4 depths with varying sortorder values. Assert correct nesting, sortorder-based child ordering, field mapping (only 6 fields per node — no sortorder in output), totalCategories count, fetchedAt format. Test empty array edge case. +- `GetCoursesByCategoryWithMasterKey()`: Mock course fetch to return fixture data. Assert field mapping (only 7 fields), no `categoryName` in response, `categoryId` echoed back. + +**Manual Testing:** + +- Open admin console → Moodle Provision → any tab → click "Browse existing categories" +- Verify tree loads with real Moodle data, expand/collapse works at all 4 levels +- Click a program-level category with courses → verify course list renders +- Click back → verify tree state is preserved +- Type in search → verify filtering with parent auto-expansion +- Click refresh → verify timestamp updates +- Click-to-copy on IDs and names → verify clipboard + toast +- Test with Moodle offline → verify error state and retry +- Test with empty Moodle instance → verify empty state + +**No frontend automated tests** — admin.faculytics has no test infrastructure. Manual testing against real Moodle instance is the validation path. + +### Notes + +- The `MoodleController` (separate from `MoodleProvisioningController`) has `POST /moodle/get-course-categories` but it requires a user token. The new tree endpoints go on the provisioning controller and use the master key — keeping admin operations token-free. +- Tree construction from flat categories is O(n) using a Map keyed by `id` — straightforward implementation. +- Moodle `getCategories()` returns ALL categories in one call (no pagination). For typical Faculytics instances (200-500 categories), this is sub-second. diff --git a/src/modules/moodle/controllers/moodle-provisioning.controller.ts b/src/modules/moodle/controllers/moodle-provisioning.controller.ts index 8265508..de5d139 100644 --- a/src/modules/moodle/controllers/moodle-provisioning.controller.ts +++ b/src/modules/moodle/controllers/moodle-provisioning.controller.ts @@ -1,17 +1,25 @@ import { + BadGatewayException, BadRequestException, Body, Controller, + Get, HttpCode, HttpStatus, + Logger, + Param, + ParseIntPipe, Post, + ServiceUnavailableException, UploadedFile, UseInterceptors, } from '@nestjs/common'; import { + ApiBearerAuth, ApiBody, ApiConsumes, ApiOperation, + ApiParam, ApiResponse, ApiTags, } from '@nestjs/swagger'; @@ -33,6 +41,9 @@ import { ProvisionResultDto } from '../dto/responses/provision-result.response.d import { CoursePreviewResultDto } from '../dto/responses/course-preview.response.dto'; import { CoursePreviewRowResponseDto } from '../dto/responses/course-preview.response.dto'; import { SeedUsersResultDto } from '../dto/responses/seed-users-result.response.dto'; +import { MoodleConnectivityError } from '../lib/moodle.client'; +import { MoodleCategoryTreeResponseDto } from '../dto/responses/moodle-tree.response.dto'; +import { MoodleCategoryCoursesResponseDto } from '../dto/responses/moodle-course-preview.response.dto'; import { SeedContext } from '../lib/provisioning.types'; function csvFileFilter( @@ -70,6 +81,8 @@ function buildSeedContext(dto: SeedCoursesContextDto): SeedContext { @ApiTags('Moodle Provisioning') @Controller('moodle/provision') export class MoodleProvisioningController { + private readonly logger = new Logger(MoodleProvisioningController.name); + constructor( private readonly provisioningService: MoodleProvisioningService, ) {} @@ -210,4 +223,54 @@ export class MoodleProvisioningController { ): Promise { return await this.provisioningService.SeedUsers(dto); } + + @Get('tree') + @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Fetch Moodle category tree (live)' }) + @ApiResponse({ status: 200, type: MoodleCategoryTreeResponseDto }) + async GetCategoryTree(): Promise { + try { + return await this.provisioningService.GetCategoryTree(); + } catch (e) { + if (e instanceof MoodleConnectivityError) { + throw new BadGatewayException('Moodle is unreachable'); + } + this.logger.error( + 'Failed to fetch category tree', + e instanceof Error ? e.stack : e, + ); + throw new ServiceUnavailableException( + 'Failed to fetch Moodle categories', + ); + } + } + + @Get('tree/:categoryId/courses') + @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Fetch courses for a Moodle category (live)' }) + @ApiResponse({ status: 200, type: MoodleCategoryCoursesResponseDto }) + @ApiParam({ name: 'categoryId', type: Number }) + async GetCategoryCourses( + @Param('categoryId', ParseIntPipe) categoryId: number, + ): Promise { + if (categoryId < 1) { + throw new BadRequestException('Category ID must be a positive integer'); + } + try { + return await this.provisioningService.GetCoursesByCategoryWithMasterKey( + categoryId, + ); + } catch (e) { + if (e instanceof MoodleConnectivityError) { + throw new BadGatewayException('Moodle is unreachable'); + } + this.logger.error( + `Failed to fetch courses for category ${categoryId}`, + e instanceof Error ? e.stack : e, + ); + throw new ServiceUnavailableException('Failed to fetch Moodle courses'); + } + } } diff --git a/src/modules/moodle/dto/responses/moodle-course-preview.response.dto.ts b/src/modules/moodle/dto/responses/moodle-course-preview.response.dto.ts new file mode 100644 index 0000000..3b19e7f --- /dev/null +++ b/src/modules/moodle/dto/responses/moodle-course-preview.response.dto.ts @@ -0,0 +1,52 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsNumber, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; + +export class MoodleCoursePreviewDto { + @ApiProperty({ example: 101 }) + @IsNumber() + id: number; + + @ApiProperty({ example: 'CS101-2026' }) + @IsString() + shortname: string; + + @ApiProperty({ example: 'Introduction to Computer Science' }) + @IsString() + fullname: string; + + @ApiPropertyOptional({ example: 45 }) + @IsOptional() + @IsNumber() + enrolledusercount?: number; + + @ApiProperty({ example: 1 }) + @IsNumber() + visible: number; + + @ApiProperty({ example: 1712800000 }) + @IsNumber() + startdate: number; + + @ApiProperty({ example: 1720000000 }) + @IsNumber() + enddate: number; +} + +export class MoodleCategoryCoursesResponseDto { + @ApiProperty({ example: 5 }) + @IsNumber() + categoryId: number; + + @ApiProperty({ type: [MoodleCoursePreviewDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MoodleCoursePreviewDto) + courses: MoodleCoursePreviewDto[]; +} diff --git a/src/modules/moodle/dto/responses/moodle-tree.response.dto.ts b/src/modules/moodle/dto/responses/moodle-tree.response.dto.ts new file mode 100644 index 0000000..23ba1c0 --- /dev/null +++ b/src/modules/moodle/dto/responses/moodle-tree.response.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsNumber, IsString, ValidateNested } from 'class-validator'; + +export class MoodleCategoryTreeNodeDto { + @ApiProperty({ example: 5 }) + @IsNumber() + id: number; + + @ApiProperty({ example: 'Campus A' }) + @IsString() + name: string; + + @ApiProperty({ example: 1 }) + @IsNumber() + depth: number; + + @ApiProperty({ example: 12 }) + @IsNumber() + coursecount: number; + + @ApiProperty({ example: 1 }) + @IsNumber() + visible: number; + + @ApiProperty({ type: () => [MoodleCategoryTreeNodeDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MoodleCategoryTreeNodeDto) + children: MoodleCategoryTreeNodeDto[]; +} + +export class MoodleCategoryTreeResponseDto { + @ApiProperty({ type: [MoodleCategoryTreeNodeDto] }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => MoodleCategoryTreeNodeDto) + tree: MoodleCategoryTreeNodeDto[]; + + @ApiProperty({ example: '2026-04-11T10:00:00.000Z' }) + @IsString() + fetchedAt: string; + + @ApiProperty({ example: 25 }) + @IsNumber() + totalCategories: number; +} diff --git a/src/modules/moodle/moodle.service.ts b/src/modules/moodle/moodle.service.ts index 5f72b47..71aa410 100644 --- a/src/modules/moodle/moodle.service.ts +++ b/src/modules/moodle/moodle.service.ts @@ -154,4 +154,8 @@ export class MoodleService { const client = this.BuildMasterClient(); return await client.getCategories(); } + + async GetCoursesByFieldWithMasterKey(field: string, value: string) { + return this.BuildMasterClient().getCoursesByField(field, value); + } } diff --git a/src/modules/moodle/services/moodle-provisioning.service.spec.ts b/src/modules/moodle/services/moodle-provisioning.service.spec.ts index e09e0ec..10ec8a9 100644 --- a/src/modules/moodle/services/moodle-provisioning.service.spec.ts +++ b/src/modules/moodle/services/moodle-provisioning.service.spec.ts @@ -6,6 +6,7 @@ import { MoodleService } from '../moodle.service'; import { MoodleCourseTransformService } from './moodle-course-transform.service'; import { MoodleCsvParserService } from './moodle-csv-parser.service'; import { MoodleCategorySyncService } from './moodle-category-sync.service'; +import { MoodleCategoryResponse } from '../lib/moodle.types'; describe('MoodleProvisioningService', () => { let service: MoodleProvisioningService; @@ -24,6 +25,7 @@ describe('MoodleProvisioningService', () => { provide: MoodleService, useValue: { GetCategoriesWithMasterKey: jest.fn(), + GetCoursesByFieldWithMasterKey: jest.fn(), CreateCategories: jest.fn(), CreateCourses: jest.fn(), CreateUsers: jest.fn(), @@ -281,6 +283,276 @@ describe('MoodleProvisioningService', () => { }); }); + describe('GetCategoryTree', () => { + it('should build a nested tree from flat categories', async () => { + const flat: Partial[] = [ + { + id: 1, + name: 'UCMN', + parent: 0, + depth: 1, + coursecount: 0, + visible: 1, + sortorder: 10000, + }, + { + id: 2, + name: 'DLSAU', + parent: 0, + depth: 1, + coursecount: 0, + visible: 1, + sortorder: 20000, + }, + { + id: 3, + name: '1st Sem 25-26', + parent: 1, + depth: 2, + coursecount: 0, + visible: 1, + sortorder: 10001, + }, + { + id: 4, + name: '2nd Sem 25-26', + parent: 1, + depth: 2, + coursecount: 0, + visible: 1, + sortorder: 10002, + }, + { + id: 5, + name: 'CCS', + parent: 3, + depth: 3, + coursecount: 0, + visible: 1, + sortorder: 10003, + }, + { + id: 6, + name: 'BSCS', + parent: 5, + depth: 4, + coursecount: 8, + visible: 1, + sortorder: 10004, + }, + { + id: 7, + name: 'BSIT', + parent: 5, + depth: 4, + coursecount: 5, + visible: 0, + sortorder: 10005, + }, + ]; + + moodleService.GetCategoriesWithMasterKey.mockResolvedValue(flat); + + const result = await service.GetCategoryTree(); + + // Root level: 2 campus nodes + expect(result.tree).toHaveLength(2); + expect(result.tree[0].name).toBe('UCMN'); + expect(result.tree[1].name).toBe('DLSAU'); + + // UCMN has 2 semester children + const ucmn = result.tree[0]; + expect(ucmn.children).toHaveLength(2); + expect(ucmn.children[0].name).toBe('1st Sem 25-26'); + expect(ucmn.children[1].name).toBe('2nd Sem 25-26'); + + // Semester -> Department -> Program nesting + const firstSem = ucmn.children[0]; + expect(firstSem.children).toHaveLength(1); + expect(firstSem.children[0].name).toBe('CCS'); + + const ccs = firstSem.children[0]; + expect(ccs.children).toHaveLength(2); + expect(ccs.children[0].name).toBe('BSCS'); + expect(ccs.children[1].name).toBe('BSIT'); + + // Metadata + expect(result.totalCategories).toBe(7); + expect(new Date(result.fetchedAt).toISOString()).toBe(result.fetchedAt); + }); + + it('should sort children by sortorder ascending, not alphabetical', async () => { + const flat: Partial[] = [ + { + id: 1, + name: 'Root', + parent: 0, + depth: 1, + coursecount: 0, + visible: 1, + sortorder: 10000, + }, + { + id: 2, + name: 'Zebra', + parent: 1, + depth: 2, + coursecount: 0, + visible: 1, + sortorder: 100, + }, + { + id: 3, + name: 'Alpha', + parent: 1, + depth: 2, + coursecount: 0, + visible: 1, + sortorder: 200, + }, + { + id: 4, + name: 'Middle', + parent: 1, + depth: 2, + coursecount: 0, + visible: 1, + sortorder: 150, + }, + ]; + + moodleService.GetCategoriesWithMasterKey.mockResolvedValue(flat); + + const result = await service.GetCategoryTree(); + const children = result.tree[0].children; + + expect(children[0].name).toBe('Zebra'); + expect(children[1].name).toBe('Middle'); + expect(children[2].name).toBe('Alpha'); + }); + + it('should only include DTO fields, not sortorder or other extras', async () => { + const flat: Partial[] = [ + { + id: 1, + name: 'Test', + parent: 0, + depth: 1, + coursecount: 3, + visible: 1, + sortorder: 100, + path: '/1', + description: 'desc', + descriptionformat: 1, + }, + ]; + + moodleService.GetCategoriesWithMasterKey.mockResolvedValue(flat); + + const result = await service.GetCategoryTree(); + const node = result.tree[0]; + + expect(Object.keys(node).sort()).toEqual([ + 'children', + 'coursecount', + 'depth', + 'id', + 'name', + 'visible', + ]); + }); + + it('should return empty tree for empty category list', async () => { + moodleService.GetCategoriesWithMasterKey.mockResolvedValue([]); + + const result = await service.GetCategoryTree(); + + expect(result.tree).toEqual([]); + expect(result.totalCategories).toBe(0); + expect(new Date(result.fetchedAt).toISOString()).toBe(result.fetchedAt); + }); + }); + + describe('GetCoursesByCategoryWithMasterKey', () => { + it('should map courses to preview DTOs', async () => { + const courses = [ + { + id: 101, + shortname: 'CS101-2526', + fullname: 'Intro to CS', + enrolledusercount: 30, + visible: 1, + startdate: 1700000000, + enddate: 1710000000, + category: 5, + displayname: 'x', + hidden: false, + timemodified: 0, + }, + { + id: 102, + shortname: 'CS102-2526', + fullname: 'Data Structures', + enrolledusercount: 25, + visible: 1, + startdate: 1700000000, + enddate: 1710000000, + category: 5, + displayname: 'y', + hidden: false, + timemodified: 0, + }, + { + id: 103, + shortname: 'CS103-2526', + fullname: 'Algorithms', + enrolledusercount: undefined, + visible: 0, + startdate: 1700000000, + enddate: 1710000000, + category: 5, + displayname: 'z', + hidden: false, + timemodified: 0, + }, + ]; + + moodleService.GetCoursesByFieldWithMasterKey.mockResolvedValue({ + courses, + }); + + const result = await service.GetCoursesByCategoryWithMasterKey(5); + + expect(result.categoryId).toBe(5); + expect(result.courses).toHaveLength(3); + + const first = result.courses[0]; + expect(first.id).toBe(101); + expect(first.shortname).toBe('CS101-2526'); + expect(first.fullname).toBe('Intro to CS'); + expect(first.enrolledusercount).toBe(30); + expect(first.visible).toBe(1); + expect(first.startdate).toBe(1700000000); + expect(first.enddate).toBe(1710000000); + + // enrolledusercount may be undefined + expect(result.courses[2].enrolledusercount).toBeUndefined(); + }); + + it('should echo back categoryId with no categoryName', async () => { + moodleService.GetCoursesByFieldWithMasterKey.mockResolvedValue({ + courses: [], + }); + + const result = await service.GetCoursesByCategoryWithMasterKey(42); + + expect(result.categoryId).toBe(42); + expect(result.courses).toEqual([]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + expect((result as any).categoryName).toBeUndefined(); + }); + }); + describe('Concurrency guard', () => { it('should throw ConflictException on concurrent operations', async () => { moodleService.CreateUsers.mockImplementation( diff --git a/src/modules/moodle/services/moodle-provisioning.service.ts b/src/modules/moodle/services/moodle-provisioning.service.ts index 079fafb..e814a77 100644 --- a/src/modules/moodle/services/moodle-provisioning.service.ts +++ b/src/modules/moodle/services/moodle-provisioning.service.ts @@ -10,6 +10,14 @@ import { MoodleCourseTransformService } from './moodle-course-transform.service' import { MoodleCsvParserService } from './moodle-csv-parser.service'; import { MoodleCategorySyncService } from './moodle-category-sync.service'; import { MoodleCategoryResponse } from '../lib/moodle.types'; +import { + MoodleCategoryTreeNodeDto, + MoodleCategoryTreeResponseDto, +} from '../dto/responses/moodle-tree.response.dto'; +import { + MoodleCoursePreviewDto, + MoodleCategoryCoursesResponseDto, +} from '../dto/responses/moodle-course-preview.response.dto'; import { env } from 'src/configurations/env'; import { Program } from 'src/entities/program.entity'; import { @@ -617,6 +625,80 @@ export class MoodleProvisioningService { } } + async GetCategoryTree(): Promise { + const flat = await this.moodleService.GetCategoriesWithMasterKey(); + + // Pass 1: create nodes + track sortorder + const nodeMap = new Map(); + const sortorderMap = new Map(); + for (const cat of flat) { + const node: MoodleCategoryTreeNodeDto = { + id: cat.id, + name: cat.name, + depth: cat.depth, + coursecount: cat.coursecount, + visible: cat.visible, + children: [], + }; + nodeMap.set(cat.id, node); + sortorderMap.set(cat.id, cat.sortorder); + } + + // Pass 2: attach children + const rootNodes: MoodleCategoryTreeNodeDto[] = []; + for (const cat of flat) { + const node = nodeMap.get(cat.id)!; + if (cat.parent === 0) { + rootNodes.push(node); + } else { + const parent = nodeMap.get(cat.parent); + if (parent) { + parent.children.push(node); + } + } + } + + // Pass 3: sort children by sortorder + const sortByOrder = ( + a: MoodleCategoryTreeNodeDto, + b: MoodleCategoryTreeNodeDto, + ) => (sortorderMap.get(a.id) ?? 0) - (sortorderMap.get(b.id) ?? 0); + + for (const node of nodeMap.values()) { + if (node.children.length > 1) { + node.children.sort(sortByOrder); + } + } + rootNodes.sort(sortByOrder); + + return { + tree: rootNodes, + fetchedAt: new Date().toISOString(), + totalCategories: flat.length, + }; + } + + async GetCoursesByCategoryWithMasterKey( + categoryId: number, + ): Promise { + const { courses } = await this.moodleService.GetCoursesByFieldWithMasterKey( + 'category', + categoryId.toString(), + ); + + const mapped: MoodleCoursePreviewDto[] = courses.map((c) => ({ + id: c.id, + shortname: c.shortname, + fullname: c.fullname, + enrolledusercount: c.enrolledusercount ?? undefined, + visible: c.visible, + startdate: c.startdate, + enddate: c.enddate, + })); + + return { categoryId, courses: mapped }; + } + private acquireGuard(opType: string) { if (this.activeOps.has(opType)) { throw new ConflictException( From 55c87c012cedfef66ee32a9569de6afac2c9160c Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:59:12 +0800 Subject: [PATCH 3/6] 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 --- 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 + 9 files changed, 858 insertions(+), 2 deletions(-) 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 diff --git a/src/modules/audit/audit-query.service.spec.ts b/src/modules/audit/audit-query.service.spec.ts new file mode 100644 index 0000000..aec83b0 --- /dev/null +++ b/src/modules/audit/audit-query.service.spec.ts @@ -0,0 +1,360 @@ +import { NotFoundException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AuditLog } from 'src/entities/audit-log.entity'; +import { AuditQueryService } from './audit-query.service'; +import { AuditAction } from './audit-action.enum'; + +describe('AuditQueryService', () => { + let service: AuditQueryService; + let em: { + findAndCount: jest.Mock; + findOneOrFail: jest.Mock; + }; + + const sampleLog = { + id: 'log-1', + action: AuditAction.AUTH_LOGIN_SUCCESS, + actorId: 'user-1', + actorUsername: 'admin', + resourceType: 'User', + resourceId: 'user-1', + metadata: { strategyUsed: 'LocalLoginStrategy' }, + browserName: 'Chrome', + os: 'Linux', + ipAddress: '127.0.0.1', + occurredAt: new Date('2026-03-29T12:00:00.000Z'), + } as AuditLog; + + beforeEach(async () => { + em = { + findAndCount: jest.fn().mockResolvedValue([[sampleLog], 1]), + findOneOrFail: jest.fn().mockResolvedValue(sampleLog), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [AuditQueryService, { provide: EntityManager, useValue: em }], + }).compile(); + + service = module.get(AuditQueryService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('ListAuditLogs', () => { + it('should return paginated results with correct meta', async () => { + const result = await service.ListAuditLogs({ page: 1, limit: 10 }); + + expect(result.data).toHaveLength(1); + expect(result.data[0].id).toBe('log-1'); + expect(result.data[0].action).toBe(AuditAction.AUTH_LOGIN_SUCCESS); + expect(result.meta).toEqual({ + totalItems: 1, + itemCount: 1, + itemsPerPage: 10, + totalPages: 1, + currentPage: 1, + }); + }); + + it('should pass softDelete: false filter', async () => { + await service.ListAuditLogs({}); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.any(Object), + expect.objectContaining({ + filters: { softDelete: false }, + }), + ); + }); + + it('should order by occurredAt DESC, id DESC', async () => { + await service.ListAuditLogs({}); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.any(Object), + expect.objectContaining({ + orderBy: { occurredAt: 'DESC', id: 'DESC' }, + }), + ); + }); + + it('should compute correct offset for pagination', async () => { + await service.ListAuditLogs({ page: 3, limit: 15 }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.any(Object), + expect.objectContaining({ + limit: 15, + offset: 30, + }), + ); + }); + + it('should apply exact match filter for action', async () => { + await service.ListAuditLogs({ + action: AuditAction.AUTH_LOGIN_SUCCESS, + }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + action: AuditAction.AUTH_LOGIN_SUCCESS, + }), + expect.any(Object), + ); + }); + + it('should apply exact match filter for actorId', async () => { + await service.ListAuditLogs({ actorId: 'user-1' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ actorId: 'user-1' }), + expect.any(Object), + ); + }); + + it('should apply ILIKE partial match for actorUsername', async () => { + await service.ListAuditLogs({ actorUsername: 'john' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + actorUsername: { $ilike: '%john%' }, + }), + expect.any(Object), + ); + }); + + it('should apply exact match filter for resourceType', async () => { + await service.ListAuditLogs({ resourceType: 'User' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ resourceType: 'User' }), + expect.any(Object), + ); + }); + + it('should apply exact match filter for resourceId', async () => { + await service.ListAuditLogs({ resourceId: 'res-1' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ resourceId: 'res-1' }), + expect.any(Object), + ); + }); + + it('should apply date range filter with from only', async () => { + await service.ListAuditLogs({ from: '2026-01-01T00:00:00.000Z' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + occurredAt: { $gte: new Date('2026-01-01T00:00:00.000Z') }, + }), + expect.any(Object), + ); + }); + + it('should apply date range filter with to only', async () => { + await service.ListAuditLogs({ to: '2026-12-31T23:59:59.999Z' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + occurredAt: { $lte: new Date('2026-12-31T23:59:59.999Z') }, + }), + expect.any(Object), + ); + }); + + it('should apply date range filter with both from and to', async () => { + await service.ListAuditLogs({ + from: '2026-01-01T00:00:00.000Z', + to: '2026-12-31T23:59:59.999Z', + }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + occurredAt: { + $gte: new Date('2026-01-01T00:00:00.000Z'), + $lte: new Date('2026-12-31T23:59:59.999Z'), + }, + }), + expect.any(Object), + ); + }); + + it('should apply general text search across multiple fields', async () => { + await service.ListAuditLogs({ search: 'login' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + $or: [ + { actorUsername: { $ilike: '%login%' } }, + { action: { $ilike: '%login%' } }, + { resourceType: { $ilike: '%login%' } }, + ], + }), + expect.any(Object), + ); + }); + + it('should escape LIKE special characters in actorUsername', async () => { + await service.ListAuditLogs({ actorUsername: '100%_done' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + actorUsername: { $ilike: '%100\\%\\_done%' }, + }), + expect.any(Object), + ); + }); + + it('should escape LIKE special characters in search', async () => { + await service.ListAuditLogs({ search: '50%' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + $or: [ + { actorUsername: { $ilike: '%50\\%%' } }, + { action: { $ilike: '%50\\%%' } }, + { resourceType: { $ilike: '%50\\%%' } }, + ], + }), + expect.any(Object), + ); + }); + + it('should return empty data and zero meta when no results', async () => { + em.findAndCount.mockResolvedValue([[], 0]); + + const result = await service.ListAuditLogs({}); + + expect(result).toEqual({ + data: [], + meta: { + totalItems: 0, + itemCount: 0, + itemsPerPage: 10, + totalPages: 0, + currentPage: 1, + }, + }); + }); + + it('should combine multiple filters', async () => { + await service.ListAuditLogs({ + action: AuditAction.AUTH_LOGIN_SUCCESS, + actorId: 'user-1', + resourceType: 'User', + }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + { + action: AuditAction.AUTH_LOGIN_SUCCESS, + actorId: 'user-1', + resourceType: 'User', + }, + expect.any(Object), + ); + }); + + it('should trim actorUsername before searching', async () => { + await service.ListAuditLogs({ actorUsername: ' john ' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + actorUsername: { $ilike: '%john%' }, + }), + expect.any(Object), + ); + }); + + it('should trim search before searching', async () => { + await service.ListAuditLogs({ search: ' login ' }); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.objectContaining({ + $or: [ + { actorUsername: { $ilike: '%login%' } }, + { action: { $ilike: '%login%' } }, + { resourceType: { $ilike: '%login%' } }, + ], + }), + expect.any(Object), + ); + }); + + it('should use default page 1 and limit 10 when not specified', async () => { + await service.ListAuditLogs({}); + + expect(em.findAndCount).toHaveBeenCalledWith( + AuditLog, + expect.any(Object), + expect.objectContaining({ + offset: 0, + limit: 10, + }), + ); + }); + }); + + describe('GetAuditLog', () => { + it('should return a mapped audit log detail', async () => { + const result = await service.GetAuditLog('log-1'); + + expect(result.id).toBe('log-1'); + expect(result.action).toBe(AuditAction.AUTH_LOGIN_SUCCESS); + expect(result.actorId).toBe('user-1'); + expect(result.actorUsername).toBe('admin'); + expect(result.metadata).toEqual({ + strategyUsed: 'LocalLoginStrategy', + }); + expect(result.occurredAt).toEqual(new Date('2026-03-29T12:00:00.000Z')); + }); + + it('should pass softDelete: false filter to findOneOrFail', async () => { + await service.GetAuditLog('log-1'); + + expect(em.findOneOrFail).toHaveBeenCalledWith( + AuditLog, + { id: 'log-1' }, + expect.objectContaining({ + filters: { softDelete: false }, + }), + ); + }); + + it('should throw NotFoundException when audit log does not exist', async () => { + em.findOneOrFail.mockImplementation( + ( + _entity: unknown, + _where: unknown, + opts: { failHandler: () => Error }, + ) => { + throw opts.failHandler(); + }, + ); + + await expect(service.GetAuditLog('non-existent')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/modules/audit/audit-query.service.ts b/src/modules/audit/audit-query.service.ts new file mode 100644 index 0000000..b6a9049 --- /dev/null +++ b/src/modules/audit/audit-query.service.ts @@ -0,0 +1,104 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; +import { AuditLog } from 'src/entities/audit-log.entity'; +import { ListAuditLogsQueryDto } from './dto/requests/list-audit-logs-query.dto'; +import { AuditLogItemResponseDto } from './dto/responses/audit-log-item.response.dto'; +import { AuditLogListResponseDto } from './dto/responses/audit-log-list.response.dto'; +import { AuditLogDetailResponseDto } from './dto/responses/audit-log-detail.response.dto'; + +@Injectable() +export class AuditQueryService { + constructor(private readonly em: EntityManager) {} + + async ListAuditLogs( + query: ListAuditLogsQueryDto, + ): Promise { + const page = query.page ?? 1; + const limit = query.limit ?? 10; + const offset = (page - 1) * limit; + + const [logs, totalItems] = await this.em.findAndCount( + AuditLog, + this.BuildFilter(query), + { + limit, + offset, + orderBy: { occurredAt: 'DESC', id: 'DESC' }, + filters: { softDelete: false }, + }, + ); + + return { + data: logs.map((log) => AuditLogItemResponseDto.Map(log)), + meta: { + totalItems, + itemCount: logs.length, + itemsPerPage: limit, + totalPages: Math.ceil(totalItems / limit), + currentPage: page, + }, + }; + } + + async GetAuditLog(id: string): Promise { + const log = await this.em.findOneOrFail( + AuditLog, + { id }, + { + filters: { softDelete: false }, + failHandler: () => new NotFoundException('Audit log not found'), + }, + ); + + return AuditLogDetailResponseDto.Map(log); + } + + private BuildFilter(query: ListAuditLogsQueryDto): FilterQuery { + const filter: FilterQuery = {}; + + if (query.action) { + filter.action = query.action; + } + + if (query.actorId) { + filter.actorId = query.actorId; + } + + if (query.actorUsername) { + filter.actorUsername = { + $ilike: `%${this.EscapeLikePattern(query.actorUsername.trim())}%`, + }; + } + + if (query.resourceType) { + filter.resourceType = query.resourceType; + } + + if (query.resourceId) { + filter.resourceId = query.resourceId; + } + + if (query.from || query.to) { + const occurredAtFilter: Record = {}; + if (query.from) occurredAtFilter.$gte = new Date(query.from); + if (query.to) occurredAtFilter.$lte = new Date(query.to); + filter.occurredAt = occurredAtFilter as never; + } + + if (query.search) { + const search = `%${this.EscapeLikePattern(query.search.trim())}%`; + filter.$or = [ + { actorUsername: { $ilike: search } }, + { action: { $ilike: search } }, + { resourceType: { $ilike: search } }, + ]; + } + + return filter; + } + + private EscapeLikePattern(value: string): string { + return value.replace(/[%_\\]/g, '\\$&'); + } +} diff --git a/src/modules/audit/audit.controller.spec.ts b/src/modules/audit/audit.controller.spec.ts new file mode 100644 index 0000000..f64c9fa --- /dev/null +++ b/src/modules/audit/audit.controller.spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AuthGuard } from '@nestjs/passport'; +import { RolesGuard } from 'src/security/guards/roles.guard'; +import { AuditController } from './audit.controller'; +import { AuditQueryService } from './audit-query.service'; +import { ListAuditLogsQueryDto } from './dto/requests/list-audit-logs-query.dto'; + +describe('AuditController', () => { + let controller: AuditController; + let auditQueryService: { + ListAuditLogs: jest.Mock; + GetAuditLog: jest.Mock; + }; + + beforeEach(async () => { + auditQueryService = { + ListAuditLogs: jest.fn().mockResolvedValue({ + data: [], + meta: { + totalItems: 0, + itemCount: 0, + itemsPerPage: 10, + totalPages: 0, + currentPage: 1, + }, + }), + GetAuditLog: jest.fn().mockResolvedValue({ + id: 'log-1', + action: 'auth.login.success', + occurredAt: new Date(), + }), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuditController], + providers: [{ provide: AuditQueryService, useValue: auditQueryService }], + }) + .overrideGuard(AuthGuard('jwt')) + .useValue({ canActivate: () => true }) + .overrideGuard(RolesGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(AuditController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should delegate audit log listing to the query service', async () => { + const query: ListAuditLogsQueryDto = { + action: 'auth.login.success', + page: 2, + limit: 15, + }; + + await controller.ListAuditLogs(query); + + expect(auditQueryService.ListAuditLogs).toHaveBeenCalledWith(query); + }); + + it('should delegate single audit log retrieval to the query service', async () => { + await controller.GetAuditLog('log-1'); + + expect(auditQueryService.GetAuditLog).toHaveBeenCalledWith('log-1'); + }); +}); diff --git a/src/modules/audit/audit.controller.ts b/src/modules/audit/audit.controller.ts new file mode 100644 index 0000000..889c7b3 --- /dev/null +++ b/src/modules/audit/audit.controller.ts @@ -0,0 +1,117 @@ +import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { UseJwtGuard } from 'src/security/decorators'; +import { UserRole } from 'src/modules/auth/roles.enum'; +import { AuditQueryService } from './audit-query.service'; +import { ListAuditLogsQueryDto } from './dto/requests/list-audit-logs-query.dto'; +import { AuditLogListResponseDto } from './dto/responses/audit-log-list.response.dto'; +import { AuditLogDetailResponseDto } from './dto/responses/audit-log-detail.response.dto'; + +@ApiTags('Audit') +@Controller('audit-logs') +@UseJwtGuard(UserRole.SUPER_ADMIN) +@ApiBearerAuth() +export class AuditController { + constructor(private readonly auditQueryService: AuditQueryService) {} + + @Get() + @ApiOperation({ summary: 'List audit logs with filters and pagination' }) + @ApiQuery({ + name: 'action', + required: false, + type: String, + example: 'auth.login.success', + description: 'Filter by exact audit action code', + }) + @ApiQuery({ + name: 'actorId', + required: false, + type: String, + example: '3f6dd1dd-8f33-4b2e-bb0b-6ac2d8bbf5d7', + description: 'Filter by actor UUID', + }) + @ApiQuery({ + name: 'actorUsername', + required: false, + type: String, + example: 'admin', + description: 'Filter by actor username (partial match)', + }) + @ApiQuery({ + name: 'resourceType', + required: false, + type: String, + example: 'User', + description: 'Filter by resource type', + }) + @ApiQuery({ + name: 'resourceId', + required: false, + type: String, + example: '9ad12fa1-6286-4461-93f8-33b48d2e5725', + description: 'Filter by resource UUID', + }) + @ApiQuery({ + name: 'from', + required: false, + type: String, + example: '2026-01-01T00:00:00.000Z', + description: 'Lower bound (inclusive) on occurredAt (ISO 8601)', + }) + @ApiQuery({ + name: 'to', + required: false, + type: String, + example: '2026-12-31T23:59:59.999Z', + description: 'Upper bound (inclusive) on occurredAt (ISO 8601)', + }) + @ApiQuery({ + name: 'search', + required: false, + type: String, + example: 'login', + description: + 'General text search across actorUsername, action, and resourceType', + }) + @ApiQuery({ + name: 'page', + required: false, + type: Number, + example: 1, + description: 'Page number starting at 1', + }) + @ApiQuery({ + name: 'limit', + required: false, + type: Number, + example: 10, + description: 'Items per page, max 100', + }) + @ApiResponse({ status: 200, type: AuditLogListResponseDto }) + @ApiResponse({ status: 401, description: 'Unauthorized' }) + @ApiResponse({ status: 403, description: 'Forbidden — superadmin only' }) + async ListAuditLogs( + @Query() query: ListAuditLogsQueryDto, + ): Promise { + return this.auditQueryService.ListAuditLogs(query); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a single audit log entry by ID' }) + @ApiParam({ name: 'id', type: String, description: 'Audit log UUID' }) + @ApiResponse({ status: 200, type: AuditLogDetailResponseDto }) + @ApiResponse({ status: 400, description: 'Invalid UUID format' }) + @ApiResponse({ status: 404, description: 'Audit log not found' }) + async GetAuditLog( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.auditQueryService.GetAuditLog(id); + } +} diff --git a/src/modules/audit/audit.module.ts b/src/modules/audit/audit.module.ts index f9b51f2..9050ebe 100644 --- a/src/modules/audit/audit.module.ts +++ b/src/modules/audit/audit.module.ts @@ -3,19 +3,28 @@ import { BullModule } from '@nestjs/bullmq'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { QueueName } from 'src/configurations/common/queue-names'; import { AuditLog } from 'src/entities/audit-log.entity'; +import { User } from 'src/entities/user.entity'; import { AppClsModule } from '../common/cls/cls.module'; import { AuditService } from './audit.service'; import { AuditProcessor } from './audit.processor'; +import { AuditQueryService } from './audit-query.service'; import { AuditInterceptor } from './interceptors/audit.interceptor'; +import { AuditController } from './audit.controller'; @Global() @Module({ imports: [ BullModule.registerQueue({ name: QueueName.AUDIT }), - MikroOrmModule.forFeature([AuditLog]), + MikroOrmModule.forFeature([AuditLog, User]), AppClsModule, ], - providers: [AuditService, AuditProcessor, AuditInterceptor], + controllers: [AuditController], + providers: [ + AuditService, + AuditProcessor, + AuditInterceptor, + AuditQueryService, + ], exports: [AuditService, AuditInterceptor], }) export class AuditModule {} diff --git a/src/modules/audit/dto/requests/list-audit-logs-query.dto.ts b/src/modules/audit/dto/requests/list-audit-logs-query.dto.ts new file mode 100644 index 0000000..990a68c --- /dev/null +++ b/src/modules/audit/dto/requests/list-audit-logs-query.dto.ts @@ -0,0 +1,81 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; +import { PaginationQueryDto } from 'src/modules/common/dto/pagination-query.dto'; + +export class ListAuditLogsQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + description: 'Filter by exact audit action code', + example: 'auth.login.success', + }) + @IsString() + @IsOptional() + @MaxLength(100) + action?: string; + + @ApiPropertyOptional({ + description: 'Filter by actor UUID', + example: '3f6dd1dd-8f33-4b2e-bb0b-6ac2d8bbf5d7', + }) + @IsUUID() + @IsOptional() + actorId?: string; + + @ApiPropertyOptional({ + description: 'Filter by actor username (partial match)', + example: 'admin', + }) + @IsString() + @IsOptional() + @MaxLength(100) + actorUsername?: string; + + @ApiPropertyOptional({ + description: 'Filter by resource type', + example: 'User', + }) + @IsString() + @IsOptional() + @MaxLength(100) + resourceType?: string; + + @ApiPropertyOptional({ + description: 'Filter by resource UUID', + example: '9ad12fa1-6286-4461-93f8-33b48d2e5725', + }) + @IsString() + @IsOptional() + @MaxLength(100) + resourceId?: string; + + @ApiPropertyOptional({ + description: 'Lower bound (inclusive) on occurredAt (ISO 8601)', + example: '2026-01-01T00:00:00.000Z', + }) + @IsDateString() + @IsOptional() + from?: string; + + @ApiPropertyOptional({ + description: 'Upper bound (inclusive) on occurredAt (ISO 8601)', + example: '2026-12-31T23:59:59.999Z', + }) + @IsDateString() + @IsOptional() + to?: string; + + @ApiPropertyOptional({ + description: + 'General text search across actorUsername, action, and resourceType', + example: 'login', + }) + @IsString() + @IsOptional() + @MaxLength(200) + search?: string; +} diff --git a/src/modules/audit/dto/responses/audit-log-detail.response.dto.ts b/src/modules/audit/dto/responses/audit-log-detail.response.dto.ts new file mode 100644 index 0000000..ae3f29f --- /dev/null +++ b/src/modules/audit/dto/responses/audit-log-detail.response.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AuditLog } from 'src/entities/audit-log.entity'; + +export class AuditLogDetailResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ example: 'auth.login.success' }) + action: string; + + @ApiPropertyOptional() + actorId?: string; + + @ApiPropertyOptional() + actorUsername?: string; + + @ApiPropertyOptional() + resourceType?: string; + + @ApiPropertyOptional() + resourceId?: string; + + @ApiPropertyOptional() + metadata?: Record; + + @ApiPropertyOptional() + browserName?: string; + + @ApiPropertyOptional() + os?: string; + + @ApiPropertyOptional() + ipAddress?: string; + + @ApiProperty() + occurredAt: Date; + + static Map(entity: AuditLog): AuditLogDetailResponseDto { + return { + id: entity.id, + action: entity.action, + actorId: entity.actorId, + actorUsername: entity.actorUsername, + resourceType: entity.resourceType, + resourceId: entity.resourceId, + metadata: entity.metadata, + browserName: entity.browserName, + os: entity.os, + ipAddress: entity.ipAddress, + occurredAt: entity.occurredAt, + }; + } +} diff --git a/src/modules/audit/dto/responses/audit-log-item.response.dto.ts b/src/modules/audit/dto/responses/audit-log-item.response.dto.ts new file mode 100644 index 0000000..14c199e --- /dev/null +++ b/src/modules/audit/dto/responses/audit-log-item.response.dto.ts @@ -0,0 +1,53 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AuditLog } from 'src/entities/audit-log.entity'; + +export class AuditLogItemResponseDto { + @ApiProperty() + id: string; + + @ApiProperty({ example: 'auth.login.success' }) + action: string; + + @ApiPropertyOptional() + actorId?: string; + + @ApiPropertyOptional() + actorUsername?: string; + + @ApiPropertyOptional() + resourceType?: string; + + @ApiPropertyOptional() + resourceId?: string; + + @ApiPropertyOptional() + metadata?: Record; + + @ApiPropertyOptional() + browserName?: string; + + @ApiPropertyOptional() + os?: string; + + @ApiPropertyOptional() + ipAddress?: string; + + @ApiProperty() + occurredAt: Date; + + static Map(entity: AuditLog): AuditLogItemResponseDto { + return { + id: entity.id, + action: entity.action, + actorId: entity.actorId, + actorUsername: entity.actorUsername, + resourceType: entity.resourceType, + resourceId: entity.resourceId, + metadata: entity.metadata, + browserName: entity.browserName, + os: entity.os, + ipAddress: entity.ipAddress, + occurredAt: entity.occurredAt, + }; + } +} diff --git a/src/modules/audit/dto/responses/audit-log-list.response.dto.ts b/src/modules/audit/dto/responses/audit-log-list.response.dto.ts new file mode 100644 index 0000000..d5e31eb --- /dev/null +++ b/src/modules/audit/dto/responses/audit-log-list.response.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginationMeta } from 'src/modules/common/dto/pagination.dto'; +import { AuditLogItemResponseDto } from './audit-log-item.response.dto'; + +export class AuditLogListResponseDto { + @ApiProperty({ type: [AuditLogItemResponseDto] }) + data: AuditLogItemResponseDto[]; + + @ApiProperty({ type: PaginationMeta }) + meta: PaginationMeta; +} From 404d2bf5beb1774ea42d0bbf85c8b5b197d7c452 Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:00:09 +0800 Subject: [PATCH 4/6] 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. --- ...-spec-fix-moodle-semester-category-bugs.md | 566 ++++++++++++++++++ .../moodle-provisioning.controller.spec.ts | 117 +++- .../moodle-provisioning.controller.ts | 44 +- src/modules/moodle/lib/moodle.client.spec.ts | 48 ++ src/modules/moodle/lib/moodle.client.ts | 6 +- .../moodle-course-transform.service.spec.ts | 38 ++ .../moodle-course-transform.service.ts | 40 ++ .../moodle-provisioning.service.spec.ts | 231 +++++++ .../services/moodle-provisioning.service.ts | 92 ++- 9 files changed, 1176 insertions(+), 6 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/tech-spec-fix-moodle-semester-category-bugs.md create mode 100644 src/modules/moodle/lib/moodle.client.spec.ts diff --git a/_bmad-output/implementation-artifacts/tech-spec-fix-moodle-semester-category-bugs.md b/_bmad-output/implementation-artifacts/tech-spec-fix-moodle-semester-category-bugs.md new file mode 100644 index 0000000..c2e942f --- /dev/null +++ b/_bmad-output/implementation-artifacts/tech-spec-fix-moodle-semester-category-bugs.md @@ -0,0 +1,566 @@ +--- +title: 'Fix Moodle Semester Category Name Generation & Add Category Preview' +slug: 'fix-moodle-semester-category-bugs' +created: '2026-04-11' +status: 'completed' +stepsCompleted: [1, 2, 3, 4] +tech_stack: + [ + 'NestJS 11', + 'MikroORM', + 'PostgreSQL', + 'React 19', + 'Vite', + 'TanStack Query', + 'shadcn/ui', + 'Tailwind 4', + ] +files_to_modify: + - 'src/modules/moodle/services/moodle-course-transform.service.ts' + - 'src/modules/moodle/services/moodle-course-transform.service.spec.ts' + - 'src/modules/moodle/services/moodle-provisioning.service.ts' + - 'src/modules/moodle/services/moodle-provisioning.service.spec.ts' + - 'src/modules/moodle/lib/moodle.client.ts' + - 'src/modules/moodle/lib/moodle.client.spec.ts' + - 'src/modules/moodle/controllers/moodle-provisioning.controller.ts' + - '../admin.faculytics/src/types/api.ts' + - '../admin.faculytics/src/features/moodle-provision/use-provision-categories.ts' + - '../admin.faculytics/src/features/moodle-provision/components/categories-tab.tsx' + - '../admin.faculytics/src/features/moodle-provision/components/provision-result-dialog.tsx' +code_patterns: + - 'Preview-then-execute: PreviewCourses()/ExecuteCourseSeeding() pattern' + - 'Guard pattern: acquireGuard()/releaseGuard() for concurrent op protection' + - 'Batch pattern: MOODLE_PROVISION_BATCH_SIZE=50 for Moodle API calls' + - 'Hierarchy walk: campus->semester->department->program with find-or-create per level' + - 'Error details: each level catches errors independently, pushes to details[]' + - 'NestJS TestingModule with real transform service + mocked MoodleService/EntityManager' +test_patterns: + - 'Direct instantiation for stateless services (MoodleCourseTransformService)' + - 'NestJS TestingModule with jest.Mocked<> for services with dependencies' + - 'Mock MoodleService methods: GetCategoriesWithMasterKey, CreateCategories, etc.' + - 'Test provisioning outcomes via result.details array assertions' + - 'Plain class + mocked global fetch for MoodleClient (no NestJS DI — it is not an injectable provider)' +--- + +# Tech-Spec: Fix Moodle Semester Category Name Generation & Add Category Preview + +**Created:** 2026-04-11 + +## Overview + +### Problem Statement + +Two bugs in Moodle category provisioning, plus a missing safety feature: + +1. **Wrong semester tag generation:** The provisioning service at `moodle-provisioning.service.ts:65-66` extracts year values directly from `startDate`/`endDate` via `.slice(2, 4)` without accounting for the academic year. For semester 2 with `startDate=2026-01-20` and `endDate=2026-06-01`, both years resolve to `26`, producing `S22626` instead of the correct `S22526` (school year 2025-2026). The bug also affects semester 1 when selected alone — dates like `2025-08-01` to `2025-12-18` both yield `25`, producing `S12525` instead of `S12526`. `BuildSemesterTag` itself is correct — it's a dumb formatter. The bug is in the year inputs fed to it. + +2. **Access control exception (RESOLVED):** The wrong semester tag caused the service to attempt _creating_ `S22626` instead of _skipping_ the existing `S22526`. The creation failed because the Moodle master key token was on the built-in "Moodle mobile web service" which doesn't include `core_course_create_categories`. **Resolution:** Migrated to a dedicated `faculytics_service` external service with all 13 required wsfunctions. New token deployed to local `.env`, VPS `.env.staging`, and VPS `.env.production`. + +3. **No category preview:** Unlike courses (which have `PreviewCourses` / `PreviewQuickCourse`), category provisioning goes straight to execution with no preview step. Users can't see what will be created/skipped before committing. + +### Solution + +1. Fix the year derivation in the provisioning service to compute school-year-aware `startYY`/`endYY` per-semester inside the hierarchy walk loop. +2. Add a friendlier error message when Moodle returns `webservice_access_exception`. +3. Add a category preview endpoint (`POST /moodle/provision/categories/preview`) and corresponding UI in the admin console's categories tab, following the existing preview-then-execute pattern from courses. + +### Scope + +**In Scope:** + +- Backend: Fix semester year calculation in `moodle-provisioning.service.ts` +- Backend: Improve `webservice_access_exception` error message in `moodle.client.ts` +- Backend: Add `POST /moodle/provision/categories/preview` endpoint +- Frontend (`admin.faculytics`): Add preview-then-confirm flow to categories tab +- Unit tests for semester tag fix, error handling, and preview endpoint + +**Out of Scope:** + +- Changes to `ProvisionCategoriesRequest` DTO contract +- Moodle permission/token configuration (already resolved) +- Other provisioning features (courses, users) +- Same year-extraction bug in `buildSeedContext()` and `PreviewQuickCourse`/`ExecuteQuickCourse` for course seeding (tracked separately) + +## Context for Development + +### Codebase Patterns + +**Year Extraction (the bug):** + +- `moodle-provisioning.service.ts:65-66` does `input.startDate.slice(2, 4)` / `input.endDate.slice(2, 4)` once before the hierarchy walk +- These `startYY`/`endYY` values are passed to `BuildSemesterTag()` at lines 112-116, and reused for departments (165-168) and programs (218-221) +- When both semesters are selected, the frontend sends combined dates spanning the school year boundary (e.g., `2025-08-01` to `2026-06-01`), so the slice works. When a single semester is selected, both dates fall in the same year, and the slice produces wrong values. + +**Hierarchy Walk:** + +- `ProvisionCategories()` walks 4 depth levels: campus -> semester -> department -> program +- Each level does find-or-create: checks `existingByParentAndName` map, skips if found, batches missing ones for Moodle `CreateCategories` API call +- Parent IDs cascade: `campusIds` -> `semesterIds` -> `deptIds` -> program creation +- If a parent level fails, children are silently skipped (no `campusId` -> `continue`) + +**Preview-then-Execute Pattern (reference):** + +- `PreviewCourses()` (line 290): parses CSV, computes shortnames/paths, checks program entity exists, returns `CoursePreviewResult` with valid/skipped/errors +- `PreviewQuickCourse()` (line 411): synchronous, returns single `CoursePreviewRow` +- Frontend `courses-bulk-tab.tsx`: upload -> preview dialog -> user selects rows -> execute +- Preview does NOT acquire concurrency guard (read-only operation) + +**Error Handling:** + +- `MoodleClient.call()` at line 138-142: checks for `moodleError.exception`, throws generic `Error` with `Moodle API error ({exception}): {message}` +- Provisioning catch blocks at each depth level: `err instanceof Error ? err.message : String(err)` -> stored in `details[].reason` + +**Testing:** + +- `moodle-provisioning.service.spec.ts`: NestJS TestingModule, real `MoodleCourseTransformService`, mocked everything else +- `moodle-course-transform.service.spec.ts`: direct instantiation, no DI needed +- Tests use `jest.fn()` for mocked methods, `mockResolvedValue`/`mockRejectedValue` for async + +### Files to Reference + +| File | Purpose | Key Lines | +| --------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------- | +| `src/modules/moodle/services/moodle-provisioning.service.ts` | Category provisioning + year extraction bug | 51-288 (ProvisionCategories), 65-66 (year bug) | +| `src/modules/moodle/services/moodle-provisioning.service.spec.ts` | Provisioning tests — pattern reference | 58-118 (ProvisionCategories tests) | +| `src/modules/moodle/services/moodle-course-transform.service.ts` | `BuildSemesterTag()`, `GetSemesterDates()` | 36-58 | +| `src/modules/moodle/services/moodle-course-transform.service.spec.ts` | Transform service tests | Full file | +| `src/modules/moodle/controllers/moodle-provisioning.controller.ts` | Controller endpoints, `buildSeedContext()` bug | 66-79 (buildSeedContext), 90-108 (ProvisionCategories endpoint) | +| `src/modules/moodle/lib/moodle.client.ts` | Moodle API error handling | 138-142 (exception check) | +| `src/modules/moodle/lib/provisioning.types.ts` | `ProvisionResult`, `ProvisionDetailItem`, `ProvisionCategoriesInput` | Full file | +| `src/modules/moodle/dto/responses/provision-result.response.dto.ts` | `ProvisionResultDto` — reusable for preview response | 17+ | +| `admin.faculytics/src/features/moodle-provision/components/categories-tab.tsx` | Category provisioning UI — needs preview step | 105-116 (handleSubmit) | +| `admin.faculytics/src/features/moodle-provision/use-provision-categories.ts` | Mutation hook — needs preview mutation | Full file | +| `admin.faculytics/src/features/moodle-provision/components/provision-result-dialog.tsx` | Result dialog — extend with preview mode | Full file | +| `admin.faculytics/src/types/api.ts` | TypeScript interfaces | 320-459 | +| `admin.faculytics/src/lib/constants.ts` | `getSemesterDates()` with correct academic year logic | 19-37 | + +### Technical Decisions + +- **Per-semester year computation via `ComputeSchoolYears` on transform service:** New method `ComputeSchoolYears(semester, startDate, endDate)` on `MoodleCourseTransformService` computes school-year-aware `startYY`/`endYY`. Placed on the transform service (not provisioning) because it's a stateless utility, matching the service's role, and is reusable when the course seeding year bug is fixed later. +- **`BuildSemesterTag` unchanged:** It remains a dumb formatter. The fix is in the caller. +- **API contract unchanged:** `ProvisionCategoriesRequest` DTO stays the same. Preview uses the same request shape and returns `ProvisionResultDto`. +- **Preview as a separate method:** `PreviewCategories()` does a read-only hierarchy walk against existing Moodle categories. No `CreateCategories` calls, no concurrency guard, no auto-sync. When a parent doesn't exist (will be created), all children are marked as "will create" too. +- **Reuse `ProvisionResult`/`ProvisionResultDto` for preview:** In preview context, `status: 'created'` means "will create" and `status: 'skipped'` means "exists, will skip". No new types needed. Frontend differentiates based on which endpoint was called. +- **Extend `ProvisionResultDialog` with preview mode:** Add `mode` prop ('preview' | 'result') and optional `onConfirm` callback. Preview mode shows "Confirm & Provision" + "Cancel" buttons. Result mode shows "Close" (existing behavior). +- **Error improvement in `MoodleClient.call()`:** Check `moodleError.exception` for `webservice_access_exception` specifically. Append hint: "Ensure the wsfunction is added to your Moodle external service." Other exceptions unchanged. + +## Implementation Plan + +### Tasks + +- [x] **Task 1: Add `ComputeSchoolYears` method to transform service** + - File: `src/modules/moodle/services/moodle-course-transform.service.ts` + - Action: Add new method after `BuildSemesterTag` (line 58): + + ```typescript + ComputeSchoolYears( + semester: number, + startDate: string, + endDate: string, + ): { startYY: string; endYY: string } { + const startYear = parseInt(startDate.slice(0, 4)); + const endYear = parseInt(endDate.slice(0, 4)); + + // If dates span different years, the school year boundary is explicit + if (startYear !== endYear) { + return { + startYY: String(startYear).slice(-2), + endYY: String(endYear).slice(-2), + }; + } + + // Same year — derive school year from semester number + if (semester === 1) { + // Semester 1 starts in Aug — year is school start year + return { + startYY: String(startYear).slice(-2), + endYY: String(startYear + 1).slice(-2), + }; + } + if (semester === 2) { + // Semester 2 starts in Jan — year is school end year + return { + startYY: String(startYear - 1).slice(-2), + endYY: String(startYear).slice(-2), + }; + } + throw new Error(`Invalid semester: ${semester}. Must be 1 or 2.`); + } + ``` + +- [x] **Task 2: Add unit tests for `ComputeSchoolYears`** + - File: `src/modules/moodle/services/moodle-course-transform.service.spec.ts` + - Action: Add new `describe('ComputeSchoolYears')` block with test cases: + - Sem 2 only (the reported bug): `(2, '2026-01-20', '2026-06-01')` -> `{ startYY: '25', endYY: '26' }` + - Sem 1 only (same bug): `(1, '2025-08-01', '2025-12-18')` -> `{ startYY: '25', endYY: '26' }` + - Both semesters (already works): `(1, '2025-08-01', '2026-06-01')` -> `{ startYY: '25', endYY: '26' }` + - Both semesters sem 2: `(2, '2025-08-01', '2026-06-01')` -> `{ startYY: '25', endYY: '26' }` + - Next year sem 1: `(1, '2026-08-01', '2026-12-18')` -> `{ startYY: '26', endYY: '27' }` + - Next year sem 2: `(2, '2027-01-20', '2027-06-01')` -> `{ startYY: '26', endYY: '27' }` + +- [x] **Task 3: Fix year derivation in `ProvisionCategories`** + - File: `src/modules/moodle/services/moodle-provisioning.service.ts` + - Action: + 1. **Remove** lines 65-66 (`const startYY = ...` / `const endYY = ...`) + 2. **Semester loop (line 111)** — compute per-semester years at the top of each iteration: + ```typescript + for (const sem of input.semesters) { + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + sem, input.startDate, input.endDate, + ); + const tag = this.transformService.BuildSemesterTag(String(sem), startYY, endYY); + // ... rest of semester handling + ``` + 3. **Department loop (line 164)** — same `ComputeSchoolYears` call to reconstruct the semester tag for map lookups: + ```typescript + for (const sem of input.semesters) { + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + sem, input.startDate, input.endDate, + ); + const tag = this.transformService.BuildSemesterTag(String(sem), startYY, endYY); + const semId = semesterIds.get(`${campus.toUpperCase()}:${tag}`); + if (!semId) continue; + // ... rest of department handling + ``` + 4. **Program loop (line 217)** — same pattern: + ```typescript + for (const sem of input.semesters) { + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + sem, input.startDate, input.endDate, + ); + const tag = this.transformService.BuildSemesterTag(String(sem), startYY, endYY); + // ... rest of program handling + ``` + - Notes: All three loops iterate over `input.semesters` and call `BuildSemesterTag` to reconstruct the semester tag for composite key lookups. Each must compute `{ startYY, endYY }` via `ComputeSchoolYears` at the top of its semester iteration. The campus loop (depth 1) does not use semester tags and needs no change. + +- [x] **Task 4: Update `ProvisionCategories` unit tests for year fix** + - File: `src/modules/moodle/services/moodle-provisioning.service.spec.ts` + - Action: Add test cases to the existing `describe('ProvisionCategories')` block: + - **Sem 2 only with same-year dates** — the reported bug scenario: `semesters: [2], startDate: '2026-01-20', endDate: '2026-06-01'`. Verify `BuildSemesterTag` is called with `startYY='25', endYY='26'` (resulting tag `S22526`, not `S22626`). Mock existing categories to include the campus, assert the semester tag in details. + - **Sem 1 only with same-year dates** — `semesters: [1], startDate: '2025-08-01', endDate: '2025-12-18'`. Verify tag is `S12526` not `S12525`. + +- [x] **Task 5: Improve `webservice_access_exception` error message** + - File: `src/modules/moodle/lib/moodle.client.ts` + - Action: In the `call()` method at line 138-142, replace the generic error throw with a check for `webservice_access_exception`: + ```typescript + const moodleError = data as { exception?: string; message?: string }; + if (moodleError.exception) { + const hint = + moodleError.exception === 'webservice_access_exception' + ? ' Ensure the wsfunction is added to your Moodle external service (Site admin > Server > External services).' + : ''; + throw new Error( + `Moodle API error (${moodleError.exception}): ${moodleError.message || 'Unknown error'}${hint}`, + ); + } + ``` + +- [x] **Task 5b: Add unit test for `webservice_access_exception` hint** + - File: `src/modules/moodle/lib/moodle.client.spec.ts` (new file) + - Action: Create test file with a focused test: + 1. Construct a `MoodleClient` instance with a test base URL and token + 2. Mock global `fetch` to return a JSON response with `{ exception: 'webservice_access_exception', message: 'Access control exception' }` + 3. Call `client.call('some_function')` and assert the thrown error message includes "Ensure the wsfunction is added to your Moodle external service" + 4. Add a second test: mock `fetch` with a different exception (e.g., `dml_write_exception`), assert the hint is NOT appended + +- [x] **Task 6: Add `PreviewCategories` method to provisioning service** + - File: `src/modules/moodle/services/moodle-provisioning.service.ts` + - Action: Add new method after `ProvisionCategories` (after line 288): + + ```typescript + async PreviewCategories(input: ProvisionCategoriesInput): Promise { + const start = Date.now(); + const details: ProvisionDetailItem[] = []; + + const existing = await this.moodleService.GetCategoriesWithMasterKey(); + const byParentAndName = new Map(); + for (const cat of existing) { + byParentAndName.set(`${cat.parent}:${cat.name}`, cat); + } + + for (const campus of input.campuses) { + const campusName = campus.toUpperCase(); + const campusCat = byParentAndName.get(`0:${campusName}`); + const campusId = campusCat?.id; + details.push({ name: campusName, status: campusCat ? 'skipped' : 'created' }); + + for (const sem of input.semesters) { + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + sem, input.startDate, input.endDate, + ); + const tag = this.transformService.BuildSemesterTag(String(sem), startYY, endYY); + const semCat = campusId ? byParentAndName.get(`${campusId}:${tag}`) : undefined; + const semId = semCat?.id; + details.push({ name: tag, status: semCat ? 'skipped' : 'created' }); + + for (const dept of input.departments) { + const deptName = dept.code.toUpperCase(); + const deptCat = semId ? byParentAndName.get(`${semId}:${deptName}`) : undefined; + const deptId = deptCat?.id; + details.push({ name: deptName, status: deptCat ? 'skipped' : 'created' }); + + for (const prog of dept.programs) { + const progName = prog.toUpperCase(); + const progCat = deptId ? byParentAndName.get(`${deptId}:${progName}`) : undefined; + details.push({ name: progName, status: progCat ? 'skipped' : 'created' }); + } + } + } + } + + const created = details.filter((d) => d.status === 'created').length; + const skipped = details.filter((d) => d.status === 'skipped').length; + return { created, skipped, errors: 0, details, durationMs: Date.now() - start }; + } + ``` + + - Notes: No concurrency guard (read-only). No auto-sync. No `CreateCategories` calls. When a parent doesn't exist (no ID), all children are automatically marked as `'created'` because they can't be looked up. + +- [x] **Task 7: Add `PreviewCategories` unit tests** + - File: `src/modules/moodle/services/moodle-provisioning.service.spec.ts` + - Action: Add new `describe('PreviewCategories')` block: + - **All exist** — mock existing categories with full hierarchy (UCMN -> S22526 -> CCS -> BSCSAI). Assert all items have `status: 'skipped'`, `errors: 0`. + - **Leaf missing** — mock existing campus/semester/department, program BSCSAI missing. Assert 3 skipped, 1 created. + - **Parent missing cascades** — mock only campus exists, semester missing. Assert campus skipped, semester/department/program all created. + - **Does not call CreateCategories** — verify `moodleService.CreateCategories` was never called. + - **No concurrency guard** — two concurrent `PreviewCategories` calls should both resolve (no `ConflictException`). Use a delayed mock to ensure true concurrency: + ```typescript + moodleService.GetCategoriesWithMasterKey.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 50)), + ); + const input = { + campuses: ['UCMN'], + semesters: [1], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [], + }; + const [a, b] = await Promise.all([ + service.PreviewCategories(input), + service.PreviewCategories(input), + ]); + expect(a.errors).toBe(0); + expect(b.errors).toBe(0); + ``` + +- [x] **Task 8: Add preview endpoint to controller** + - File: `src/modules/moodle/controllers/moodle-provisioning.controller.ts` + - Action: Add new endpoint near the existing `ProvisionCategories` endpoint: + ```typescript + @Post('categories/preview') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Preview Moodle category provisioning (dry run)' }) + @ApiResponse({ status: 200, type: ProvisionResultDto }) + async PreviewCategories( + @Body() dto: ProvisionCategoriesRequestDto, + ): Promise { + try { + return await this.provisioningService.PreviewCategories(dto); + } catch (e) { + if (e instanceof MoodleConnectivityError) { + throw new BadGatewayException('Moodle is unreachable'); + } + if (e instanceof Error && e.message.startsWith('Invalid semester')) { + throw new BadRequestException(e.message); + } + this.logger.error( + 'Failed to preview categories', + e instanceof Error ? e.stack : e, + ); + throw new ServiceUnavailableException( + 'Failed to preview Moodle categories', + ); + } + } + ``` + - Notes: Same DTO, same response type. Includes `@ApiBearerAuth()` for Swagger docs. Wraps `MoodleConnectivityError` the same way `GetCategoryTree` does (controller lines 233-247). Validation errors from `ComputeSchoolYears` (e.g., "Invalid semester: 3") are rethrown as `BadRequestException` (400), not swallowed into 503. No audit decorator (read-only). No MetaDataInterceptor/CurrentUserInterceptor/AuditInterceptor needed. + +- [x] **Task 8b: Add error wrapping to existing `ProvisionCategories` endpoint** + - File: `src/modules/moodle/controllers/moodle-provisioning.controller.ts` + - Action: Wrap the existing `ProvisionCategories` handler (lines 104-108) in the same try/catch pattern as the preview and `GetCategoryTree` endpoints: + ```typescript + async ProvisionCategories( + @Body() dto: ProvisionCategoriesRequestDto, + ): Promise { + try { + return await this.provisioningService.ProvisionCategories(dto); + } catch (e) { + if (e instanceof MoodleConnectivityError) { + throw new BadGatewayException('Moodle is unreachable'); + } + if (e instanceof Error && e.message.startsWith('Invalid semester')) { + throw new BadRequestException(e.message); + } + this.logger.error( + 'Failed to provision categories', + e instanceof Error ? e.stack : e, + ); + throw new ServiceUnavailableException( + 'Failed to provision Moodle categories', + ); + } + } + ``` + - Notes: Pre-existing gap — the execute endpoint had no `MoodleConnectivityError` handling while the preview does. Fixes the asymmetry so both endpoints handle connectivity and validation errors consistently. + +- [x] **Task 9: Add frontend preview hook** + - File: `admin.faculytics/src/features/moodle-provision/use-provision-categories.ts` + - Action: Add `usePreviewCategories` mutation alongside existing `useProvisionCategories`: + ```typescript + // No onSuccess toast — preview results are shown in the dialog, not via toast + export function usePreviewCategories() { + return useMutation({ + mutationFn: (data: ProvisionCategoriesRequest) => + apiClient( + '/moodle/provision/categories/preview', + { + method: 'POST', + body: JSON.stringify(data), + }, + ), + onError: () => { + toast.error('Failed to preview categories'); + }, + }); + } + ``` + +- [x] **Task 10: Extend `ProvisionResultDialog` with preview mode** + - File: `admin.faculytics/src/features/moodle-provision/components/provision-result-dialog.tsx` + - Action: Add `mode` prop and optional `onConfirm` callback: + - Props: `mode?: 'preview' | 'result'` (default `'result'`), `onConfirm?: () => void`, `isConfirming?: boolean` + - Title: `mode === 'preview' ? 'Category Preview' : 'Provisioning Result'` + - **Display-only label mapping** (does NOT mutate data, only affects rendered text): Add a `statusLabel` map alongside existing `statusVariant`: + ```typescript + const statusLabel: Record> = { + preview: { created: 'will create', skipped: 'exists', error: 'error' }, + result: { created: 'created', skipped: 'skipped', error: 'error' }, + }; + ``` + Use it in the Badge render: `{statusLabel[mode][d.status]}` instead of `{d.status}`. The `statusVariant` badge colors remain unchanged. + - Footer: if `onConfirm` provided, show `"Confirm & Provision"` button (with loading state via `isConfirming`) + `"Cancel"` button. Otherwise show existing `"Close"` button. + +- [x] **Task 11: Update `categories-tab.tsx` with preview-then-confirm flow** + - File: `admin.faculytics/src/features/moodle-provision/components/categories-tab.tsx` + - Action: + 1. Import and use `usePreviewCategories` alongside existing `useProvisionCategories` + 2. Add state for preview result AND captured request payload: + ```typescript + const [preview, setPreview] = useState( + null, + ); + const [previewPayload, setPreviewPayload] = + useState(null); + ``` + 3. Change submit handler to call preview first, capturing the payload: + ```typescript + const handlePreview = () => { + const payload: ProvisionCategoriesRequest = { + campuses: selectedCampuses, + semesters: selectedSemesters, + startDate, + endDate, + departments, + }; + previewMutation.mutate(payload, { + onSuccess: (data) => { + setPreviewPayload(payload); + setPreview(data); + }, + }); + }; + ``` + 4. Add confirm handler that reuses the **captured payload** (not current form state) to prevent TOCTOU race: + ```typescript + const handleConfirm = () => { + if (!previewPayload) return; + provisionMutation.mutate(previewPayload, { + onSuccess: (data) => { + setPreview(null); + setPreviewPayload(null); + setResult(data); + }, + onError: () => { + toast.error('Provisioning failed. You can retry or cancel.'); + }, + }); + }; + ``` + 5. Update the submit button: label `"Preview Categories"`, calls `handlePreview`, disabled when `!isValid || previewMutation.isPending` + 6. Add preview dialog: + ```tsx + { + setPreview(null); + setPreviewPayload(null); + }} + mode="preview" + onConfirm={handleConfirm} + isConfirming={provisionMutation.isPending} + /> + ``` + 7. Keep existing result dialog unchanged (shows after confirm completes). + - Notes: The `previewPayload` state captures the exact request used for preview. The confirm handler reuses this captured payload rather than rebuilding from current form state, preventing a TOCTOU race where the user modifies the form between preview and confirm. + +### Acceptance Criteria + +- [x] **AC 1:** Given a category provision request with `semesters: [2], startDate: '2026-01-20', endDate: '2026-06-01'`, when the provisioning service processes it, then the generated semester tag is `S22526` (not `S22626`). +- [x] **AC 2:** Given a category provision request with `semesters: [1], startDate: '2025-08-01', endDate: '2025-12-18'`, when the provisioning service processes it, then the generated semester tag is `S12526` (not `S12525`). +- [x] **AC 3:** Given a category provision request with `semesters: [1, 2], startDate: '2025-08-01', endDate: '2026-06-01'`, when processed, then tags are `S12526` and `S22526` (existing behavior preserved). +- [x] **AC 3b:** Given a category provision request with `semesters: [1], startDate: '2026-08-01', endDate: '2026-12-18'`, when processed, then the tag is `S12627` (boundary year coverage). +- [x] **AC 3c:** Given a category provision request with `semesters: [2], startDate: '2027-01-20', endDate: '2027-06-01'`, when processed, then the tag is `S22627` (boundary year coverage). +- [x] **AC 4:** Given a Moodle API response with `exception: 'webservice_access_exception'`, when `MoodleClient.call()` throws, then the error message includes a hint about adding the wsfunction to the Moodle external service. +- [x] **AC 5:** Given a valid category provision request, when `POST /moodle/provision/categories/preview` is called, then the response returns a `ProvisionResult` with each category's status (`skipped` for existing, `created` for missing) without creating anything in Moodle. +- [x] **AC 6:** Given a preview where a parent category doesn't exist (e.g., campus is missing), when the preview walks child levels, then all children are marked as `created` (since they can't exist without the parent). +- [x] **AC 7:** Given two concurrent `POST /moodle/provision/categories/preview` requests, when both are processed, then both succeed without `ConflictException` (no concurrency guard on preview). +- [x] **AC 8:** Given the admin console categories tab, when the user fills the form and clicks "Preview Categories", then a preview dialog shows the expected create/skip results before any Moodle changes are made. +- [x] **AC 9:** Given the preview dialog, when the user clicks "Confirm & Provision", then the actual provisioning executes and the result dialog shows the final outcome. +- [x] **AC 10:** Given the preview dialog, when the user clicks "Cancel", then no provisioning occurs and the dialog closes. + +## Additional Context + +### Dependencies + +- Moodle LMS API (`core_course_create_categories`, `core_course_get_categories`) — both authorized on `faculytics_service` +- Admin frontend (`admin.faculytics`) for preview UI +- shadcn/ui components (Dialog, Table, Badge, Button, ScrollArea) — already installed and in use + +### Testing Strategy + +**Unit tests (backend):** + +- `moodle-course-transform.service.spec.ts`: 6 new test cases for `ComputeSchoolYears` covering all semester/date combinations +- `moodle-provisioning.service.spec.ts`: 2 new test cases for year-fix in `ProvisionCategories`, 5 new test cases for `PreviewCategories` +- `moodle.client.spec.ts` (new file): 2 test cases — verify `webservice_access_exception` hint is appended, verify other exceptions don't get the hint + +**Manual testing:** + +1. Start admin console dev server (`cd admin.faculytics && bun dev`) +2. Start API dev server (`cd api.faculytics && npm run start:dev`) +3. Navigate to Moodle Provision > Categories tab +4. Select UCMN campus, semester 2, add CCS/BSCSAI +5. Click "Preview Categories" — verify preview shows correct tag `S22526` and correct skip/create statuses +6. Click "Confirm & Provision" — verify BSCSAI is created under existing hierarchy +7. Check Moodle tree browser to confirm the new category exists + +### Notes + +- The request that triggered the bug: `{"campuses":["UCMN"],"semesters":[2],"startDate":"2026-01-20","endDate":"2026-06-01","departments":[{"code":"CCS","programs":["BSCSAI"]}]}` +- API response: `{"created":0,"skipped":1,"errors":1,"details":[{"name":"UCMN","status":"skipped"},{"name":"S22626","status":"error","reason":"Moodle API error (webservice_access_exception): Access control exception"}]}` +- Expected correct flow: UCMN (skip) -> S22526 (skip) -> CCS (skip) -> BSCSAI (create) +- Moodle token migration completed: mobile web service -> dedicated `faculytics_service` with 13 functions +- VPS credentials updated: `.env.staging` and `.env.production` at `185.201.9.190` +- **Future UX enhancement:** Preview details are rendered as a flat list. Adding a `depth` field to `ProvisionDetailItem` and indenting items by depth in the dialog would better communicate the tree hierarchy. Not blocking — the execute endpoint returns the same flat list. Track separately if needed. +- **Known related issue (out of scope):** Same `.slice()` year-extraction bug exists in `buildSeedContext()` (controller:66-79) and `PreviewQuickCourse`/`ExecuteQuickCourse` (service:411-457) for course seeding. Unlike the category fix (where `ComputeSchoolYears` is called per-semester in a loop), the course fix is more involved: `buildSeedContext` creates a single `SeedContext` for the entire request, but each CSV row has its own semester. The fix requires per-row year computation in `ComputePreview`, `GenerateShortname`, and `BuildCategoryPath`, which touches the `SeedContext` interface. Track as a separate issue. + +## Review Notes + +- Adversarial review completed with 12 findings +- 8 fixed: F1 (DRY controller), F2 (controller spec tests), F3 (@ApiBearerAuth), F5 (NaN guard), F7 (error surfacing), F8 (confirm error UX), F10 (test cleanup), F12 (explicit via no-change — correct as-is) +- 3 acknowledged (design-level, no code fix): F4 (cascade UX), F6 (error semantics), F11 (Moodle TOCTOU) +- 1 noise: F9 (no guard on preview — intentional) +- Resolution approach: auto-fix diff --git a/src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts b/src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts index c42b96d..c1269b5 100644 --- a/src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts +++ b/src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts @@ -1,5 +1,9 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { BadRequestException } from '@nestjs/common'; +import { + BadGatewayException, + BadRequestException, + ServiceUnavailableException, +} from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { RolesGuard } from 'src/security/guards/roles.guard'; import { CurrentUserInterceptor } from 'src/modules/common/interceptors/current-user.interceptor'; @@ -9,6 +13,7 @@ import { } from 'src/modules/audit/testing/audit-test.helpers'; import { MoodleProvisioningController } from './moodle-provisioning.controller'; import { MoodleProvisioningService } from '../services/moodle-provisioning.service'; +import { MoodleConnectivityError } from '../lib/moodle.client'; describe('MoodleProvisioningController', () => { let controller: MoodleProvisioningController; @@ -22,11 +27,14 @@ describe('MoodleProvisioningController', () => { provide: MoodleProvisioningService, useValue: { ProvisionCategories: jest.fn(), + PreviewCategories: jest.fn(), PreviewCourses: jest.fn(), ExecuteCourseSeeding: jest.fn(), PreviewQuickCourse: jest.fn(), ExecuteQuickCourse: jest.fn(), SeedUsers: jest.fn(), + GetCategoryTree: jest.fn(), + GetCoursesByCategoryWithMasterKey: jest.fn(), }, }, ...auditTestProviders(), @@ -78,6 +86,113 @@ describe('MoodleProvisioningController', () => { }); }); + describe('PreviewCategories', () => { + it('should delegate to service', async () => { + const mockResult = { + created: 2, + skipped: 1, + errors: 0, + details: [], + durationMs: 50, + }; + provisioningService.PreviewCategories.mockResolvedValue(mockResult); + + const result = await controller.PreviewCategories({ + campuses: ['UCMN'], + semesters: [2], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [{ code: 'CCS', programs: ['BSCS'] }], + }); + + expect(result).toEqual(mockResult); + }); + + it('should throw BadGatewayException on MoodleConnectivityError', async () => { + provisioningService.PreviewCategories.mockRejectedValue( + new MoodleConnectivityError('timeout'), + ); + + await expect( + controller.PreviewCategories({ + campuses: ['UCMN'], + semesters: [1], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [], + }), + ).rejects.toThrow(BadGatewayException); + }); + + it('should throw BadRequestException on invalid semester', async () => { + provisioningService.PreviewCategories.mockRejectedValue( + new Error('Invalid semester: 3. Must be 1 or 2.'), + ); + + await expect( + controller.PreviewCategories({ + campuses: ['UCMN'], + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + semesters: [3 as any], + startDate: '2025-08-01', + endDate: '2025-12-18', + departments: [], + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw ServiceUnavailableException on unknown errors', async () => { + provisioningService.PreviewCategories.mockRejectedValue( + new Error('Something unexpected'), + ); + + await expect( + controller.PreviewCategories({ + campuses: ['UCMN'], + semesters: [1], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [], + }), + ).rejects.toThrow(ServiceUnavailableException); + }); + }); + + describe('ProvisionCategories error handling', () => { + it('should throw BadGatewayException on MoodleConnectivityError', async () => { + provisioningService.ProvisionCategories.mockRejectedValue( + new MoodleConnectivityError('timeout'), + ); + + await expect( + controller.ProvisionCategories({ + campuses: ['UCMN'], + semesters: [1], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [], + }), + ).rejects.toThrow(BadGatewayException); + }); + + it('should throw BadRequestException on invalid semester', async () => { + provisioningService.ProvisionCategories.mockRejectedValue( + new Error('Invalid semester: 3. Must be 1 or 2.'), + ); + + await expect( + controller.ProvisionCategories({ + campuses: ['UCMN'], + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + semesters: [3 as any], + startDate: '2025-08-01', + endDate: '2025-12-18', + departments: [], + }), + ).rejects.toThrow(BadRequestException); + }); + }); + describe('PreviewCourses', () => { it('should throw when no file provided', async () => { await expect( diff --git a/src/modules/moodle/controllers/moodle-provisioning.controller.ts b/src/modules/moodle/controllers/moodle-provisioning.controller.ts index de5d139..2a516f6 100644 --- a/src/modules/moodle/controllers/moodle-provisioning.controller.ts +++ b/src/modules/moodle/controllers/moodle-provisioning.controller.ts @@ -90,6 +90,7 @@ export class MoodleProvisioningController { @Post('categories') @HttpCode(HttpStatus.OK) @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiBearerAuth() @Audited({ action: AuditAction.MOODLE_PROVISION_CATEGORIES, resource: 'MoodleCategory', @@ -104,7 +105,25 @@ export class MoodleProvisioningController { async ProvisionCategories( @Body() dto: ProvisionCategoriesRequestDto, ): Promise { - return await this.provisioningService.ProvisionCategories(dto); + return this.handleCategoryOperation( + () => this.provisioningService.ProvisionCategories(dto), + 'provision', + ); + } + + @Post('categories/preview') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Preview Moodle category provisioning (dry run)' }) + @ApiResponse({ status: 200, type: ProvisionResultDto }) + async PreviewCategories( + @Body() dto: ProvisionCategoriesRequestDto, + ): Promise { + return this.handleCategoryOperation( + () => this.provisioningService.PreviewCategories(dto), + 'preview', + ); } @Post('courses/preview') @@ -273,4 +292,27 @@ export class MoodleProvisioningController { throw new ServiceUnavailableException('Failed to fetch Moodle courses'); } } + + private async handleCategoryOperation( + operation: () => Promise, + label: string, + ): Promise { + try { + return await operation(); + } catch (e) { + if (e instanceof MoodleConnectivityError) { + throw new BadGatewayException('Moodle is unreachable'); + } + if (e instanceof Error && e.message.startsWith('Invalid semester')) { + throw new BadRequestException(e.message); + } + this.logger.error( + `Failed to ${label} categories`, + e instanceof Error ? e.stack : e, + ); + throw new ServiceUnavailableException( + `Failed to ${label} Moodle categories`, + ); + } + } } diff --git a/src/modules/moodle/lib/moodle.client.spec.ts b/src/modules/moodle/lib/moodle.client.spec.ts new file mode 100644 index 0000000..5168095 --- /dev/null +++ b/src/modules/moodle/lib/moodle.client.spec.ts @@ -0,0 +1,48 @@ +import { MoodleClient } from './moodle.client'; + +describe('MoodleClient', () => { + let client: MoodleClient; + const originalFetch = global.fetch; + + beforeEach(() => { + client = new MoodleClient('http://moodle.test', 'test-token'); + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + describe('call() error handling', () => { + it('should append hint for webservice_access_exception', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => + Promise.resolve({ + exception: 'webservice_access_exception', + message: 'Access control exception', + }), + }); + + await expect(client.call('some_function')).rejects.toThrow( + 'Ensure the wsfunction is added to your Moodle external service', + ); + }); + + it('should not append hint for other exceptions', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ 'content-type': 'application/json' }), + json: () => + Promise.resolve({ + exception: 'dml_write_exception', + message: 'Some DB error', + }), + }); + + await expect(client.call('some_function')).rejects.toThrow( + /^Moodle API error \(dml_write_exception\): Some DB error$/, + ); + }); + }); +}); diff --git a/src/modules/moodle/lib/moodle.client.ts b/src/modules/moodle/lib/moodle.client.ts index 1ba2592..c914475 100644 --- a/src/modules/moodle/lib/moodle.client.ts +++ b/src/modules/moodle/lib/moodle.client.ts @@ -137,8 +137,12 @@ export class MoodleClient { const moodleError = data as { exception?: string; message?: string }; if (moodleError.exception) { + const hint = + moodleError.exception === 'webservice_access_exception' + ? ' Ensure the wsfunction is added to your Moodle external service (Site admin > Server > External services).' + : ''; throw new Error( - `Moodle API error (${moodleError.exception}): ${moodleError.message || 'Unknown error'}`, + `Moodle API error (${moodleError.exception}): ${moodleError.message || 'Unknown error'}${hint}`, ); } diff --git a/src/modules/moodle/services/moodle-course-transform.service.spec.ts b/src/modules/moodle/services/moodle-course-transform.service.spec.ts index d8693d9..c6e5b99 100644 --- a/src/modules/moodle/services/moodle-course-transform.service.spec.ts +++ b/src/modules/moodle/services/moodle-course-transform.service.spec.ts @@ -156,6 +156,44 @@ describe('MoodleCourseTransformService', () => { }); }); + describe('ComputeSchoolYears', () => { + it('should fix sem 2 only with same-year dates (the reported bug)', () => { + const result = service.ComputeSchoolYears(2, '2026-01-20', '2026-06-01'); + expect(result).toEqual({ startYY: '25', endYY: '26' }); + }); + + it('should fix sem 1 only with same-year dates', () => { + const result = service.ComputeSchoolYears(1, '2025-08-01', '2025-12-18'); + expect(result).toEqual({ startYY: '25', endYY: '26' }); + }); + + it('should handle both semesters (dates span years) for sem 1', () => { + const result = service.ComputeSchoolYears(1, '2025-08-01', '2026-06-01'); + expect(result).toEqual({ startYY: '25', endYY: '26' }); + }); + + it('should handle both semesters (dates span years) for sem 2', () => { + const result = service.ComputeSchoolYears(2, '2025-08-01', '2026-06-01'); + expect(result).toEqual({ startYY: '25', endYY: '26' }); + }); + + it('should handle next school year sem 1 with same-year dates', () => { + const result = service.ComputeSchoolYears(1, '2026-08-01', '2026-12-18'); + expect(result).toEqual({ startYY: '26', endYY: '27' }); + }); + + it('should handle next school year sem 2 with same-year dates', () => { + const result = service.ComputeSchoolYears(2, '2027-01-20', '2027-06-01'); + expect(result).toEqual({ startYY: '26', endYY: '27' }); + }); + + it('should throw for invalid semester number', () => { + expect(() => + service.ComputeSchoolYears(3, '2025-08-01', '2025-12-18'), + ).toThrow('Invalid semester: 3. Must be 1 or 2.'); + }); + }); + describe('ComputePreview', () => { it('should combine all transformations for a valid row', () => { const result = service.ComputePreview( diff --git a/src/modules/moodle/services/moodle-course-transform.service.ts b/src/modules/moodle/services/moodle-course-transform.service.ts index b273349..bea3662 100644 --- a/src/modules/moodle/services/moodle-course-transform.service.ts +++ b/src/modules/moodle/services/moodle-course-transform.service.ts @@ -57,6 +57,46 @@ export class MoodleCourseTransformService { return `S${semester}${startYY}${endYY}`; } + ComputeSchoolYears( + semester: number, + startDate: string, + endDate: string, + ): { startYY: string; endYY: string } { + const startYear = parseInt(startDate.slice(0, 4), 10); + const endYear = parseInt(endDate.slice(0, 4), 10); + + if (isNaN(startYear) || isNaN(endYear)) { + throw new Error( + `Invalid date format: startDate="${startDate}", endDate="${endDate}". Expected YYYY-MM-DD.`, + ); + } + + // If dates span different years, the school year boundary is explicit + if (startYear !== endYear) { + return { + startYY: String(startYear).slice(-2), + endYY: String(endYear).slice(-2), + }; + } + + // Same year — derive school year from semester number + if (semester === 1) { + // Semester 1 starts in Aug — year is school start year + return { + startYY: String(startYear).slice(-2), + endYY: String(startYear + 1).slice(-2), + }; + } + if (semester === 2) { + // Semester 2 starts in Jan — year is school end year + return { + startYY: String(startYear - 1).slice(-2), + endYY: String(startYear).slice(-2), + }; + } + throw new Error(`Invalid semester: ${semester}. Must be 1 or 2.`); + } + ComputePreview(row: CurriculumRow, context: SeedContext): CoursePreviewRow { const dates = this.GetSemesterDates( row.semester, diff --git a/src/modules/moodle/services/moodle-provisioning.service.spec.ts b/src/modules/moodle/services/moodle-provisioning.service.spec.ts index 10ec8a9..a776f50 100644 --- a/src/modules/moodle/services/moodle-provisioning.service.spec.ts +++ b/src/modules/moodle/services/moodle-provisioning.service.spec.ts @@ -96,6 +96,82 @@ describe('MoodleProvisioningService', () => { expect(skipped[0].name).toBe('UCMN'); }); + it('should produce correct tag S22526 for sem 2 with same-year dates', async () => { + moodleService.GetCategoriesWithMasterKey.mockResolvedValue([ + { + id: 1, + name: 'UCMN', + parent: 0, + depth: 1, + path: '1', + coursecount: 0, + visible: 1, + }, + ] as any); + moodleService.CreateCategories.mockResolvedValue([ + { id: 10, name: 'S22526' }, + ]); + categorySyncService.SyncAndRebuildHierarchy.mockResolvedValue({ + status: 'success', + durationMs: 100, + fetched: 0, + inserted: 0, + updated: 0, + deactivated: 0, + errors: 0, + }); + + const result = await service.ProvisionCategories({ + campuses: ['UCMN'], + semesters: [2], + startDate: '2026-01-20', + endDate: '2026-06-01', + departments: [], + }); + + const semDetail = result.details.find((d) => d.name.startsWith('S2')); + expect(semDetail).toBeDefined(); + expect(semDetail!.name).toBe('S22526'); + }); + + it('should produce correct tag S12526 for sem 1 with same-year dates', async () => { + moodleService.GetCategoriesWithMasterKey.mockResolvedValue([ + { + id: 1, + name: 'UCMN', + parent: 0, + depth: 1, + path: '1', + coursecount: 0, + visible: 1, + }, + ] as any); + moodleService.CreateCategories.mockResolvedValue([ + { id: 10, name: 'S12526' }, + ]); + categorySyncService.SyncAndRebuildHierarchy.mockResolvedValue({ + status: 'success', + durationMs: 100, + fetched: 0, + inserted: 0, + updated: 0, + deactivated: 0, + errors: 0, + }); + + const result = await service.ProvisionCategories({ + campuses: ['UCMN'], + semesters: [1], + startDate: '2025-08-01', + endDate: '2025-12-18', + departments: [], + }); + + const semDetail = result.details.find((d) => d.name.startsWith('S1')); + expect(semDetail).toBeDefined(); + expect(semDetail!.name).toBe('S12526'); + }); + it('should set syncCompleted to false when sync fails', async () => { moodleService.GetCategoriesWithMasterKey.mockResolvedValue([]); moodleService.CreateCategories.mockResolvedValue([ @@ -117,6 +193,161 @@ describe('MoodleProvisioningService', () => { }); }); + describe('PreviewCategories', () => { + it('should mark all as skipped when full hierarchy exists', async () => { + moodleService.GetCategoriesWithMasterKey.mockResolvedValue([ + { + id: 1, + name: 'UCMN', + parent: 0, + depth: 1, + coursecount: 0, + visible: 1, + }, + { + id: 10, + name: 'S22526', + parent: 1, + depth: 2, + coursecount: 0, + visible: 1, + }, + { + id: 20, + name: 'CCS', + parent: 10, + depth: 3, + coursecount: 0, + visible: 1, + }, + { + id: 30, + name: 'BSCSAI', + parent: 20, + depth: 4, + coursecount: 0, + visible: 1, + }, + ] as any); + + const result = await service.PreviewCategories({ + campuses: ['UCMN'], + semesters: [2], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [{ code: 'CCS', programs: ['BSCSAI'] }], + }); + + expect(result.errors).toBe(0); + expect(result.skipped).toBe(4); + expect(result.created).toBe(0); + expect(result.details.every((d) => d.status === 'skipped')).toBe(true); + }); + + it('should mark leaf as created when only program is missing', async () => { + moodleService.GetCategoriesWithMasterKey.mockResolvedValue([ + { + id: 1, + name: 'UCMN', + parent: 0, + depth: 1, + coursecount: 0, + visible: 1, + }, + { + id: 10, + name: 'S22526', + parent: 1, + depth: 2, + coursecount: 0, + visible: 1, + }, + { + id: 20, + name: 'CCS', + parent: 10, + depth: 3, + coursecount: 0, + visible: 1, + }, + ] as any); + + const result = await service.PreviewCategories({ + campuses: ['UCMN'], + semesters: [2], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [{ code: 'CCS', programs: ['BSCSAI'] }], + }); + + expect(result.skipped).toBe(3); + expect(result.created).toBe(1); + const createdItem = result.details.find((d) => d.status === 'created'); + expect(createdItem!.name).toBe('BSCSAI'); + }); + + it('should cascade created when parent is missing', async () => { + moodleService.GetCategoriesWithMasterKey.mockResolvedValue([ + { + id: 1, + name: 'UCMN', + parent: 0, + depth: 1, + coursecount: 0, + visible: 1, + }, + ] as any); + + const result = await service.PreviewCategories({ + campuses: ['UCMN'], + semesters: [2], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [{ code: 'CCS', programs: ['BSCSAI'] }], + }); + + expect(result.skipped).toBe(1); // campus + expect(result.created).toBe(3); // semester, dept, program + }); + + it('should not call CreateCategories', async () => { + moodleService.GetCategoriesWithMasterKey.mockResolvedValue([]); + + await service.PreviewCategories({ + campuses: ['UCMN'], + semesters: [1], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [], + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(moodleService.CreateCategories).not.toHaveBeenCalled(); + }); + + it('should not block concurrent preview calls', async () => { + moodleService.GetCategoriesWithMasterKey.mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([]), 50)), + ); + + const input = { + campuses: ['UCMN'], + semesters: [1], + startDate: '2025-08-01', + endDate: '2026-06-01', + departments: [], + }; + + const [a, b] = await Promise.all([ + service.PreviewCategories(input), + service.PreviewCategories(input), + ]); + + expect(a.errors).toBe(0); + expect(b.errors).toBe(0); + }); + }); + describe('PreviewCourses', () => { it('should transform valid rows and skip semester-0', async () => { csvParser.Parse.mockReturnValue({ diff --git a/src/modules/moodle/services/moodle-provisioning.service.ts b/src/modules/moodle/services/moodle-provisioning.service.ts index e814a77..b887c59 100644 --- a/src/modules/moodle/services/moodle-provisioning.service.ts +++ b/src/modules/moodle/services/moodle-provisioning.service.ts @@ -62,9 +62,6 @@ export class MoodleProvisioningService { existingByParentAndName.set(`${cat.parent}:${cat.name}`, cat); } - const startYY = input.startDate.slice(2, 4); - const endYY = input.endDate.slice(2, 4); - // Depth 1: Campuses const campusIds = new Map(); const missingCampuses = input.campuses.filter((c) => { @@ -109,6 +106,11 @@ export class MoodleProvisioningService { const campusId = campusIds.get(campus.toUpperCase()); if (!campusId) continue; for (const sem of input.semesters) { + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + sem, + input.startDate, + input.endDate, + ); const tag = this.transformService.BuildSemesterTag( String(sem), startYY, @@ -162,6 +164,11 @@ export class MoodleProvisioningService { }[] = []; for (const campus of input.campuses) { for (const sem of input.semesters) { + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + sem, + input.startDate, + input.endDate, + ); const tag = this.transformService.BuildSemesterTag( String(sem), startYY, @@ -215,6 +222,11 @@ export class MoodleProvisioningService { const missingProgs: { name: string; parent: number }[] = []; for (const campus of input.campuses) { for (const sem of input.semesters) { + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + sem, + input.startDate, + input.endDate, + ); const tag = this.transformService.BuildSemesterTag( String(sem), startYY, @@ -287,6 +299,80 @@ export class MoodleProvisioningService { } } + async PreviewCategories( + input: ProvisionCategoriesInput, + ): Promise { + const start = Date.now(); + const details: ProvisionDetailItem[] = []; + + const existing = await this.moodleService.GetCategoriesWithMasterKey(); + const byParentAndName = new Map(); + for (const cat of existing) { + byParentAndName.set(`${cat.parent}:${cat.name}`, cat); + } + + for (const campus of input.campuses) { + const campusName = campus.toUpperCase(); + const campusCat = byParentAndName.get(`0:${campusName}`); + const campusId = campusCat?.id; + details.push({ + name: campusName, + status: campusCat ? 'skipped' : 'created', + }); + + for (const sem of input.semesters) { + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + sem, + input.startDate, + input.endDate, + ); + const tag = this.transformService.BuildSemesterTag( + String(sem), + startYY, + endYY, + ); + const semCat = campusId + ? byParentAndName.get(`${campusId}:${tag}`) + : undefined; + const semId = semCat?.id; + details.push({ name: tag, status: semCat ? 'skipped' : 'created' }); + + for (const dept of input.departments) { + const deptName = dept.code.toUpperCase(); + const deptCat = semId + ? byParentAndName.get(`${semId}:${deptName}`) + : undefined; + const deptId = deptCat?.id; + details.push({ + name: deptName, + status: deptCat ? 'skipped' : 'created', + }); + + for (const prog of dept.programs) { + const progName = prog.toUpperCase(); + const progCat = deptId + ? byParentAndName.get(`${deptId}:${progName}`) + : undefined; + details.push({ + name: progName, + status: progCat ? 'skipped' : 'created', + }); + } + } + } + } + + const created = details.filter((d) => d.status === 'created').length; + const skipped = details.filter((d) => d.status === 'skipped').length; + return { + created, + skipped, + errors: 0, + details, + durationMs: Date.now() - start, + }; + } + async PreviewCourses( file: Buffer, context: SeedContext, From ba67334adc51666c3c6914c59338fa2281e27845 Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Sun, 12 Apr 2026 01:54:25 +0800 Subject: [PATCH 5/6] FAC-120 feat: enhance bulk course provisioning with cascading dropdowns (#286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- ...ech-spec-moodle-course-bulk-enhancement.md | 610 ++++++++++++++++++ .../admin/admin-filters.controller.spec.ts | 21 +- src/modules/admin/admin-filters.controller.ts | 19 +- .../requests/filter-departments-query.dto.ts | 5 + .../responses/semester-filter.response.dto.ts | 30 + .../admin/services/admin-filters.service.ts | 61 +- src/modules/audit/audit-action.enum.ts | 1 + .../moodle-provisioning.controller.ts | 42 ++ .../bulk-course-execute.request.dto.ts | 79 +++ .../bulk-course-preview.request.dto.ts | 66 ++ .../moodle-provisioning.service.spec.ts | 215 ++++++ .../services/moodle-provisioning.service.ts | 194 ++++++ 12 files changed, 1337 insertions(+), 6 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/tech-spec-moodle-course-bulk-enhancement.md create mode 100644 src/modules/admin/dto/responses/semester-filter.response.dto.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 diff --git a/_bmad-output/implementation-artifacts/tech-spec-moodle-course-bulk-enhancement.md b/_bmad-output/implementation-artifacts/tech-spec-moodle-course-bulk-enhancement.md new file mode 100644 index 0000000..c2bd85e --- /dev/null +++ b/_bmad-output/implementation-artifacts/tech-spec-moodle-course-bulk-enhancement.md @@ -0,0 +1,610 @@ +--- +title: 'Moodle Course Bulk Enhancement' +slug: 'moodle-course-bulk-enhancement' +created: '2026-04-12' +status: 'implementation-complete' +stepsCompleted: [1, 2, 3, 4] +tech_stack: + [ + 'NestJS 11', + 'MikroORM 6', + 'PostgreSQL', + 'React 19', + 'Vite', + 'TanStack Query', + 'shadcn/ui', + 'Radix Select', + 'Zod', + ] +files_to_modify: + - 'api: src/modules/admin/admin-filters.controller.ts' + - 'api: src/modules/admin/services/admin-filters.service.ts' + - 'api: src/modules/moodle/controllers/moodle-provisioning.controller.ts' + - 'api: src/modules/moodle/services/moodle-provisioning.service.ts' + - 'api: src/modules/moodle/services/moodle-course-transform.service.ts' + - 'api: src/modules/moodle/dto/requests/seed-courses.request.dto.ts' + - 'api: src/modules/moodle/dto/requests/execute-courses.request.dto.ts' + - 'api: src/modules/moodle/dto/responses/course-preview.response.dto.ts' + - 'api: src/modules/admin/dto/responses/semester-filter.response.dto.ts (new)' + - 'api: src/modules/admin/dto/requests/filter-departments-query.dto.ts' + - 'api: src/modules/audit/audit-action.enum.ts' + - 'admin: src/features/moodle-provision/components/courses-bulk-tab.tsx' + - 'admin: src/features/moodle-provision/use-preview-courses.ts' + - 'admin: src/features/moodle-provision/use-execute-courses.ts' + - 'admin: src/types/api.ts' +code_patterns: + - 'Filter endpoints: GET /admin/filters/?parentParam= returning { id, code, name? }[]' + - 'No dedicated repositories — direct em.find() with EntityManager' + - 'Guard pattern on provisioning execute (concurrent protection)' + - 'PascalCase public service methods' + - 'DTOs in dto/requests/ and dto/responses/ subfolders' + - 'Swagger decorators on all endpoints and DTO properties' + - 'shadcn Select component (Radix) for dropdowns' + - 'TanStack Query mutation hooks per API call' +test_patterns: + - 'Unit tests alongside source: *.spec.ts' + - 'Jest mocks for injected services' + - 'NestJS TestingModule setup pattern' +--- + +# Tech-Spec: Moodle Course Bulk Enhancement + +**Created:** 2026-04-12 + +## Overview + +### Problem Statement + +The current bulk course provisioning flow only drills down to Campus + Department with free-text inputs. This doesn't match the actual Moodle category hierarchy (Semester -> Department -> Program) and forces users to manually type values and date ranges that could be derived from existing data. + +### Solution + +Enhance the bulk course provisioning UI with cascading dropdown selectors (Semester -> Department -> Program) backed by new/existing API filter endpoints, with semester selection auto-filling date ranges while keeping dates editable. Replace CSV file upload with an inline editable table for course data entry (courseCode + descriptiveTitle), keeping the preview -> execute two-step pattern. + +### Scope + +**In Scope:** + +- Cascading dropdown selectors: Semester -> Department -> Program +- API endpoint: `GET /admin/filters/semesters` (new, with date range data) +- API endpoint: Update department filter to support `semesterId` parameter +- Replace CSV upload with inline editable table (courseCode + descriptiveTitle columns) +- New JSON-based preview endpoint (replaces CSV buffer input) +- Auto-fill start/end dates from semester selection (dates remain editable) +- Both api.faculytics and admin.faculytics changes +- Bulk course tab full rework +- Single program per batch (confirmed user workflow) + +**Out of Scope:** + +- Quick course tab changes +- Category provisioning tab changes +- Moodle sync changes +- User provisioning changes +- CSV upload (fully replaced by inline table) +- Multi-program batch support + +## Context for Development + +### Codebase Patterns + +- API uses PascalCase for public service methods +- Filter endpoints: `GET /admin/filters/?parentParam=` returning `{ id, code, name? }[]` +- No dedicated repositories for entities — direct `em.find()` via EntityManager +- DTOs split into `dto/requests/` and `dto/responses/` subfolders with Swagger decorators +- Admin frontend: TanStack Query mutation hooks per API call in `src/features/moodle-provision/` +- shadcn `Select` (Radix) already used for dropdowns in quick-course-tab +- Guard pattern prevents concurrent provisioning operations +- Category hierarchy: Campus -> Semester -> Department -> Program + +### Entity Schema (Investigated) + +``` +Campus { id, moodleCategoryId, code, name? } + └─ Semester { id, moodleCategoryId, code, label?, academicYear? } ← NO startDate/endDate + └─ Department { id, moodleCategoryId, code, name? } + └─ Program { id, moodleCategoryId, code, name? } +``` + +**Critical**: Semester has no date fields. Dates are derived from the semester `code` field (e.g., `S12526` → Semester 1, 2025-2026) using hardcoded logic in `MoodleCourseTransformService.GetSemesterDates()`: + +- S1: Aug 1 – Dec 18 (of startYear) +- S2: Jan 20 – Jun 1 (of endYear) + +### Files to Reference + +| File | Purpose | +| ----------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `api: src/entities/semester.entity.ts` | Semester entity (code, label, academicYear, moodleCategoryId) | +| `api: src/entities/department.entity.ts` | Department entity (ManyToOne Semester) | +| `api: src/entities/program.entity.ts` | Program entity (ManyToOne Department, moodleCategoryId) | +| `api: src/modules/admin/admin-filters.controller.ts` | Filter endpoints controller | +| `api: src/modules/admin/services/admin-filters.service.ts` | Filter queries (no moodleCategoryId filtering currently) | +| `api: src/modules/moodle/controllers/moodle-provisioning.controller.ts` | Provision endpoints (CSV upload + context) | +| `api: src/modules/moodle/services/moodle-provisioning.service.ts` | PreviewCourses (CSV parse), ExecuteCourseSeeding (batch create) | +| `api: src/modules/moodle/services/moodle-course-transform.service.ts` | Shortname/categoryPath generation, GetSemesterDates | +| `api: src/modules/moodle/services/moodle-csv-parser.service.ts` | CSV parser (4 columns: courseCode, descriptiveTitle, program, semester) | +| `api: src/modules/moodle/dto/requests/seed-courses.request.dto.ts` | Current: { campus, department, startDate, endDate } | +| `api: src/modules/moodle/dto/requests/execute-courses.request.dto.ts` | Current: { rows[], campus, department, startDate, endDate } | +| `api: src/modules/moodle/dto/responses/course-preview.response.dto.ts` | Preview response (valid/skipped/errors) | +| `api: src/modules/moodle/lib/provisioning.types.ts` | CurriculumRow, SeedContext, CoursePreviewRow types | +| `admin: src/features/moodle-provision/components/courses-bulk-tab.tsx` | Current bulk UI (text inputs + CSV drop zone) | +| `admin: src/features/moodle-provision/components/csv-drop-zone.tsx` | CSV file upload component (to be removed from bulk flow) | +| `admin: src/features/moodle-provision/use-preview-courses.ts` | FormData POST with file + context | +| `admin: src/features/moodle-provision/use-execute-courses.ts` | JSON POST with rows + context | +| `admin: src/types/api.ts` | All shared types (SeedCoursesContext, CoursePreviewRow, etc.) | +| `admin: src/lib/constants.ts` | CAMPUSES array, getSemesterDates() helper | +| `admin: src/components/ui/select.tsx` | shadcn Select component (Radix-based) | + +### Technical Decisions + +- Dropdown cascade starts from Semester (not Campus) — matches DB schema (Department.semester) +- Semester selection auto-fills dates by parsing semester code — dates remain editable +- CSV upload fully replaced by inline editable table — no backward compatibility +- Single program per batch always — no per-row program/semester needed +- `moodleCategoryId` is non-nullable (`number`, required) on all entities — these tables are only populated by Moodle sync, so every row inherently has a valid ID. No additional `$ne: null` filter is needed. +- Preview endpoint accepts JSON `{ semesterId, departmentId, programId, startDate, endDate, courses[] }` instead of CSV buffer +- Server resolves shortname, fullname, categoryPath, categoryId from entity hierarchy using programId +- CategoryId resolved directly from `Program.moodleCategoryId` (no path-string parsing) +- Inline table provides client-side validation before preview (course code format) +- Existing `MoodleCourseTransformService` methods reused for shortname/date generation +- Guard pattern on execute preserved + +## Implementation Plan + +### Phase 1: API Filter Endpoints + +- [x] Task 1: Add `GET /admin/filters/semesters` endpoint + - File: `api: src/modules/admin/services/admin-filters.service.ts` + - Action: Add `GetSemesters()` method + - Query: `em.find(Semester, {}, { populate: ['campus'], orderBy: { code: 'DESC' } })` + - Map results to response shape including computed dates: + ```typescript + { + id: string; // Semester UUID + code: string; // "S12526" + label: string; // "Semester 1" (from entity label or parsed from code) + academicYear: string; // "2025-2026" (from entity or parsed) + campusCode: string; // "UCMN" (from populated campus.code) + startDate: string; // Computed: parse code → GetSemesterDates() + endDate: string; // Computed: parse code → GetSemesterDates() + } + ``` + - Compute dates by parsing semester code with defensive regex `/^S([12])(\d{2})(\d{2})$/`: + - Extract semester number (`match[1]`), startYY (`match[2]`), endYY (`match[3]`) + - **Convert 2-digit to 4-digit years**: `"20" + startYY` → `"2025"`, `"20" + endYY` → `"2026"`. `GetSemesterDates()` takes full 4-digit year strings (it interpolates into `${startYear}-08-01`). + - Call: `GetSemesterDates(semesterNum, fullStartYear, fullEndYear)` → returns `{ startDate, endDate }` as ISO date strings + - If code doesn't match regex, skip semester from results (log warning) — malformed Moodle category codes should not crash the endpoint + - File: `api: src/modules/admin/admin-filters.controller.ts` + - Action: Add `@Get('semesters')` endpoint calling `GetSemesters()`. Add Swagger decorators. + - File: `api: src/modules/admin/dto/responses/` (new file: `semester-filter.response.dto.ts`) + - Action: Create `SemesterFilterDto` response class with Swagger `@ApiProperty()` decorators. **Place in admin module's DTO folder** (not moodle's) — the endpoint lives in `AdminFiltersController`, and all other admin filter DTOs are in `src/modules/admin/dto/responses/`. + - Notes: Semesters are per-campus — response must include `campusCode` so frontend can display "UCMN - Semester 1 (2025-2026)" to disambiguate + +- [x] Task 2: Update department filter to accept `semesterId` + - File: `api: src/modules/admin/dto/requests/filter-departments-query.dto.ts` + - Action: Add `@IsOptional() @IsUUID() semesterId?: string` property to the existing `FilterDepartmentsQueryDto` class. Add `@ApiPropertyOptional()` decorator. + - File: `api: src/modules/admin/admin-filters.controller.ts` + - Action: The controller already uses `@Query() query: FilterDepartmentsQueryDto`. Pass `query.semesterId` to the service: `this.filtersService.GetDepartments(query.campusId, query.semesterId)`. + - File: `api: src/modules/admin/services/admin-filters.service.ts` + - Action: Update `GetDepartments(campusId?: string)` signature to `GetDepartments(campusId?: string, semesterId?: string)` + - When `semesterId` provided: filter `{ semester: semesterId }` and order by `{ code: 'ASC' }` + - When `campusId` provided: keep existing `{ semester: { campus: campusId } }` and order by `{ code: 'ASC' }` + - `semesterId` takes precedence if both provided + - Notes: Additive change — existing call site passes `(query.campusId)` which still works since `semesterId` defaults to `undefined`. Update the controller call to pass both: `(query.campusId, query.semesterId)`. + +- [x] Task 3: No `moodleCategoryId` filtering needed (removed) + - `moodleCategoryId` is non-nullable on all entities (Semester, Department, Program). These tables are only populated by Moodle sync, so every row has a valid Moodle category ID. No additional filter is needed — the existing queries return correct results as-is. + - **This task is a no-op.** Numbering preserved for continuity. + +### Phase 2: API Provisioning DTOs + +- [x] Task 4: Create new bulk course preview request DTO + - File: `api: src/modules/moodle/dto/requests/` (new file: `bulk-course-preview.request.dto.ts`) + - Action: Create DTO class: + + ```typescript + class BulkCoursePreviewRequestDto { + @IsUUID() semesterId: string; + @IsUUID() departmentId: string; + @IsUUID() programId: string; + @IsDateString() @Validate(IsBeforeEndDate) startDate: string; + @IsDateString() endDate: string; + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(500) + @ValidateNested({ each: true }) + @Type(() => CourseEntryDto) + courses: CourseEntryDto[]; + } + + class CourseEntryDto { + @IsString() @IsNotEmpty() courseCode: string; + @IsString() @IsNotEmpty() descriptiveTitle: string; + } + ``` + + - Notes: Add Swagger decorators to all properties. `@Validate(IsBeforeEndDate)` is the custom validator used by existing DTOs (see `seed-courses.request.dto.ts`). `@ArrayMaxSize(500)` prevents DoS — 500 courses = 10 Moodle API batches, reasonable upper bound. `@IsArray()` and `@ArrayNotEmpty()` match existing DTO patterns. + +- [x] Task 5: Create new bulk course execute request DTO + - File: `api: src/modules/moodle/dto/requests/` (new file: `bulk-course-execute.request.dto.ts`) + - Action: Create DTO class: + + ```typescript + class BulkCourseExecuteRequestDto { + @IsUUID() semesterId: string; + @IsUUID() departmentId: string; + @IsUUID() programId: string; + @IsDateString() @Validate(IsBeforeEndDate) startDate: string; + @IsDateString() endDate: string; + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(500) + @ValidateNested({ each: true }) + @Type(() => ConfirmedCourseEntryDto) + courses: ConfirmedCourseEntryDto[]; + } + + class ConfirmedCourseEntryDto { + @IsString() courseCode: string; + @IsString() descriptiveTitle: string; + @IsInt() categoryId: number; // moodleCategoryId from preview — use @IsInt() not @IsNumber() to reject floats (matches existing CoursePreviewRowDto convention) + } + ``` + + - Notes: `categoryId` is carried from preview response so execute doesn't re-resolve. **Known tradeoff (F12)**: trusting client-supplied `categoryId` is the pre-existing pattern from `ExecuteCourseSeeding` — a stale preview could target a wrong category. Accepted as pre-existing technical debt; server-side re-validation deferred to a future pass. + +### Phase 3: API Provisioning Service + +- [x] Task 6: Add `PreviewBulkCourses` method to provisioning service + - File: `api: src/modules/moodle/services/moodle-provisioning.service.ts` + - Action: Add new method (keep old `PreviewCourses` intact for now): + ```typescript + async PreviewBulkCourses(dto: BulkCoursePreviewRequest): Promise + ``` + + - Load Program with populated hierarchy: `em.findOne(Program, dto.programId, { populate: ['department.semester.campus'] })`. If `null`, throw `BadRequestException('Program not found')` — matches existing pattern in this service (do NOT use `findOneOrFail` which throws raw `NotFoundError` as 500). + - Validate the loaded program's `department.id === dto.departmentId` and `department.semester.id === dto.semesterId` (relationship integrity check). If mismatch, throw `BadRequestException('Program does not belong to the specified department/semester')`. + - Extract from the populated entity: + - `campusCode = program.department.semester.campus.code` (e.g., `"UCMN"`) + - `semesterCode = program.department.semester.code` (e.g., `"S12526"` — the full code string from the entity) + - `deptCode = program.department.code` (e.g., `"CCS"`) + - `programCode = program.code` (e.g., `"BSIT"`) + - `moodleCategoryId = program.moodleCategoryId` + - Parse semester code with defensive regex: `const match = semesterCode.match(/^S([12])(\d{2})(\d{2})$/)`. If no match, throw `BadRequestException('Invalid semester code format: ${semesterCode}')`. + - Extract: `const semesterDigit = Number(match[1])` (e.g., `1`). **Must use `Number()` for runtime conversion** — `as number` is a type assertion that does NOT convert. `"1" === 1` is false. + - **Variable naming convention**: `semesterCode` = full entity string (e.g., `"S12526"`), `semesterDigit` = the parsed number (e.g., `1`). Never pass `semesterCode` to `GenerateShortname`/`BuildCategoryPath` — they expect the digit as a string and prepend `S` internally. + - Derive `startYY`, `endYY` from provided `startDate`/`endDate` using `transformService.ComputeSchoolYears(semesterDigit, dto.startDate, dto.endDate)` — `ComputeSchoolYears` takes `semester: number`, returns `{ startYY: string, endYY: string }`. **Use user-provided dates** (not code-parsed years) since user may have manually overridden dates. + - For each course in `dto.courses`: + - Generate shortname via `transformService.GenerateShortname(campusCode, String(semesterDigit), startYY, endYY, course.courseCode)` — `GenerateShortname` takes `semester: string` (e.g., `"1"`), prepends `S` internally to produce `S12526`. + - Build categoryPath via `transformService.BuildCategoryPath(campusCode, String(semesterDigit), deptCode, programCode, startYY, endYY)` — also prepends `S` internally. + - Build preview row: `{ shortname, fullname: course.descriptiveTitle, categoryPath, categoryId: program.moodleCategoryId, startDate: dto.startDate, endDate: dto.endDate, program: programCode, semester: String(semesterNum), courseCode: course.courseCode }`. **Note**: `CoursePreviewRow` type (in `provisioning.types.ts`) requires `program: string` and `semester: string` fields — populate them from the entity-derived `programCode` and `semesterNum`. In the new bulk flow these are the same for every row, but the type requires them. + - Return `{ valid, skipped: [], errors: [], shortnameNote }` + - Notes: No CSV parsing. No per-row program/semester lookup. Single entity load covers all rows. + +- [x] Task 7: Add `ExecuteBulkCourses` method to provisioning service + - File: `api: src/modules/moodle/services/moodle-provisioning.service.ts` + - Action: Add new method (keep old `ExecuteCourseSeeding` intact for now): + ```typescript + async ExecuteBulkCourses(dto: BulkCourseExecuteRequest): Promise + ``` + + - Use `this.acquireGuard('courses')` (same guard pattern) + - Load Program with populated hierarchy (same as preview): `em.findOne(Program, dto.programId, { populate: ['department.semester.campus'] })` + null check throwing `BadRequestException` + - Extract `campusCode`, `semesterCode` from populated entity (same variable naming as Task 6). Parse with regex: `const match = semesterCode.match(/^S([12])(\d{2})(\d{2})$/)` + `BadRequestException` if no match. `const semesterDigit = Number(match[1])`. + - Derive `startYY`, `endYY` from `dto.startDate`/`dto.endDate` using `transformService.ComputeSchoolYears(semesterDigit, dto.startDate, dto.endDate)` + - For each confirmed course: + - Regenerate shortname via `transformService.GenerateShortname(campusCode, String(semesterDigit), startYY, endYY, course.courseCode)` — new EDP code is generated (this is the final one) + - Build Moodle course input: `{ shortname, fullname: course.descriptiveTitle, categoryid: course.categoryId, startdate: unixTimestamp, enddate: unixTimestamp }` + - Convert dates to Unix timestamps: `Math.floor(new Date(dto.startDate).getTime() / 1000)` + - Batch create in chunks of 50 via `moodleService.CreateCourses(batch)` + - Track results, release guard in finally block + - Return `ProvisionResult` + - Notes: Same batching and guard pattern as existing `ExecuteCourseSeeding`. `MoodleConnectivityError` is NOT caught here — it propagates to the controller (see Task 8). The execute method deliberately loads the full entity hierarchy from `programId` and derives campus/semester/department codes from the _entity_ data — not from per-row fields. This is intentional: entity data is the source of truth, and the cascade dropdowns already validated the hierarchy before the user reached this point. + +### Phase 4: API Provisioning Controller + +- [x] Task 8: Add new bulk preview and execute endpoints + - File: `api: src/modules/moodle/controllers/moodle-provisioning.controller.ts` + - Action: Add two new endpoints: + + ```typescript + @Post('courses/bulk/preview') + async PreviewBulkCourses(@Body() dto: BulkCoursePreviewRequestDto): Promise + + @Post('courses/bulk/execute') + async ExecuteBulkCourses(@Body() dto: BulkCourseExecuteRequestDto): Promise + ``` + + - Notes: New routes (`/bulk/preview`, `/bulk/execute`) rather than modifying existing CSV-based endpoints. Keeps backward compat if CSV endpoints are used elsewhere. Add Swagger `@ApiOperation()` and `@ApiResponse()` decorators. Both use `@UseJwtGuard()`. + - **Audit trail (F6 fix)**: The execute endpoint MUST include audit decorators matching the existing `ExecuteCourses` pattern: + - Add a new property to the `AuditAction` **const object** (not a TS enum) in `audit-action.enum.ts`: `MOODLE_BULK_PROVISION_COURSES: 'moodle.provision.bulk-courses'` — follows the existing `dot.separated.kebab` string value convention (e.g., `MOODLE_PROVISION_COURSES: 'moodle.provision.courses'`) + - Add `@Audited({ action: AuditAction.MOODLE_BULK_PROVISION_COURSES, resource: 'MoodleCourse' })` to the execute endpoint + - Add `@UseInterceptors(MetaDataInterceptor, CurrentUserInterceptor, AuditInterceptor)` to the execute endpoint + - The preview endpoint does NOT need auditing (read-only, no side effects) + - **MoodleConnectivityError handling**: The execute controller method must wrap the service call in a try/catch that catches `MoodleConnectivityError` and throws `BadGatewayException('Moodle is unreachable')`. This follows the pattern used in read-only controller methods (`GetCategoryTree`, `GetCategoryCourses`, `PreviewCategories`). Note: the existing `ExecuteCourses` endpoint does NOT have this handling — the new bulk endpoint is an improvement over the old pattern. Error handling belongs in the controller, NOT the service. + - **`@HttpCode(HttpStatus.OK)`**: Add to BOTH new POST endpoints. All existing POSTs in this controller use this decorator. Without it, NestJS defaults to 201 which is semantically wrong. + - **`@ApiBearerAuth()`**: Add to BOTH new endpoints. The controller does not have class-level `@ApiBearerAuth()`, so each endpoint needs it individually for Swagger documentation. + +### Phase 5: Admin Frontend Types + +- [x] Task 9: Add new TypeScript types for bulk course flow + - File: `admin: src/types/api.ts` + - Action: Add new interfaces: + + ```typescript + // Semester filter response + interface SemesterFilterOption { + id: string; + code: string; + label: string; + academicYear: string; + campusCode: string; + startDate: string; + endDate: string; + } + + // New bulk course request + interface BulkCoursePreviewRequest { + semesterId: string; + departmentId: string; + programId: string; + startDate: string; + endDate: string; + courses: { courseCode: string; descriptiveTitle: string }[]; + } + + // New bulk course execute request + interface BulkCourseExecuteRequest { + semesterId: string; + departmentId: string; + programId: string; + startDate: string; + endDate: string; + courses: { + courseCode: string; + descriptiveTitle: string; + categoryId: number; + }[]; + } + ``` + + - Notes: Existing `CoursePreviewResponse` and `ProvisionResultResponse` types remain unchanged — the response shapes are the same + +### Phase 6: Admin Frontend Hooks + +- [x] Task 10: Add `useSemesters` query hook + - File: `admin: src/features/moodle-provision/` (new file: `use-semesters.ts`) + - Action: Create TanStack Query hook. **CRITICAL**: Follow the exact pattern from existing hooks like `use-moodle-tree.ts`: + + ```typescript + import { useQuery } from '@tanstack/react-query'; + import { apiClient } from '@/lib/api-client'; + import { useEnvStore } from '@/stores/env-store'; + import { useAuthStore } from '@/stores/auth-store'; + import type { SemesterFilterOption } from '@/types/api'; + + export function useSemesters() { + const activeEnvId = useEnvStore((s) => s.activeEnvId); + const isAuth = useAuthStore((s) => + activeEnvId ? s.isAuthenticated(activeEnvId) : false, + ); + return useQuery({ + queryKey: ['filters', 'semesters', activeEnvId], + queryFn: () => + apiClient('/admin/filters/semesters'), + enabled: !!activeEnvId && isAuth, + }); + } + ``` + + - Notes: **`apiClient` is a plain async function** (not Axios) — call as `apiClient(path, options?)`. It returns parsed JSON directly (no `.data` wrapper). Zustand stores must use **selector syntax** `useEnvStore((s) => s.activeEnvId)`, not destructuring. `isAuthenticated(envId)` is a method on the auth store, not a boolean property. `activeEnvId` must be included in `queryKey` for cache isolation across environments. + +- [x] Task 11: Add `useDepartmentsBySemester` query hook + - File: `admin: src/features/moodle-provision/` (new file or extend existing) + - Action: Create TanStack Query hook following the same pattern: + ```typescript + export function useDepartmentsBySemester(semesterId: string | undefined) { + const activeEnvId = useEnvStore((s) => s.activeEnvId); + const isAuth = useAuthStore((s) => + activeEnvId ? s.isAuthenticated(activeEnvId) : false, + ); + return useQuery({ + queryKey: ['filters', 'departments', activeEnvId, { semesterId }], + queryFn: () => + apiClient<{ id: string; code: string; name?: string }[]>( + `/admin/filters/departments?semesterId=${semesterId}`, + ), + enabled: !!activeEnvId && isAuth && !!semesterId, + }); + } + ``` + - Notes: `enabled` includes all three guards. `activeEnvId` in query key. + +- [x] Task 12: Add `useProgramsByDepartment` query hook + - File: `admin: src/features/moodle-provision/` (new file or extend existing) + - Action: Create TanStack Query hook following the same pattern: + ```typescript + export function useProgramsByDepartment(departmentId: string | undefined) { + const activeEnvId = useEnvStore((s) => s.activeEnvId); + const isAuth = useAuthStore((s) => + activeEnvId ? s.isAuthenticated(activeEnvId) : false, + ); + return useQuery({ + queryKey: ['filters', 'programs', activeEnvId, { departmentId }], + queryFn: () => + apiClient<{ id: string; code: string; name?: string }[]>( + `/admin/filters/programs?departmentId=${departmentId}`, + ), + enabled: !!activeEnvId && isAuth && !!departmentId, + }); + } + ``` + +- [x] Task 13: Rewrite `use-preview-courses.ts` for JSON body + - File: `admin: src/features/moodle-provision/use-preview-courses.ts` + - Action: Replace FormData/file upload with JSON POST using `apiClient`: + ```typescript + export function usePreviewBulkCourses() { + return useMutation({ + mutationFn: (dto: BulkCoursePreviewRequest) => + apiClient( + '/moodle/provision/courses/bulk/preview', + { + method: 'POST', + body: JSON.stringify(dto), + }, + ), + onError: (error) => { + /* toast error — follow existing use-execute-courses.ts pattern */ + }, + }); + } + ``` + - Notes: Remove file/FormData handling entirely. `apiClient` is called as a function with path + options object (see existing `use-execute-courses.ts` for exact pattern). Returns parsed JSON directly. + +- [x] Task 14: Update `use-execute-courses.ts` for new request shape + - File: `admin: src/features/moodle-provision/use-execute-courses.ts` + - Action: Update mutation to use `BulkCourseExecuteRequest` type. Change endpoint to `/moodle/provision/courses/bulk/execute`. Keep 409 conflict handling. Use same `apiClient(path, { method: 'POST', body: JSON.stringify(data) })` pattern as the existing hook. + +### Phase 7: Admin Frontend UI + +- [x] Task 15: Rework `courses-bulk-tab.tsx` with cascading dropdowns and inline table + - File: `admin: src/features/moodle-provision/components/courses-bulk-tab.tsx` + - Action: Full rework of the component. New structure: + + **State:** + + ```typescript + // Cascade state + const [semesterId, setSemesterId] = useState(); + const [departmentId, setDepartmentId] = useState(); + const [programId, setProgramId] = useState(); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + // Course table state + const [courses, setCourses] = useState< + { courseCode: string; descriptiveTitle: string }[] + >([ + { courseCode: '', descriptiveTitle: '' }, // start with one empty row + ]); + + // View state + const [view, setView] = useState<'input' | 'preview'>('input'); + ``` + + **Cascade behavior:** + - When `semesterId` changes: reset `departmentId`, `programId`. Auto-fill `startDate`/`endDate` from the selected semester's `startDate`/`endDate` fields returned by the `GET /admin/filters/semesters` API response (server-computed dates are the source of truth — do NOT use the client-side `getSemesterDates()` helper from `constants.ts`). + - When `departmentId` changes: reset `programId`. + + **Dropdowns (using shadcn Select):** + 1. Semester Select — data from `useSemesters()`. Display: `"{campusCode} - {label} ({academicYear})"`. On change: auto-fill dates, clear children. + 2. Department Select — data from `useDepartmentsBySemester(semesterId)`. Disabled until semester selected. Display: `"{code} - {name}"` (or just `code` if no name). + 3. Program Select — data from `useProgramsByDepartment(departmentId)`. Disabled until department selected. Display: `"{code} - {name}"`. + 4. Start Date input — auto-filled, editable. + 5. End Date input — auto-filled, editable. + + **Inline course table:** + - Rendered below dropdowns when all three selections made + - Table columns: Row #, Course Code (text input), Descriptive Title (text input), Delete (button) + - "Add Row" button below the table + - Client-side validation: non-empty courseCode and descriptiveTitle, no duplicate courseCodes + - Minimum 1 row to enable Preview button + + **Preview button:** + - Enabled when: all 3 dropdowns selected + startDate + endDate + at least 1 valid course row + - Calls `previewMutation.mutate()` with the full DTO + - On success: switches to `preview` view + + **Preview view:** + - Table with checkboxes showing shortname, fullname, categoryPath, dates. Note: the response still includes `program` and `semester` per-row (required by `CoursePreviewRow` type), but in the new bulk flow these are identical for every row — the preview table should NOT display them as columns (unlike the old CSV flow where they varied per row). + - Shows `shortnameNote` info box + - Skipped/errors sections if any + - "Back" button to return to input view + - "Create N Courses" button for checked rows + + **Execute:** + - Maps checked preview rows to `BulkCourseExecuteRequest` + - Calls `executeMutation.mutate()` + - Shows `ProvisionResultDialog` on success + + - Notes: Remove all CSV-related imports/components (`CsvDropZone`, file state). Remove campus/department text inputs. The `onBrowse` prop can remain if the tree explorer button is still desired. + +### Acceptance Criteria + +- [x] AC 1: Given the admin is on the bulk courses tab, when the page loads, then a Semester dropdown is shown containing all synced semesters, displaying campus code, label, and academic year. + +- [x] AC 2: Given the admin selects a semester, when the semester changes, then the Department dropdown populates with departments under that semester, and the start/end date fields auto-fill with the semester's server-computed dates. + +- [x] AC 3: Given the admin selects a department, when the department changes, then the Program dropdown populates with programs under that department. + +- [x] AC 4: Given the admin changes the semester selection, when a department and/or program were previously selected, then both department and program selections are cleared and their dropdowns reset. + +- [x] AC 5: Given the admin changes the department selection, when a program was previously selected, then the program selection is cleared. + +- [x] AC 6: Given all three dropdowns are selected, when the inline course table is displayed, then the admin can add rows with courseCode and descriptiveTitle fields, remove rows, and sees at least one empty row by default. + +- [x] AC 7: Given the admin has filled in all dropdowns + dates + at least one valid course row, when they click Preview, then a JSON POST is sent to `/moodle/provision/courses/bulk/preview` and the response displays generated shortnames, fullnames, category paths, and dates for each course. + +- [x] AC 8: Given the preview is displayed, when the admin checks courses and clicks "Create N Courses", then a JSON POST is sent to `/moodle/provision/courses/bulk/execute` and a result dialog shows created/error counts. + +- [x] AC 9: Given a semester is selected, when the dates are auto-filled, then the admin can still manually edit the start and end dates before previewing. + +- [x] AC 10: Given the admin enters duplicate course codes in the inline table, when they attempt to preview, then client-side validation prevents the request and shows an error. + +- [x] AC 11: Given there are no semesters in the database, when the semester dropdown loads, then it shows an empty state (no options) and downstream dropdowns remain disabled. + +- [x] AC 12: Given the execute endpoint is called while another provisioning operation is running, when the guard detects a conflict, then a 409 response is returned and the admin sees "A provisioning operation is already in progress." + +- [x] AC 13: Given Moodle is unreachable during bulk execute, when the `MoodleConnectivityError` is thrown, then a 502 Bad Gateway response is returned and the admin sees a clear "Moodle is unreachable" error message. + +- [x] AC 14: Given an invalid programId is submitted to the preview or execute endpoint, when the program is not found or doesn't belong to the specified department/semester, then a 400 Bad Request is returned with a descriptive error message. + +## Additional Context + +### Dependencies + +- Existing filter endpoints: `GET /admin/filters/departments?campusId=`, `GET /admin/filters/programs?departmentId=` +- New endpoint: `GET /admin/filters/semesters` (must return computed date range from code) +- Department filter gains `semesterId` query parameter (additive, backward compatible) +- `moodleCategoryId` is non-nullable on all entities — no additional filter needed (tables only populated by Moodle sync) +- `MoodleCourseTransformService` methods reused: `GenerateShortname`, `BuildCategoryPath`, `GetSemesterDates`, `ComputeSchoolYears` +- Existing preview/execute CSV endpoints remain untouched (new `/bulk/` routes added alongside) + +### Testing Strategy + +**API Unit Tests:** + +- `admin-filters.service.spec.ts`: Test `GetSemesters()` returns semesters with populated campus and computed dates from semester code, test `GetDepartments(undefined, semesterId)` filters by semester correctly, test backward compat of `GetDepartments(campusId)` still works +- `moodle-provisioning.service.spec.ts`: Test `PreviewBulkCourses()` generates correct shortnames/categoryPaths from entity hierarchy, test `BadRequestException` when programId not found, test relationship validation (mismatched semesterId/departmentId/programId throws `BadRequestException`), test `ExecuteBulkCourses()` batching and guard behavior, test `MoodleConnectivityError` propagation + +**Admin Manual Testing:** + +- Verify cascade: select semester → departments load → select department → programs load +- Verify cascade reset: change semester → department and program clear +- Verify date auto-fill on semester select, then manual override +- Verify inline table: add rows, remove rows, enter data, duplicate detection +- Verify preview renders correctly with generated shortnames +- Verify execute creates courses in Moodle +- Verify empty states (no semesters, no departments for a semester, etc.) + +### Notes + +- Semester code format: `S{semesterNum}{startYY}{endYY}` (e.g., `S12526` = Semester 1, 2025-2026) +- Shortname format: `{CAMPUS}-{semesterCode}-{courseCode}-{5digitEDP}` +- EDP code is random, regenerated on each preview, finalized at execute +- `MoodleCsvParserService` is not modified — old CSV endpoints still reference it +- Existing `SeedContext` type and `buildSeedContext()` helper remain for old endpoints +- Old CSV-based preview/execute endpoints (`/courses/preview`, `/courses/execute`) left intact — can be deprecated in a future cleanup pass +- `csv-drop-zone.tsx` component left in codebase (may be used by other tabs) — just removed from bulk tab imports +- **Behavioral note**: The new bulk flow derives `startYY`/`endYY` via `ComputeSchoolYears(semesterDigit, startDate, endDate)` while the old CSV flow uses pre-computed values from `buildSeedContext()`. These can diverge for same-year date ranges — this is acceptable since the new flow is the replacement, not a parallel path. +- **Task 2 edge case**: When both `campusId` and `semesterId` are provided to the departments filter, `semesterId` wins silently. This is intentional — the frontend cascade only ever sends one parameter. If both are sent, the semester is the more specific filter and `campusId` is redundant. + +## Review Notes + +- Adversarial review completed with 15 findings +- 10 fixed, 5 acknowledged (noise/low-severity design concerns) +- Resolution approach: auto-fix +- Key fixes: IDOR on categoryId (F1), missing @IsNotEmpty/@Min validators (F2/F14), guard moved after validation (F3), backend duplicate courseCode check (F4), moodleCategoryId zero-check (F6), unit tests added (F8), stable React keys (F10) +- Acknowledged without fix: duplicate date computation (F5 - intentional to avoid cross-module coupling), no filtering on GetSemesters (F11), century rollover (F12), date divergence by design (F13), mutation state reset (F15) diff --git a/src/modules/admin/admin-filters.controller.spec.ts b/src/modules/admin/admin-filters.controller.spec.ts index f2ce2ad..d6000f9 100644 --- a/src/modules/admin/admin-filters.controller.spec.ts +++ b/src/modules/admin/admin-filters.controller.spec.ts @@ -9,6 +9,7 @@ describe('AdminFiltersController', () => { let controller: AdminFiltersController; let filtersService: { GetCampuses: jest.Mock; + GetSemesters: jest.Mock; GetDepartments: jest.Mock; GetPrograms: jest.Mock; GetRoles: jest.Mock; @@ -17,6 +18,7 @@ describe('AdminFiltersController', () => { beforeEach(async () => { filtersService = { GetCampuses: jest.fn().mockResolvedValue([]), + GetSemesters: jest.fn().mockResolvedValue([]), GetDepartments: jest.fn().mockResolvedValue([]), GetPrograms: jest.fn().mockResolvedValue([]), GetRoles: jest.fn().mockReturnValue(Object.values(UserRole)), @@ -59,14 +61,29 @@ describe('AdminFiltersController', () => { const result = await controller.GetDepartments({ campusId: 'c-1' }); - expect(filtersService.GetDepartments).toHaveBeenCalledWith('c-1'); + expect(filtersService.GetDepartments).toHaveBeenCalledWith( + 'c-1', + undefined, + ); expect(result).toEqual(departments); }); it('should pass undefined campusId when not provided', async () => { await controller.GetDepartments({}); - expect(filtersService.GetDepartments).toHaveBeenCalledWith(undefined); + expect(filtersService.GetDepartments).toHaveBeenCalledWith( + undefined, + undefined, + ); + }); + + it('should pass semesterId to the filters service', async () => { + await controller.GetDepartments({ semesterId: 's-1' }); + + expect(filtersService.GetDepartments).toHaveBeenCalledWith( + undefined, + 's-1', + ); }); it('should delegate program listing to the filters service', async () => { diff --git a/src/modules/admin/admin-filters.controller.ts b/src/modules/admin/admin-filters.controller.ts index 2b2b010..aab588b 100644 --- a/src/modules/admin/admin-filters.controller.ts +++ b/src/modules/admin/admin-filters.controller.ts @@ -17,6 +17,7 @@ import { FilterOptionResponseDto } from './dto/responses/filter-option.response. 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'; +import { SemesterFilterResponseDto } from './dto/responses/semester-filter.response.dto'; @ApiTags('Admin') @Controller('admin/filters') @@ -32,6 +33,16 @@ export class AdminFiltersController { return this.filtersService.GetCampuses(); } + @Get('semesters') + @ApiOperation({ + summary: + 'List all semesters with computed date ranges for filter dropdowns', + }) + @ApiResponse({ status: 200, type: [SemesterFilterResponseDto] }) + async GetSemesters(): Promise { + return this.filtersService.GetSemesters(); + } + @Get('departments') @ApiOperation({ summary: 'List departments for filter dropdowns' }) @ApiQuery({ @@ -40,11 +51,17 @@ export class AdminFiltersController { type: String, description: 'Filter by campus UUID', }) + @ApiQuery({ + name: 'semesterId', + required: false, + type: String, + description: 'Filter by semester UUID', + }) @ApiResponse({ status: 200, type: [FilterOptionResponseDto] }) async GetDepartments( @Query() query: FilterDepartmentsQueryDto, ): Promise { - return this.filtersService.GetDepartments(query.campusId); + return this.filtersService.GetDepartments(query.campusId, query.semesterId); } @Get('programs') diff --git a/src/modules/admin/dto/requests/filter-departments-query.dto.ts b/src/modules/admin/dto/requests/filter-departments-query.dto.ts index c975557..10c4f13 100644 --- a/src/modules/admin/dto/requests/filter-departments-query.dto.ts +++ b/src/modules/admin/dto/requests/filter-departments-query.dto.ts @@ -6,4 +6,9 @@ export class FilterDepartmentsQueryDto { @IsUUID() @IsOptional() campusId?: string; + + @ApiPropertyOptional({ description: 'Filter departments by semester UUID' }) + @IsUUID() + @IsOptional() + semesterId?: string; } diff --git a/src/modules/admin/dto/responses/semester-filter.response.dto.ts b/src/modules/admin/dto/responses/semester-filter.response.dto.ts new file mode 100644 index 0000000..5c4ebb6 --- /dev/null +++ b/src/modules/admin/dto/responses/semester-filter.response.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SemesterFilterResponseDto { + @ApiProperty({ description: 'Semester UUID' }) + id: string; + + @ApiProperty({ description: 'Semester code', example: 'S12526' }) + code: string; + + @ApiProperty({ description: 'Semester label', example: 'Semester 1' }) + label: string; + + @ApiProperty({ description: 'Academic year', example: '2025-2026' }) + academicYear: string; + + @ApiProperty({ description: 'Campus code', example: 'UCMN' }) + campusCode: string; + + @ApiProperty({ + description: 'Computed start date (ISO 8601)', + example: '2025-08-01', + }) + startDate: string; + + @ApiProperty({ + description: 'Computed end date (ISO 8601)', + example: '2025-12-18', + }) + endDate: string; +} diff --git a/src/modules/admin/services/admin-filters.service.ts b/src/modules/admin/services/admin-filters.service.ts index 8654d10..d4faa06 100644 --- a/src/modules/admin/services/admin-filters.service.ts +++ b/src/modules/admin/services/admin-filters.service.ts @@ -1,10 +1,11 @@ import { FilterQuery } from '@mikro-orm/core'; import { EntityManager } from '@mikro-orm/postgresql'; -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, 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 { Semester } from 'src/entities/semester.entity'; import { QuestionnaireType } from 'src/entities/questionnaire-type.entity'; import { QuestionnaireVersion } from 'src/entities/questionnaire-version.entity'; import { User } from 'src/entities/user.entity'; @@ -14,9 +15,12 @@ import { FilterOptionResponseDto } from '../dto/responses/filter-option.response 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'; +import { SemesterFilterResponseDto } from '../dto/responses/semester-filter.response.dto'; @Injectable() export class AdminFiltersService { + private readonly logger = new Logger(AdminFiltersService.name); + constructor(private readonly em: EntityManager) {} async GetCampuses(): Promise { @@ -28,9 +32,60 @@ export class AdminFiltersService { return campuses.map((c) => FilterOptionResponseDto.Map(c)); } - async GetDepartments(campusId?: string): Promise { + async GetSemesters(): Promise { + const semesters = await this.em.find( + Semester, + {}, + { populate: ['campus'], orderBy: { code: 'DESC' } }, + ); + + const results: SemesterFilterResponseDto[] = []; + + for (const sem of semesters) { + const match = sem.code.match(/^S([12])(\d{2})(\d{2})$/); + if (!match) { + this.logger.warn( + `Skipping semester with malformed code: "${sem.code}" (id=${sem.id})`, + ); + continue; + } + + const semesterNum = match[1]; + const fullStartYear = '20' + match[2]; + const fullEndYear = '20' + match[3]; + + let startDate: string; + let endDate: string; + if (semesterNum === '1') { + startDate = `${fullStartYear}-08-01`; + endDate = `${fullStartYear}-12-18`; + } else { + startDate = `${fullEndYear}-01-20`; + endDate = `${fullEndYear}-06-01`; + } + + results.push({ + id: sem.id, + code: sem.code, + label: sem.label ?? `Semester ${semesterNum}`, + academicYear: sem.academicYear ?? `${fullStartYear}-${fullEndYear}`, + campusCode: sem.campus.code, + startDate, + endDate, + }); + } + + return results; + } + + async GetDepartments( + campusId?: string, + semesterId?: string, + ): Promise { const filter: FilterQuery = {}; - if (campusId) { + if (semesterId) { + filter.semester = semesterId; + } else if (campusId) { filter.semester = { campus: campusId }; } const departments = await this.em.find(Department, filter, { diff --git a/src/modules/audit/audit-action.enum.ts b/src/modules/audit/audit-action.enum.ts index 1859f50..7ea5af4 100644 --- a/src/modules/audit/audit-action.enum.ts +++ b/src/modules/audit/audit-action.enum.ts @@ -15,6 +15,7 @@ export const AuditAction = { MOODLE_PROVISION_COURSES: 'moodle.provision.courses', MOODLE_PROVISION_QUICK_COURSE: 'moodle.provision.quick-course', MOODLE_PROVISION_USERS: 'moodle.provision.users', + MOODLE_BULK_PROVISION_COURSES: 'moodle.provision.bulk-courses', } as const; export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction]; diff --git a/src/modules/moodle/controllers/moodle-provisioning.controller.ts b/src/modules/moodle/controllers/moodle-provisioning.controller.ts index 2a516f6..b6e09ec 100644 --- a/src/modules/moodle/controllers/moodle-provisioning.controller.ts +++ b/src/modules/moodle/controllers/moodle-provisioning.controller.ts @@ -35,6 +35,8 @@ import { MoodleProvisioningService } from '../services/moodle-provisioning.servi import { ProvisionCategoriesRequestDto } from '../dto/requests/provision-categories.request.dto'; import { SeedCoursesContextDto } from '../dto/requests/seed-courses.request.dto'; import { ExecuteCoursesRequestDto } from '../dto/requests/execute-courses.request.dto'; +import { BulkCoursePreviewRequestDto } from '../dto/requests/bulk-course-preview.request.dto'; +import { BulkCourseExecuteRequestDto } from '../dto/requests/bulk-course-execute.request.dto'; import { QuickCourseRequestDto } from '../dto/requests/quick-course.request.dto'; import { SeedUsersRequestDto } from '../dto/requests/seed-users.request.dto'; import { ProvisionResultDto } from '../dto/responses/provision-result.response.dto'; @@ -192,6 +194,46 @@ export class MoodleProvisioningController { ); } + @Post('courses/bulk/preview') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiBearerAuth() + @ApiOperation({ summary: 'Preview bulk course creation from JSON input' }) + @ApiResponse({ status: 200, type: CoursePreviewResultDto }) + async PreviewBulkCourses( + @Body() dto: BulkCoursePreviewRequestDto, + ): Promise { + return await this.provisioningService.PreviewBulkCourses(dto); + } + + @Post('courses/bulk/execute') + @HttpCode(HttpStatus.OK) + @UseJwtGuard(UserRole.SUPER_ADMIN) + @ApiBearerAuth() + @Audited({ + action: AuditAction.MOODLE_BULK_PROVISION_COURSES, + resource: 'MoodleCourse', + }) + @UseInterceptors( + MetaDataInterceptor, + CurrentUserInterceptor, + AuditInterceptor, + ) + @ApiOperation({ summary: 'Execute bulk course creation in Moodle' }) + @ApiResponse({ status: 200, type: ProvisionResultDto }) + async ExecuteBulkCourses( + @Body() dto: BulkCourseExecuteRequestDto, + ): Promise { + try { + return await this.provisioningService.ExecuteBulkCourses(dto); + } catch (e) { + if (e instanceof MoodleConnectivityError) { + throw new BadGatewayException('Moodle is unreachable'); + } + throw e; + } + } + @Post('courses/quick/preview') @HttpCode(HttpStatus.OK) @UseJwtGuard(UserRole.SUPER_ADMIN) diff --git a/src/modules/moodle/dto/requests/bulk-course-execute.request.dto.ts b/src/modules/moodle/dto/requests/bulk-course-execute.request.dto.ts new file mode 100644 index 0000000..edd9cae --- /dev/null +++ b/src/modules/moodle/dto/requests/bulk-course-execute.request.dto.ts @@ -0,0 +1,79 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsDateString, + IsInt, + IsNotEmpty, + IsString, + IsUUID, + Min, + ArrayNotEmpty, + ArrayMaxSize, + ValidateNested, + Validate, +} from 'class-validator'; +import { IsBeforeEndDate } from '../validators/is-before-end-date.validator'; + +export class ConfirmedCourseEntryDto { + @ApiProperty({ description: 'Course code', example: 'CS101' }) + @IsString() + @IsNotEmpty() + courseCode: string; + + @ApiProperty({ + description: 'Descriptive title', + example: 'Introduction to Computer Science', + }) + @IsString() + @IsNotEmpty() + descriptiveTitle: string; + + @ApiProperty({ + description: 'Moodle category ID from preview', + example: 42, + }) + @IsInt() + @Min(1) + categoryId: number; +} + +export class BulkCourseExecuteRequestDto { + @ApiProperty({ description: 'Semester UUID' }) + @IsUUID() + semesterId: string; + + @ApiProperty({ description: 'Department UUID' }) + @IsUUID() + departmentId: string; + + @ApiProperty({ description: 'Program UUID' }) + @IsUUID() + programId: string; + + @ApiProperty({ + description: 'Course start date (ISO 8601)', + example: '2025-08-01', + }) + @IsDateString() + @Validate(IsBeforeEndDate) + startDate: string; + + @ApiProperty({ + description: 'Course end date (ISO 8601)', + example: '2025-12-18', + }) + @IsDateString() + endDate: string; + + @ApiProperty({ + type: [ConfirmedCourseEntryDto], + description: 'Confirmed courses to create', + }) + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(500) + @ValidateNested({ each: true }) + @Type(() => ConfirmedCourseEntryDto) + courses: ConfirmedCourseEntryDto[]; +} diff --git a/src/modules/moodle/dto/requests/bulk-course-preview.request.dto.ts b/src/modules/moodle/dto/requests/bulk-course-preview.request.dto.ts new file mode 100644 index 0000000..51e0941 --- /dev/null +++ b/src/modules/moodle/dto/requests/bulk-course-preview.request.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsDateString, + IsNotEmpty, + IsString, + IsUUID, + ArrayNotEmpty, + ArrayMaxSize, + ValidateNested, + Validate, +} from 'class-validator'; +import { IsBeforeEndDate } from '../validators/is-before-end-date.validator'; + +export class CourseEntryDto { + @ApiProperty({ description: 'Course code', example: 'CS101' }) + @IsString() + @IsNotEmpty() + courseCode: string; + + @ApiProperty({ + description: 'Descriptive title', + example: 'Introduction to Computer Science', + }) + @IsString() + @IsNotEmpty() + descriptiveTitle: string; +} + +export class BulkCoursePreviewRequestDto { + @ApiProperty({ description: 'Semester UUID' }) + @IsUUID() + semesterId: string; + + @ApiProperty({ description: 'Department UUID' }) + @IsUUID() + departmentId: string; + + @ApiProperty({ description: 'Program UUID' }) + @IsUUID() + programId: string; + + @ApiProperty({ + description: 'Course start date (ISO 8601)', + example: '2025-08-01', + }) + @IsDateString() + @Validate(IsBeforeEndDate) + startDate: string; + + @ApiProperty({ + description: 'Course end date (ISO 8601)', + example: '2025-12-18', + }) + @IsDateString() + endDate: string; + + @ApiProperty({ type: [CourseEntryDto], description: 'Courses to preview' }) + @IsArray() + @ArrayNotEmpty() + @ArrayMaxSize(500) + @ValidateNested({ each: true }) + @Type(() => CourseEntryDto) + courses: CourseEntryDto[]; +} diff --git a/src/modules/moodle/services/moodle-provisioning.service.spec.ts b/src/modules/moodle/services/moodle-provisioning.service.spec.ts index a776f50..1337a2e 100644 --- a/src/modules/moodle/services/moodle-provisioning.service.spec.ts +++ b/src/modules/moodle/services/moodle-provisioning.service.spec.ts @@ -810,4 +810,219 @@ describe('MoodleProvisioningService', () => { await first; }); }); + + describe('PreviewBulkCourses', () => { + const mockProgram = { + id: 'prog-1', + code: 'BSIT', + moodleCategoryId: 42, + department: { + id: 'dept-1', + code: 'CCS', + semester: { + id: 'sem-1', + code: 'S12526', + campus: { id: 'campus-1', code: 'UCMN' }, + }, + }, + }; + + const baseDto = { + semesterId: 'sem-1', + departmentId: 'dept-1', + programId: 'prog-1', + startDate: '2025-08-01', + endDate: '2025-12-18', + courses: [ + { courseCode: 'CS101', descriptiveTitle: 'Intro to CS' }, + { courseCode: 'CS102', descriptiveTitle: 'Data Structures' }, + ], + }; + + it('should generate preview rows with correct shortnames and categoryPath', async () => { + em.findOne.mockResolvedValue(mockProgram); + + const result = await service.PreviewBulkCourses(baseDto); + + expect(result.valid).toHaveLength(2); + expect(result.skipped).toEqual([]); + expect(result.errors).toEqual([]); + expect(result.valid[0].fullname).toBe('Intro to CS'); + expect(result.valid[0].categoryId).toBe(42); + expect(result.valid[0].categoryPath).toContain('UCMN'); + expect(result.valid[0].categoryPath).toContain('CCS'); + expect(result.valid[0].categoryPath).toContain('BSIT'); + expect(result.valid[0].shortname).toContain('UCMN'); + expect(result.valid[0].courseCode).toBe('CS101'); + }); + + it('should throw BadRequestException when program not found', async () => { + em.findOne.mockResolvedValue(null); + + await expect(service.PreviewBulkCourses(baseDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException for mismatched departmentId', async () => { + em.findOne.mockResolvedValue(mockProgram); + + await expect( + service.PreviewBulkCourses({ ...baseDto, departmentId: 'wrong-dept' }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for mismatched semesterId', async () => { + em.findOne.mockResolvedValue(mockProgram); + + await expect( + service.PreviewBulkCourses({ ...baseDto, semesterId: 'wrong-sem' }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException for malformed semester code', async () => { + const badProgram = { + ...mockProgram, + department: { + ...mockProgram.department, + semester: { + ...mockProgram.department.semester, + code: 'INVALID', + }, + }, + }; + em.findOne.mockResolvedValue(badProgram); + + await expect(service.PreviewBulkCourses(baseDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException for unprovisioned category', async () => { + em.findOne.mockResolvedValue({ ...mockProgram, moodleCategoryId: 0 }); + + await expect(service.PreviewBulkCourses(baseDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException for duplicate course codes', async () => { + em.findOne.mockResolvedValue(mockProgram); + + await expect( + service.PreviewBulkCourses({ + ...baseDto, + courses: [ + { courseCode: 'CS101', descriptiveTitle: 'A' }, + { courseCode: 'CS101', descriptiveTitle: 'B' }, + ], + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('ExecuteBulkCourses', () => { + const mockProgram = { + id: 'prog-1', + code: 'BSIT', + moodleCategoryId: 42, + department: { + id: 'dept-1', + code: 'CCS', + semester: { + id: 'sem-1', + code: 'S12526', + campus: { id: 'campus-1', code: 'UCMN' }, + }, + }, + }; + + const baseDto = { + semesterId: 'sem-1', + departmentId: 'dept-1', + programId: 'prog-1', + startDate: '2025-08-01', + endDate: '2025-12-18', + courses: [ + { + courseCode: 'CS101', + descriptiveTitle: 'Intro to CS', + categoryId: 42, + }, + ], + }; + + it('should create courses and return result', async () => { + em.findOne.mockResolvedValue(mockProgram); + moodleService.CreateCourses.mockResolvedValue([ + { id: 1001, shortname: 'UCMN-S12526-CS101-00001' }, + ]); + + const result = await service.ExecuteBulkCourses(baseDto); + + expect(result.created).toBe(1); + expect(result.errors).toBe(0); + expect(result.details[0].status).toBe('created'); + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(moodleService.CreateCourses).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + categoryid: 42, + fullname: 'Intro to CS', + }), + ]), + ); + }); + + it('should throw BadRequestException when program not found', async () => { + em.findOne.mockResolvedValue(null); + + await expect(service.ExecuteBulkCourses(baseDto)).rejects.toThrow( + BadRequestException, + ); + }); + + it('should throw BadRequestException for mismatched hierarchy', async () => { + em.findOne.mockResolvedValue(mockProgram); + + await expect( + service.ExecuteBulkCourses({ ...baseDto, departmentId: 'wrong' }), + ).rejects.toThrow(BadRequestException); + }); + + it('should use program.moodleCategoryId, not client-supplied categoryId', async () => { + em.findOne.mockResolvedValue(mockProgram); + moodleService.CreateCourses.mockResolvedValue([ + { id: 1001, shortname: 'test' }, + ]); + + await service.ExecuteBulkCourses({ + ...baseDto, + courses: [ + { courseCode: 'CS101', descriptiveTitle: 'A', categoryId: 999 }, + ], + }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(moodleService.CreateCourses).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ categoryid: 42 })]), + ); + }); + + it('should release guard after error', async () => { + em.findOne.mockResolvedValue(mockProgram); + moodleService.CreateCourses.mockRejectedValue(new Error('Moodle down')); + + const result = await service.ExecuteBulkCourses(baseDto); + + expect(result.errors).toBe(1); + // Guard should be released -- second call should not throw ConflictException + em.findOne.mockResolvedValue(mockProgram); + moodleService.CreateCourses.mockResolvedValue([ + { id: 1, shortname: 'x' }, + ]); + const result2 = await service.ExecuteBulkCourses(baseDto); + expect(result2.created).toBe(1); + }); + }); }); diff --git a/src/modules/moodle/services/moodle-provisioning.service.ts b/src/modules/moodle/services/moodle-provisioning.service.ts index b887c59..86e1ee5 100644 --- a/src/modules/moodle/services/moodle-provisioning.service.ts +++ b/src/modules/moodle/services/moodle-provisioning.service.ts @@ -34,6 +34,8 @@ import { CoursePreviewRow, SeedUserRecord, } from '../lib/provisioning.types'; +import { BulkCoursePreviewRequestDto } from '../dto/requests/bulk-course-preview.request.dto'; +import { BulkCourseExecuteRequestDto } from '../dto/requests/bulk-course-execute.request.dto'; @Injectable() export class MoodleProvisioningService { @@ -711,6 +713,198 @@ export class MoodleProvisioningService { } } + async PreviewBulkCourses( + dto: BulkCoursePreviewRequestDto, + ): Promise { + const program = await this.em.findOne(Program, dto.programId, { + populate: ['department.semester.campus'], + }); + + if (!program) { + throw new BadRequestException('Program not found'); + } + + if (program.department.id !== dto.departmentId) { + throw new BadRequestException( + 'Program does not belong to the specified department', + ); + } + + if (program.department.semester.id !== dto.semesterId) { + throw new BadRequestException( + 'Program does not belong to the specified semester', + ); + } + + const campusCode = program.department.semester.campus.code; + const semesterCode = program.department.semester.code; + const deptCode = program.department.code; + const programCode = program.code; + + const match = semesterCode.match(/^S([12])(\d{2})(\d{2})$/); + if (!match) { + throw new BadRequestException( + `Invalid semester code format: ${semesterCode}`, + ); + } + + if (!program.moodleCategoryId) { + throw new BadRequestException( + `Category not provisioned for program ${programCode}. Provision categories first.`, + ); + } + + // Check for duplicate course codes within the batch + const codes = dto.courses.map((c) => c.courseCode.trim().toUpperCase()); + const seen = new Set(); + const dupes: string[] = []; + for (const code of codes) { + if (seen.has(code)) dupes.push(code); + seen.add(code); + } + if (dupes.length > 0) { + throw new BadRequestException( + `Duplicate course codes: ${[...new Set(dupes)].join(', ')}`, + ); + } + + const semesterDigit = Number(match[1]); + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + semesterDigit, + dto.startDate, + dto.endDate, + ); + + const valid: CoursePreviewRow[] = dto.courses.map((course) => ({ + shortname: this.transformService.GenerateShortname( + campusCode, + String(semesterDigit), + startYY, + endYY, + course.courseCode, + ), + fullname: course.descriptiveTitle, + categoryPath: this.transformService.BuildCategoryPath( + campusCode, + String(semesterDigit), + deptCode, + programCode, + startYY, + endYY, + ), + categoryId: program.moodleCategoryId, + startDate: dto.startDate, + endDate: dto.endDate, + program: programCode, + semester: String(semesterDigit), + courseCode: course.courseCode, + })); + + return { + valid, + skipped: [], + errors: [], + shortnameNote: + 'EDP codes are examples. Final codes are generated at execution time.', + }; + } + + async ExecuteBulkCourses( + dto: BulkCourseExecuteRequestDto, + ): Promise { + // Validate before acquiring guard (F1, F3) + const program = await this.em.findOne(Program, dto.programId, { + populate: ['department.semester.campus'], + }); + + if (!program) { + throw new BadRequestException('Program not found'); + } + + if (program.department.id !== dto.departmentId) { + throw new BadRequestException( + 'Program does not belong to the specified department', + ); + } + + if (program.department.semester.id !== dto.semesterId) { + throw new BadRequestException( + 'Program does not belong to the specified semester', + ); + } + + const campusCode = program.department.semester.campus.code; + const semesterCode = program.department.semester.code; + + const match = semesterCode.match(/^S([12])(\d{2})(\d{2})$/); + if (!match) { + throw new BadRequestException( + `Invalid semester code format: ${semesterCode}`, + ); + } + + const semesterDigit = Number(match[1]); + const { startYY, endYY } = this.transformService.ComputeSchoolYears( + semesterDigit, + dto.startDate, + dto.endDate, + ); + + // Acquire guard only after all validation passes + this.acquireGuard('courses'); + const start = Date.now(); + const details: ProvisionDetailItem[] = []; + + try { + const courseInputs = dto.courses.map((course) => ({ + shortname: this.transformService.GenerateShortname( + campusCode, + String(semesterDigit), + startYY, + endYY, + course.courseCode, + ), + fullname: course.descriptiveTitle, + categoryid: program.moodleCategoryId, + startdate: Math.floor(new Date(dto.startDate).getTime() / 1000), + enddate: Math.floor(new Date(dto.endDate).getTime() / 1000), + })); + + for ( + let i = 0; + i < courseInputs.length; + i += MOODLE_PROVISION_BATCH_SIZE + ) { + const batch = courseInputs.slice(i, i + MOODLE_PROVISION_BATCH_SIZE); + try { + const results = await this.moodleService.CreateCourses(batch); + for (const r of results) { + details.push({ + name: r.shortname, + status: 'created', + moodleId: r.id, + }); + } + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + for (const item of batch) { + details.push({ name: item.shortname, status: 'error', reason }); + } + } + } + + return { + created: details.filter((d) => d.status === 'created').length, + skipped: 0, + errors: details.filter((d) => d.status === 'error').length, + details, + durationMs: Date.now() - start, + }; + } finally { + this.releaseGuard('courses'); + } + } + async GetCategoryTree(): Promise { const flat = await this.moodleService.GetCategoriesWithMasterKey(); From 44d5711a93f23c493da679a3d375cd118721fe81 Mon Sep 17 00:00:00 2001 From: Leander Lubguban <113151776+y4nder@users.noreply.github.com> Date: Sun, 12 Apr 2026 03:22:42 +0800 Subject: [PATCH 6/6] 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 --- ...ch-spec-enhance-seed-users-provision-ux.md | 429 ++++++++++++++++++ .../admin/admin-filters.controller.spec.ts | 10 +- src/modules/admin/admin-filters.controller.ts | 5 +- .../program-filter-option.response.dto.ts | 29 ++ .../services/admin-filters.service.spec.ts | 70 +++ .../admin/services/admin-filters.service.ts | 7 +- 6 files changed, 545 insertions(+), 5 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md create mode 100644 src/modules/admin/dto/responses/program-filter-option.response.dto.ts create mode 100644 src/modules/admin/services/admin-filters.service.spec.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**: `