From e45baa420b9abf6a3906cddb67a340ea9a9f4b72 Mon Sep 17 00:00:00 2001 From: y4nder Date: Sun, 12 Apr 2026 03:41:38 +0800 Subject: [PATCH] [STAGING] FAC-116 to FAC-121 feat: Moodle seeding toolkit, tree explorer, audit trail, semester fix, bulk course provisioning, program filter enhancements (#289) Cherry-picked from staging (4ccbc4b) with import conflicts resolved. --- ...ch-spec-enhance-seed-users-provision-ux.md | 429 +++++++ ...-spec-fix-moodle-semester-category-bugs.md | 566 +++++++++ ...ech-spec-moodle-course-bulk-enhancement.md | 614 ++++++++++ .../tech-spec-moodle-seeding-toolkit.md | 661 +++++++++++ .../tech-spec-moodle-tree-explorer.md | 574 +++++++++ _bmad/_config/agent-manifest.csv | 1 + .../bmm-moodle-integrator.customize.yaml | 41 + _bmad/bmm/agents/moodle-integrator.md | 103 ++ package-lock.json | 17 + package.json | 3 +- src/configurations/env/moodle.env.ts | 2 + .../admin/admin-filters.controller.spec.ts | 31 +- src/modules/admin/admin-filters.controller.ts | 24 +- .../requests/filter-departments-query.dto.ts | 5 + .../program-filter-option.response.dto.ts | 29 + .../responses/semester-filter.response.dto.ts | 30 + .../services/admin-filters.service.spec.ts | 70 ++ .../admin/services/admin-filters.service.ts | 68 +- src/modules/audit/audit-action.enum.ts | 5 + src/modules/audit/audit-query.service.spec.ts | 360 ++++++ src/modules/audit/audit-query.service.ts | 104 ++ src/modules/audit/audit.controller.spec.ts | 68 ++ src/modules/audit/audit.controller.ts | 117 ++ src/modules/audit/audit.module.ts | 13 +- .../dto/requests/list-audit-logs-query.dto.ts | 81 ++ .../audit-log-detail.response.dto.ts | 53 + .../responses/audit-log-item.response.dto.ts | 53 + .../responses/audit-log-list.response.dto.ts | 11 + .../moodle-provisioning.controller.spec.ts | 255 ++++ .../moodle-provisioning.controller.ts | 360 ++++++ .../bulk-course-execute.request.dto.ts | 79 ++ .../bulk-course-preview.request.dto.ts | 66 ++ .../requests/execute-courses.request.dto.ts | 66 ++ .../provision-categories.request.dto.ts | 73 ++ .../dto/requests/quick-course.request.dto.ts | 48 + .../dto/requests/seed-courses.request.dto.ts | 28 + .../dto/requests/seed-users.request.dto.ts | 44 + .../responses/course-preview.response.dto.ts | 41 + .../moodle-course-preview.response.dto.ts | 52 + .../dto/responses/moodle-tree.response.dto.ts | 47 + .../provision-result.response.dto.ts | 35 + .../seed-users-result.response.dto.ts | 18 + .../is-before-end-date.validator.ts | 17 + src/modules/moodle/lib/moodle.client.spec.ts | 48 + src/modules/moodle/lib/moodle.client.ts | 75 +- src/modules/moodle/lib/moodle.constants.ts | 4 + src/modules/moodle/lib/moodle.types.ts | 55 + src/modules/moodle/lib/provisioning.types.ts | 115 ++ src/modules/moodle/moodle.module.ts | 10 +- src/modules/moodle/moodle.service.ts | 55 +- .../moodle-course-transform.service.spec.ts | 228 ++++ .../moodle-course-transform.service.ts | 164 +++ .../moodle-csv-parser.service.spec.ts | 80 ++ .../services/moodle-csv-parser.service.ts | 107 ++ .../moodle-provisioning.service.spec.ts | 1028 +++++++++++++++++ .../services/moodle-provisioning.service.ts | 994 ++++++++++++++++ 56 files changed, 8307 insertions(+), 18 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md create mode 100644 _bmad-output/implementation-artifacts/tech-spec-fix-moodle-semester-category-bugs.md create mode 100644 _bmad-output/implementation-artifacts/tech-spec-moodle-course-bulk-enhancement.md create mode 100644 _bmad-output/implementation-artifacts/tech-spec-moodle-seeding-toolkit.md create mode 100644 _bmad-output/implementation-artifacts/tech-spec-moodle-tree-explorer.md create mode 100644 _bmad/_config/agents/bmm-moodle-integrator.customize.yaml create mode 100644 _bmad/bmm/agents/moodle-integrator.md create mode 100644 src/modules/admin/dto/responses/program-filter-option.response.dto.ts create mode 100644 src/modules/admin/dto/responses/semester-filter.response.dto.ts create mode 100644 src/modules/admin/services/admin-filters.service.spec.ts create mode 100644 src/modules/audit/audit-query.service.spec.ts create mode 100644 src/modules/audit/audit-query.service.ts create mode 100644 src/modules/audit/audit.controller.spec.ts create mode 100644 src/modules/audit/audit.controller.ts create mode 100644 src/modules/audit/dto/requests/list-audit-logs-query.dto.ts create mode 100644 src/modules/audit/dto/responses/audit-log-detail.response.dto.ts create mode 100644 src/modules/audit/dto/responses/audit-log-item.response.dto.ts create mode 100644 src/modules/audit/dto/responses/audit-log-list.response.dto.ts create mode 100644 src/modules/moodle/controllers/moodle-provisioning.controller.spec.ts create mode 100644 src/modules/moodle/controllers/moodle-provisioning.controller.ts create mode 100644 src/modules/moodle/dto/requests/bulk-course-execute.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/bulk-course-preview.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/execute-courses.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/provision-categories.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/quick-course.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/seed-courses.request.dto.ts create mode 100644 src/modules/moodle/dto/requests/seed-users.request.dto.ts create mode 100644 src/modules/moodle/dto/responses/course-preview.response.dto.ts create mode 100644 src/modules/moodle/dto/responses/moodle-course-preview.response.dto.ts create mode 100644 src/modules/moodle/dto/responses/moodle-tree.response.dto.ts create mode 100644 src/modules/moodle/dto/responses/provision-result.response.dto.ts create mode 100644 src/modules/moodle/dto/responses/seed-users-result.response.dto.ts create mode 100644 src/modules/moodle/dto/validators/is-before-end-date.validator.ts create mode 100644 src/modules/moodle/lib/moodle.client.spec.ts create mode 100644 src/modules/moodle/lib/provisioning.types.ts create mode 100644 src/modules/moodle/services/moodle-course-transform.service.spec.ts create mode 100644 src/modules/moodle/services/moodle-course-transform.service.ts create mode 100644 src/modules/moodle/services/moodle-csv-parser.service.spec.ts create mode 100644 src/modules/moodle/services/moodle-csv-parser.service.ts create mode 100644 src/modules/moodle/services/moodle-provisioning.service.spec.ts create mode 100644 src/modules/moodle/services/moodle-provisioning.service.ts diff --git a/_bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md b/_bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md new file mode 100644 index 0000000..706063a --- /dev/null +++ b/_bmad-output/implementation-artifacts/tech-spec-enhance-seed-users-provision-ux.md @@ -0,0 +1,429 @@ +--- +title: 'Enhance Seed Users Provision UX' +slug: 'enhance-seed-users-provision-ux' +created: '2026-04-12' +status: 'ready-for-dev' +stepsCompleted: [1, 2, 3, 4] +tech_stack: + [ + 'React 19', + 'Vite', + 'TanStack Query', + 'shadcn/ui', + 'Radix Select', + 'Tailwind 4', + 'NestJS 11', + 'MikroORM 6', + 'Zod', + ] +files_to_modify: + - 'api: src/modules/admin/dto/responses/program-filter-option.response.dto.ts (NEW)' + - 'api: src/modules/admin/services/admin-filters.service.ts' + - 'api: src/modules/admin/services/admin-filters.service.spec.ts (NEW)' + - 'api: src/modules/admin/admin-filters.controller.ts' + - 'api: src/modules/admin/admin-filters.controller.spec.ts' + - 'admin: src/features/moodle-provision/components/seed-users-tab.tsx' + - 'admin: src/features/moodle-provision/use-seed-users.ts' + - 'admin: src/features/moodle-provision/use-programs-by-department.ts' + - 'admin: src/features/moodle-provision/provision-page.tsx' + - 'admin: src/types/api.ts' +code_patterns: + - 'Cascading dropdowns: useSemesters → useDepartmentsBySemester → useProgramsByDepartment with reset-on-change' + - 'View state machine: type View = "input" | "preview"' + - 'Category course fetch: useCategoryCourses(categoryId) with keepPreviousData' + - 'Checkbox selection: checked Set with toggleRow/toggleAll' + - 'onBrowse prop pattern for MoodleTreeSheet integration' + - 'Standalone dedicated DTOs per entity (SemesterFilterResponseDto pattern): flat class, own @ApiProperty decorators, static mapper' + - 'PascalCase public service methods' + - 'Swagger decorators on all DTO properties' +test_patterns: + - 'Jest with NestJS TestingModule' + - 'Controller spec mocks service methods as jest.fn()' + - 'admin-filters.controller.spec.ts exists; no service spec for admin-filters (service spec added in this work)' +--- + +# Tech-Spec: Enhance Seed Users Provision UX + +**Created:** 2026-04-12 + +## Overview + +### Problem Statement + +The Seed Users tab in the admin Moodle provisioning feature has a bare-bones UX compared to the recently enhanced Bulk Course Insert tab. Users must type raw comma-separated Moodle course IDs into a text input, pick campus from a static dropdown with no relationship to course selection, and execute with only a basic AlertDialog confirmation. There is no visual course selection, no preview step, and results are displayed as inline badges. This is friction-heavy, error-prone, and inconsistent with the improved UX patterns established in the bulk course flow. + +### Solution + +Rebuild the Seed Users tab with cascading dropdowns (Semester > Department > Program) that scope the user to a specific Moodle category, a visual course picker table fetched from the Moodle category tree API, a client-side preview/confirm step showing exactly what will happen, and a dedicated result panel. One API change: create a standalone `ProgramFilterOptionResponseDto` (same pattern as `SemesterFilterResponseDto`) that includes `moodleCategoryId`, keeping `FilterOptionResponseDto` untouched. Retain a small "Add by ID" escape hatch for power users. + +### Scope + +**In Scope:** + +- `admin.faculytics` — Rewrite `seed-users-tab.tsx` with cascading dropdowns, course picker table, preview view, and result panel +- `admin.faculytics` — Wire `onBrowse` prop to `SeedUsersTab` in `provision-page.tsx` +- `admin.faculytics` — Add `ProgramFilterOption` type in `api.ts`; update `useProgramsByDepartment` return type +- `admin.faculytics` — Remove `onSuccess` toast from `useSeedUsers` hook (result panel replaces it) +- `api.faculytics` — Create standalone `ProgramFilterOptionResponseDto` with `moodleCategoryId` +- Small "Add by ID" input for manual course entry as an escape hatch + +**Out of Scope:** + +- New backend preview endpoint (client-side preview only for this iteration) +- Multi-program selection (single program per operation; run twice for cross-program seeding) +- Changes to the `POST /moodle/provision/users` API request/response contract +- Changes to other provisioning tabs (categories, bulk courses, quick course) +- Changes to `FilterOptionResponseDto` — it stays untouched to avoid polluting campus/department responses + +## Context for Development + +### Codebase Patterns + +**Cascading Dropdowns (established in `courses-bulk-tab.tsx`):** + +- Three hooks chained: `useSemesters()` → `useDepartmentsBySemester(semesterId)` → `useProgramsByDepartment(departmentId)` +- Each hook uses `useQuery` with `enabled: !!parentId && isAuth` for conditional fetching +- Semester change resets department + program; department change resets program +- Semester selection auto-fills `startDate`/`endDate` from `SemesterFilterOption` and derives `campusCode` +- All hooks depend on `activeEnvId` from `useEnvStore` and `isAuthenticated` from `useAuthStore` + +**Category Course Fetching:** + +- `useCategoryCourses(categoryId: number | null)` fetches `GET /moodle/provision/tree/:categoryId/courses` +- Returns `MoodleCategoryCoursesResponse { categoryId, courses: MoodleCoursePreview[] }` +- `MoodleCoursePreview` has: `id`, `shortname`, `fullname`, `enrolledusercount?`, `visible`, `startdate`, `enddate` +- Uses `keepPreviousData` and 3-minute stale time +- **Important**: The hook uses `keepPreviousData`, meaning stale data from the previous category persists during fetch transitions. Components must snapshot courses into local state and eagerly clear the snapshot on program change to prevent showing stale courses with a new program label. + +**View State Machine:** + +- `type View = 'input' | 'preview'` pattern used by bulk courses +- Input view: form with cascade + data entry +- Preview view: read-only summary + execute button + back button +- Result shown inline after execution within the preview view + +**Checkbox Selection:** + +- `checked` as `Set` (indices into a **stable local snapshot**, not the live query data) +- `toggleRow(idx)` and `toggleAll()` handlers +- Select-all checkbox in table header + +**Standalone Dedicated Filter DTO Pattern (precedent: `SemesterFilterResponseDto`):** + +- When an entity needs fields beyond `{ id, code, name }`, a **standalone flat DTO class** is created with its own `@ApiProperty` decorators and static mapper +- `SemesterFilterResponseDto` is a standalone class — it does NOT extend `FilterOptionResponseDto`. It has its own `id`, `code`, `label`, `academicYear`, `campusCode`, `startDate`, `endDate` properties and decorators +- `FilterOptionResponseDto` stays unchanged for campuses and departments +- New `ProgramFilterOptionResponseDto` follows this exact same pattern: standalone flat class, own decorators, own `MapProgram()` static mapper +- **Why standalone**: NestJS Swagger metadata scanner relies on class prototypes. Spreading a plain object from `FilterOptionResponseDto.Map()` into a return value strips the prototype, making `@ApiProperty` decorators invisible. Standalone classes avoid this entirely. + +**Seed Users API (unchanged):** + +- `POST /moodle/provision/users` accepts `{ count, role, campus, courseIds }` +- `campus` is a plain string (e.g., `'UCMN'`) — the API calls `.toLowerCase()` internally in `GenerateFakeUser()` +- API generates users via `GenerateFakeUser(campus, role)`: + - Student username: `campus-YYMMDD####` (date + 4-digit random) + - Faculty username: `campus-t-#####` (5-digit random) + - Email: `username@faculytics.seed`, password: `User123#` +- Enrollments use Moodle role IDs from env: `MOODLE_ROLE_ID_STUDENT` / `MOODLE_ROLE_ID_EDITING_TEACHER` +- Batch size: 50 (users and enrolments) +- Operation guard prevents concurrent seed operations + +**`useSeedUsers` Hook Behavior:** + +- The hook defines both `onSuccess` (toast) and `onError` (409 check + generic toast) callbacks at the hook level +- TanStack Query executes hook-level AND component-level `onSuccess` callbacks. With the new result panel as primary success feedback, the hook-level `onSuccess` toast must be removed to avoid double feedback. + +### Files to Reference + +| File | Purpose | +| ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `admin: src/features/moodle-provision/components/seed-users-tab.tsx` | **PRIMARY TARGET** — current seed users component to rewrite | +| `admin: src/features/moodle-provision/components/courses-bulk-tab.tsx` | Reference implementation for cascading dropdowns + preview pattern | +| `admin: src/features/moodle-provision/provision-page.tsx` | Tab orchestrator — needs `onBrowse` wired to SeedUsersTab | +| `admin: src/features/moodle-provision/use-seed-users.ts` | Mutation hook — remove `onSuccess` toast (result panel replaces it) | +| `admin: src/features/moodle-provision/use-semesters.ts` | Cascade level 1 hook — reuse as-is | +| `admin: src/features/moodle-provision/use-departments-by-semester.ts` | Cascade level 2 hook — reuse as-is | +| `admin: src/features/moodle-provision/use-programs-by-department.ts` | Cascade level 3 hook — return type changes to `ProgramFilterOption[]` | +| `admin: src/features/moodle-provision/use-category-courses.ts` | Course fetcher by category — reuse as-is | +| `admin: src/features/moodle-provision/components/moodle-tree-sheet.tsx` | Browse existing tree — wired via `onBrowse` prop | +| `admin: src/types/api.ts` | Add `ProgramFilterOption` type with required `moodleCategoryId: number` | +| `admin: src/features/admin/use-admin-filters.ts` | **NOT MODIFIED** — contains `usePrograms()` typed as `FilterOption[]`. This is a separate consumer for admin user management and does not need `moodleCategoryId`. The type divergence is intentional (structural typing makes it safe at runtime). | +| `api: src/modules/admin/dto/responses/filter-option.response.dto.ts` | **UNCHANGED** — existing DTO stays clean | +| `api: src/modules/admin/dto/responses/program-filter-option.response.dto.ts` | **NEW** — standalone flat class with `moodleCategoryId` | +| `api: src/modules/admin/dto/responses/semester-filter.response.dto.ts` | **Pattern reference** — standalone flat DTO with own decorators and mapper | +| `api: src/modules/admin/services/admin-filters.service.ts` | `GetPrograms()` — return type changes to `ProgramFilterOptionResponseDto[]` | +| `api: src/modules/admin/admin-filters.controller.ts` | Programs endpoint — return type annotation changes | +| `api: src/modules/admin/admin-filters.controller.spec.ts` | Controller test — needs updated mock and assertion | +| `api: src/entities/program.entity.ts` | Source of truth: `moodleCategoryId` is a required `number` field on `Program` | +| `api: src/modules/moodle/services/moodle-provisioning.service.ts:608-714` | `SeedUsers()` method — unchanged, for reference | + +### Technical Decisions + +- **Standalone `ProgramFilterOptionResponseDto` (not extending `FilterOptionResponseDto`)**: NestJS Swagger metadata scanner relies on class prototypes. Spreading a plain object from `Map()` strips the prototype, making `@ApiProperty` decorators invisible. `SemesterFilterResponseDto` uses a standalone flat class — `ProgramFilterOptionResponseDto` follows the same pattern. (Adversarial review R1-F1, R2-F1, R2-F2) +- **Client-side preview over server-side**: The course picker already shows live Moodle data (fetched via `useCategoryCourses`), so the preview is a confirmation of user selections, not a validation step. If server-side preview is needed later, the view structure supports swapping the data source. +- **Course list snapshot for stable checkbox indices**: The `useCategoryCourses` hook uses `keepPreviousData`, meaning stale data persists during transitions. Indexing `checked: Set` directly into live query data creates a race condition. Snapshot courses into `useState` on load, clear eagerly on program change via `handleProgramChange`. (Adversarial review R1-F7, R2-F4, R2-F5) +- **Eager clearing on program change**: A dedicated `handleProgramChange` handler clears `courseSnapshot` and `checked` immediately, before the async fetch completes. This prevents stale courses from appearing under a new program label during the `keepPreviousData` transition. (Adversarial review R2-F5) +- **Remove hook-level success toast**: `useSeedUsers` defines `onSuccess: toast.success(...)` at hook level. TanStack Query fires both hook-level and component-level `onSuccess`. With the result panel as primary success feedback, the hook toast causes double feedback. Remove it. (Adversarial review R2-F3) +- **Deduplication via `Set`**: All course IDs (from picker + manual input) are merged into a `new Set()` before submission to prevent duplicate enrollments. (Adversarial review R1-F3) +- **Campus sent as uppercase**: `SemesterFilterOption.campusCode` is uppercase (e.g., `'UCMN'`). Send it as-is to the API — the API's `GenerateFakeUser()` calls `.toLowerCase()` internally. Matching the current behavior avoids any contract ambiguity. (Adversarial review R1-F2) +- **Single-program scoping**: Matches the Rust script's directory-based scoping (`enrolments/ucmn/ccs/bscs/`). The "Add by ID" escape hatch covers cross-program edge cases. +- **Dedicated result panel over shared dialog**: `SeedUsersResponse` (`usersCreated`, `usersFailed`, `enrolmentsCreated`, `warnings[]`, `durationMs`) doesn't map to `ProvisionResultResponse` (`created`, `skipped`, `errors`, `details[]`, `durationMs`). An inline panel avoids conditional rendering complexity. +- **`mutation.reset()` on form reset**: TanStack Query mutations hold internal state (`isSuccess`, `data`, `error`). The reset handler must call `mutation.reset()` to clear stale mutation state before the user starts a new form session. (Adversarial review R2-F12) + +## Implementation Plan + +### Tasks + +#### Task 1: Create `ProgramFilterOptionResponseDto` + +- File: `api: src/modules/admin/dto/responses/program-filter-option.response.dto.ts` **(NEW)** +- Action: + 1. Create a **standalone flat DTO class** `ProgramFilterOptionResponseDto` (do NOT extend `FilterOptionResponseDto`) + 2. Add properties with Swagger decorators: + - `@ApiProperty()` `id: string` + - `@ApiProperty()` `code: string` + - `@ApiPropertyOptional({ nullable: true })` `name: string | null` + - `@ApiProperty({ description: 'Moodle category ID for this program' })` `moodleCategoryId: number` + 3. Add a static mapper method: + ```typescript + static MapProgram(entity: { + id: string; + code: string; + name?: string; + moodleCategoryId: number; + }): ProgramFilterOptionResponseDto { + const dto = new ProgramFilterOptionResponseDto(); + dto.id = entity.id; + dto.code = entity.code; + dto.name = entity.name ?? null; + dto.moodleCategoryId = entity.moodleCategoryId; + return dto; + } + ``` +- Notes: `FilterOptionResponseDto` is **not modified**. This follows the exact same pattern as `SemesterFilterResponseDto` — standalone flat class, own decorators, own mapper. Do NOT use extends or spread. + +#### Task 2: Use `ProgramFilterOptionResponseDto` in `GetPrograms()` + +- **Depends on**: Task 1 +- File: `api: src/modules/admin/services/admin-filters.service.ts` +- Action: + 1. Import `ProgramFilterOptionResponseDto` + 2. Change `GetPrograms()` return type from `Promise` to `Promise` + 3. Change the mapper call from `FilterOptionResponseDto.Map(p)` to `ProgramFilterOptionResponseDto.MapProgram(p)` — the `Program` entity already has `moodleCategoryId` loaded from `em.find()` + +#### Task 3: Add service-level test for `GetPrograms()` mapping + +- **Depends on**: Task 1, Task 2 +- File: `api: src/modules/admin/services/admin-filters.service.spec.ts` **(NEW)** +- Action: + 1. Create a new spec file for `AdminFiltersService` + 2. Add a test: `'GetPrograms should map moodleCategoryId via ProgramFilterOptionResponseDto'` + 3. Mock `EntityManager.find()` to return a program entity with `{ id: 'p-1', code: 'BSCS', name: 'Computer Science', moodleCategoryId: 42 }` + 4. Assert `result[0].moodleCategoryId` equals `42` + 5. Assert `result[0]` is an instance of `ProgramFilterOptionResponseDto` (verifies real mapper, not mock passthrough) +- Notes: This is the critical mapping that the entire feature depends on. The controller test bypasses the mapper via mock; this test exercises the real `MapProgram()` method. + +#### Task 4: Update controller return type and test + +- **Depends on**: Task 1 +- File: `api: src/modules/admin/admin-filters.controller.ts` +- Action: + 1. Import `ProgramFilterOptionResponseDto` + 2. Change the `GetPrograms()` method return type annotation from `Promise` to `Promise` + 3. Update `@ApiResponse` decorator from `{ status: 200, type: [FilterOptionResponseDto] }` to `{ status: 200, type: [ProgramFilterOptionResponseDto] }` +- File: `api: src/modules/admin/admin-filters.controller.spec.ts` +- Action: + 1. Update the mock program data to include `moodleCategoryId`: `{ id: 'p-1', code: 'BSCS', name: 'Computer Science', moodleCategoryId: 42 }` + 2. Update assertion to expect `moodleCategoryId: 42` in the result + +#### Task 5: Add `ProgramFilterOption` type on frontend + +- File: `admin: src/types/api.ts` +- Action: + 1. Add a new interface below `FilterOption`: + ```typescript + export interface ProgramFilterOption extends FilterOption { + moodleCategoryId: number; + } + ``` + 2. `FilterOption` is **not modified**. + +#### Task 6: Update `useProgramsByDepartment` return type + +- **Depends on**: Task 5 +- File: `admin: src/features/moodle-provision/use-programs-by-department.ts` +- Action: + 1. Import `ProgramFilterOption` instead of `FilterOption` + 2. Change the `apiClient` generic from `FilterOption[]` to `ProgramFilterOption[]` +- Notes: This is the only provision hook that changes. `useSemesters` and `useDepartmentsBySemester` remain unchanged. A separate `usePrograms()` hook in `use-admin-filters.ts` also calls the programs endpoint but is typed as `FilterOption[]` — this is intentional. That hook serves admin user management which does not need `moodleCategoryId`. TypeScript structural typing makes the runtime safe; the type divergence is acceptable. + +#### Task 7: Remove `onSuccess` toast from `useSeedUsers` hook + +- File: `admin: src/features/moodle-provision/use-seed-users.ts` +- Action: + 1. Remove the `onSuccess` callback from the `useMutation` options (lines 13-15: `onSuccess: (data) => { toast.success(...) }`) + 2. Keep the `onError` callback unchanged — error toasts (409 and generic) are still appropriate +- Notes: The result panel (Task 10) replaces the success toast as the sole success feedback. TanStack Query fires both hook-level and component-level `onSuccess` callbacks; removing the hook-level one prevents double feedback. + +#### Task 8: Wire `onBrowse` to `SeedUsersTab` in provision page + +- File: `admin: src/features/moodle-provision/provision-page.tsx` +- Action: + 1. Change `` to `` + +#### Task 9: Rewrite `seed-users-tab.tsx` — Input View + +- File: `admin: src/features/moodle-provision/components/seed-users-tab.tsx` +- Action: Full rewrite of the component. This task covers the **input view**: + 1. **Component signature**: Accept `{ onBrowse: () => void }` prop + 2. **State**: + - Cascade: `semesterId`, `departmentId`, `programId` (all `string | undefined`) + - View: `type View = 'input' | 'preview'` + - Course snapshot: `courseSnapshot: MoodleCoursePreview[]` (local state, not live query) + - Selection: `checked: Set` (indices into `courseSnapshot`) + - Manual IDs: `manualIdsInput: string` (default: `''`), `manualIdsExpanded: boolean` (default: `false`) + - Form: `role: 'student' | 'faculty' | ''`, `count: string` + - Result: `result: SeedUsersResponse | null` + 3. **Cascade dropdowns section** (full-width semester, then 2-col department + program): + - Import and use `useSemesters()`, `useDepartmentsBySemester(semesterId)`, `useProgramsByDepartment(departmentId)` + - `handleSemesterChange(id)`: set semesterId, reset departmentId + programId + courseSnapshot + checked + - `handleDepartmentChange(id)`: set departmentId, reset programId + courseSnapshot + checked + - `handleProgramChange(id)`: set programId, **immediately clear `courseSnapshot` to `[]` and `checked` to `new Set()`** — this prevents stale courses from appearing under a new program label during the `keepPreviousData` transition + - Disable department until semester selected; disable program until department selected + 4. **Role + Count row** (2-col grid below cascade): + - Role: `Select` with `student` / `faculty` options (same as current) + - Count: `Input type="number"` min=1 max=200 (same as current) + 5. **Course picker section** (appears when programId is set): + - Derive `moodleCategoryId` from selected program: `programs?.find(p => p.id === programId)?.moodleCategoryId ?? null` + - Call `useCategoryCourses(moodleCategoryId)` + - **Snapshot pattern**: When `categoryCourses` data arrives with a new `categoryId` (different from the current snapshot's source), copy `categoryCourses.courses` into `courseSnapshot` via `useEffect` and reset `checked` to empty `Set`. Dependency: `categoryCourses?.categoryId` — but note the snapshot is already eagerly cleared by `handleProgramChange`, so this effect only populates, never clears. + - **Loading state**: Show `Loader2` spinner while `useCategoryCourses` is loading AND `courseSnapshot` is empty + - **Error state**: If `useCategoryCourses` returns an error, show a bordered error box: "Failed to load courses from Moodle" with a "Retry" button that calls `refetch()` + - **Empty state**: Show "No courses found in this category" if fetch succeeded and `courseSnapshot.length === 0` + - **Course table**: Render checkbox table from `courseSnapshot` with columns: Checkbox, ID, Shortname, Fullname, Enrolled + - Select-all checkbox in header + - `toggleRow(idx)` and `toggleAll()` handlers using `checked: Set` indexing into `courseSnapshot` + 6. **"Add by ID" escape hatch** (below course table, visible only when programId is set): + - Collapsible section toggled by a text link: "Add courses by ID" + - **Initial state**: collapsed (`manualIdsExpanded: false`) + - **Collapse behavior**: collapsing does NOT clear the input (preserves typed IDs) + - **State persistence**: `manualIdsInput` and `manualIdsExpanded` persist across view transitions (preview and back) + - When expanded: text `Input` for comma-separated IDs + - Parsing logic: + ``` + parsedManualIds = input.split(',').map(s => s.trim()).filter(Boolean).map(s => parseInt(s, 10)) + invalidIds = input.split(',').map(s => s.trim()).filter(s => s && isNaN(parseInt(s, 10))) + validManualIds = parsedManualIds.filter(n => !isNaN(n)) + ``` + - Show inline error listing ALL invalid IDs: `Invalid course IDs: {invalidIds.join(', ')}` when `invalidIds.length > 0` + 7. **Derived values for submission**: + - `pickerIds = checked indices mapped to courseSnapshot[i].id` + - `allCourseIds = [...new Set([...pickerIds, ...validManualIds])]` — **deduplicated via Set** + - `campusCode = semesters?.find(s => s.id === semesterId)?.campusCode` + 8. **Action buttons row**: + - "Preview" button — enabled when ALL of: role is set, `parsedCount >= 1 && parsedCount <= 200`, `allCourseIds.length > 0`, **`invalidIds.length === 0`** (manual IDs must be valid or empty) + - On click: set `view = 'preview'` + - "Browse existing" button with `FolderTree` icon — calls `onBrowse()` + 9. **Remove**: `CAMPUSES` import, static campus dropdown, raw courseIdsInput text field as the sole input, `AlertDialog` confirmation, inline badge result display + +#### Task 10: Rewrite `seed-users-tab.tsx` — Preview View + +- File: `admin: src/features/moodle-provision/components/seed-users-tab.tsx` +- Action: Add the **preview view** (rendered when `view === 'preview'` and `result` is null): + 1. **Back button**: `