|
| 1 | +--- |
| 2 | +title: 'Streamline Dean Promotion Flow' |
| 3 | +slug: 'streamline-dean-promotion' |
| 4 | +created: '2026-04-02' |
| 5 | +status: 'completed' |
| 6 | +stepsCompleted: [1, 2, 3, 4] |
| 7 | +tech_stack: |
| 8 | + [ |
| 9 | + 'NestJS 11', |
| 10 | + 'MikroORM 6', |
| 11 | + 'PostgreSQL', |
| 12 | + 'TypeScript 5.7', |
| 13 | + 'class-validator', |
| 14 | + '@nestjs/swagger', |
| 15 | + ] |
| 16 | +files_to_modify: |
| 17 | + - 'src/modules/admin/admin.controller.ts' |
| 18 | + - 'src/modules/admin/services/admin.service.ts' |
| 19 | + - 'src/modules/admin/services/admin.service.spec.ts' |
| 20 | + - 'src/modules/admin/dto/requests/dean-eligible-categories-query.dto.ts (new)' |
| 21 | + - 'src/modules/admin/dto/responses/dean-eligible-category.response.dto.ts (new)' |
| 22 | +code_patterns: |
| 23 | + - 'Controllers use @UseJwtGuard(UserRole.SUPER_ADMIN) for auth' |
| 24 | + - 'Services inject EntityManager directly' |
| 25 | + - 'DTOs use class-validator + @nestjs/swagger decorators' |
| 26 | + - 'Response DTOs have static Map() methods' |
| 27 | + - 'Public service methods use PascalCase' |
| 28 | + - 'findOneOrFail always uses failHandler for NestJS exceptions' |
| 29 | + - 'UserInstitutionalRole.role is string, compare with (UserRole.X as string)' |
| 30 | +test_patterns: |
| 31 | + - 'Tests use Test.createTestingModule with mocked EntityManager' |
| 32 | + - 'Tests co-located with source as .spec.ts' |
| 33 | + - 'Jest mocks for em methods: find, findOneOrFail, etc.' |
| 34 | +--- |
| 35 | + |
| 36 | +# Tech-Spec: Streamline Dean Promotion Flow |
| 37 | + |
| 38 | +**Created:** 2026-04-02 |
| 39 | +**Issue:** [#249](https://github.com/CtrlAltElite-Devs/api.faculytics/issues/249) |
| 40 | + |
| 41 | +## Overview |
| 42 | + |
| 43 | +### Problem Statement |
| 44 | + |
| 45 | +The `POST /admin/institutional-roles` endpoint requires a numeric `moodleCategoryId` to assign a DEAN role, but admins have no reliable way to discover the correct ID. The admin console currently presents a raw text input, forcing admins to manually look up Moodle category IDs. CHAIRPERSON roles are auto-assigned via Moodle sync (`category:manage` capability check), so only DEAN promotion needs a manual flow. |
| 46 | + |
| 47 | +### Solution |
| 48 | + |
| 49 | +Create a new endpoint that returns the eligible depth-3 (department-level) Moodle categories for a given user, derived from their existing institutional role assignments. The user's CHAIRPERSON roles (auto-synced from Moodle or manually assigned) are resolved up the category tree to depth 3. The admin console can then present a dropdown of valid targets, and the selected `moodleCategoryId` is sent to the existing assignment endpoint. |
| 50 | + |
| 51 | +### Scope |
| 52 | + |
| 53 | +**In Scope:** |
| 54 | + |
| 55 | +- New API endpoint: `GET /admin/institutional-roles/dean-eligible-categories?userId=<uuid>` |
| 56 | +- Query the user's existing `UserInstitutionalRole` records where `role === CHAIRPERSON`, resolve to depth 3 |
| 57 | +- Exclude categories where user is already DEAN |
| 58 | +- Return list of eligible department-level categories with `moodleCategoryId` and `name` |
| 59 | +- Super admin access guard |
| 60 | +- Unit tests |
| 61 | + |
| 62 | +**Out of Scope:** |
| 63 | + |
| 64 | +- Admin console UI changes (separate repo: `admin.faculytics`) |
| 65 | +- Changes to the existing `POST /admin/institutional-roles` contract |
| 66 | +- CHAIRPERSON assignment (handled by Moodle sync) |
| 67 | +- Moodle sync modifications |
| 68 | + |
| 69 | +## Context for Development |
| 70 | + |
| 71 | +### Codebase Patterns |
| 72 | + |
| 73 | +- **Auth:** Controller-level `@UseJwtGuard(UserRole.SUPER_ADMIN)` — already applied to `AdminController` |
| 74 | +- **Entity queries:** Services inject `EntityManager` directly, use `em.find()` with `populate` for relations |
| 75 | +- **DTOs:** Request DTOs use `class-validator` decorators (`IsUUID`, `IsString`, etc.), response DTOs use `@nestjs/swagger` decorators (`ApiProperty` with `description` and `example`) with static `Map()` factory methods |
| 76 | +- **Method naming:** Public service methods use PascalCase (e.g., `ListUsers`, `AssignInstitutionalRole`) |
| 77 | +- **Error handling:** Standard NestJS exceptions (`NotFoundException`, `BadRequestException`). All `findOneOrFail` calls must use `{ failHandler: () => new NotFoundException(...) }` — bare `findOneOrFail` throws MikroORM's `NotFoundError` (500), not NestJS's `NotFoundException` (404) |
| 78 | +- **Query DTOs:** See `FilterDepartmentsQueryDto` pattern — single optional/required field with `@ApiPropertyOptional`/`@ApiProperty` + validators |
| 79 | +- **String enum comparison:** `UserInstitutionalRole.role` is typed as `string`, not `UserRole` enum. Compare using `ir.role === (UserRole.DEAN as string)` pattern (see `moodle-user-hydration.service.ts` line 393) |
| 80 | +- **Read-only methods:** This new method is a pure query — no `flush()` or `refreshUserRoles()` needed |
| 81 | + |
| 82 | +### Files to Reference |
| 83 | + |
| 84 | +| File | Purpose | |
| 85 | +| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | |
| 86 | +| `src/modules/admin/admin.controller.ts` | Existing admin endpoints — add new GET here | |
| 87 | +| `src/modules/admin/services/admin.service.ts` | Service with `AssignInstitutionalRole` — add query method here | |
| 88 | +| `src/modules/admin/services/admin.service.spec.ts` | Existing tests — add new tests here | |
| 89 | +| `src/entities/user-institutional-role.entity.ts` | `UserInstitutionalRole` — links user→role→moodleCategory, unique on `(user, moodleCategory, role)` | |
| 90 | +| `src/entities/moodle-category.entity.ts` | `MoodleCategory` — `moodleCategoryId`, `name`, `depth`, `parentMoodleCategoryId` | |
| 91 | +| `src/modules/admin/dto/responses/filter-option.response.dto.ts` | Pattern reference for response DTO with static `Map()` | |
| 92 | +| `src/modules/admin/dto/requests/filter-departments-query.dto.ts` | Pattern reference for query DTO | |
| 93 | +| `src/modules/admin/admin.module.ts` | Module already imports all needed entities | |
| 94 | +| `src/modules/common/services/scope-resolver.service.ts` | Pattern reference for batch `$in` query on `moodleCategoryId` | |
| 95 | + |
| 96 | +### Technical Decisions |
| 97 | + |
| 98 | +- **User-centric query:** Resolve eligible categories from the user's own `UserInstitutionalRole` records, not from a global category list |
| 99 | +- **Explicit CHAIRPERSON filter:** Only roles where `role === (UserRole.CHAIRPERSON as string)` are considered candidates. Do NOT use "everything that isn't DEAN" — this prevents future role types from being silently included |
| 100 | +- **Both source types included:** All CHAIRPERSON roles regardless of `source` (auto or manual) are considered eligible. The `source` distinction is for sync cleanup logic, not eligibility |
| 101 | +- **Depth resolution:** For depth 4 categories, follow `parentMoodleCategoryId` to get the depth 3 parent. For depth 3 categories, use directly. Other depths are skipped |
| 102 | +- **Batch parent resolution:** Collect all `parentMoodleCategoryId`s from depth-4 candidates and batch-fetch with `em.find(MoodleCategory, { moodleCategoryId: { $in: [...ids] } })` — no N+1 queries |
| 103 | +- **Null guard on populated relations:** Skip any `UserInstitutionalRole` where `moodleCategory` is null after populate (handles edge case of soft-deleted `MoodleCategory`) |
| 104 | +- **Exclusion:** Filter out categories where the user already holds a DEAN role |
| 105 | +- **Deduplication:** Multiple CHAIRPERSON roles at depth 4 under the same department collapse to one entry |
| 106 | +- **No new entities or module changes:** `AdminModule` already imports `UserInstitutionalRole`, `MoodleCategory`, and `User` |
| 107 | +- **Response shape:** `{ moodleCategoryId: number, name: string }` — minimal, directly usable by the assignment DTO |
| 108 | + |
| 109 | +## Implementation Plan |
| 110 | + |
| 111 | +### Tasks |
| 112 | + |
| 113 | +- [ ] Task 1: Create response DTO |
| 114 | + - File: `src/modules/admin/dto/responses/dean-eligible-category.response.dto.ts` (new) |
| 115 | + - Action: Create DTO with `moodleCategoryId` (number) and `name` (string), `@ApiProperty` decorators with `description` and `example` values, and static `Map(category: MoodleCategory)` factory method |
| 116 | + - Example: |
| 117 | + |
| 118 | + ```typescript |
| 119 | + export class DeanEligibleCategoryResponseDto { |
| 120 | + @ApiProperty({ |
| 121 | + description: 'Moodle category ID for the department', |
| 122 | + example: 8, |
| 123 | + }) |
| 124 | + moodleCategoryId: number; |
| 125 | + |
| 126 | + @ApiProperty({ description: 'Department name', example: 'CCS' }) |
| 127 | + name: string; |
| 128 | + |
| 129 | + static Map(category: MoodleCategory): DeanEligibleCategoryResponseDto { |
| 130 | + return { |
| 131 | + moodleCategoryId: category.moodleCategoryId, |
| 132 | + name: category.name, |
| 133 | + }; |
| 134 | + } |
| 135 | + } |
| 136 | + ``` |
| 137 | + |
| 138 | +- [ ] Task 2: Create request query DTO |
| 139 | + - File: `src/modules/admin/dto/requests/dean-eligible-categories-query.dto.ts` (new) |
| 140 | + - Action: Create DTO with required `userId` field, `@ApiProperty({ description: '...' })` + `@IsUUID()` validators |
| 141 | + - Notes: Follow `FilterDepartmentsQueryDto` pattern but with `userId` as required (not optional) |
| 142 | + |
| 143 | +- [ ] Task 3: Add service method `GetDeanEligibleCategories` |
| 144 | + - File: `src/modules/admin/services/admin.service.ts` |
| 145 | + - Action: Add new public method with this logic: |
| 146 | + 1. Validate user exists: |
| 147 | + ```typescript |
| 148 | + await this.em.findOneOrFail( |
| 149 | + User, |
| 150 | + { id: userId }, |
| 151 | + { |
| 152 | + failHandler: () => new NotFoundException('User not found'), |
| 153 | + }, |
| 154 | + ); |
| 155 | + ``` |
| 156 | + 2. Fetch all institutional roles with populated moodleCategory: |
| 157 | + ```typescript |
| 158 | + const roles = await this.em.find( |
| 159 | + UserInstitutionalRole, |
| 160 | + { user: userId }, |
| 161 | + { populate: ['moodleCategory'] }, |
| 162 | + ); |
| 163 | + ``` |
| 164 | + 3. Build DEAN exclusion set — collect `moodleCategoryId` from roles where `ir.role === (UserRole.DEAN as string)`: |
| 165 | + ```typescript |
| 166 | + const deanCategoryIds = new Set( |
| 167 | + roles |
| 168 | + .filter( |
| 169 | + (ir) => ir.role === (UserRole.DEAN as string) && ir.moodleCategory, |
| 170 | + ) |
| 171 | + .map((ir) => ir.moodleCategory.moodleCategoryId), |
| 172 | + ); |
| 173 | + ``` |
| 174 | + 4. Filter CHAIRPERSON candidates (explicitly, not "non-DEAN"), skip null moodleCategory: |
| 175 | + ```typescript |
| 176 | + const chairpersonRoles = roles.filter( |
| 177 | + (ir) => |
| 178 | + ir.role === (UserRole.CHAIRPERSON as string) && ir.moodleCategory, |
| 179 | + ); |
| 180 | + ``` |
| 181 | + 5. Separate depth-3 (direct) and depth-4 (need parent resolution): |
| 182 | + - Depth 3 → add directly to candidates `Map<number, MoodleCategory>` |
| 183 | + - Depth 4 → collect `parentMoodleCategoryId` for batch fetch |
| 184 | + - Other depths → skip |
| 185 | + 6. Batch-fetch depth-4 parents (no N+1): |
| 186 | + ```typescript |
| 187 | + const parentCategories = await this.em.find(MoodleCategory, { |
| 188 | + moodleCategoryId: { $in: [...parentIds] }, |
| 189 | + }); |
| 190 | + ``` |
| 191 | + Add resolved parents to candidates map. |
| 192 | + 7. Exclude any `moodleCategoryId` in the DEAN exclusion set. |
| 193 | + 8. Return mapped through `DeanEligibleCategoryResponseDto.Map()`, sorted by `name`. |
| 194 | + - Notes: Method signature: `async GetDeanEligibleCategories(userId: string): Promise<DeanEligibleCategoryResponseDto[]>`. This is a read-only query — no `flush()` or `refreshUserRoles()` needed. |
| 195 | + |
| 196 | +- [ ] Task 4: Add controller endpoint |
| 197 | + - File: `src/modules/admin/admin.controller.ts` |
| 198 | + - Action: Add new GET endpoint in `AdminController`: |
| 199 | + ```typescript |
| 200 | + @Get('institutional-roles/dean-eligible-categories') |
| 201 | + @ApiOperation({ summary: 'List eligible department categories for DEAN promotion' }) |
| 202 | + @ApiQuery({ name: 'userId', required: true, type: String, description: 'UUID of the user to check eligibility for' }) |
| 203 | + @ApiResponse({ status: 200, type: [DeanEligibleCategoryResponseDto] }) |
| 204 | + @ApiResponse({ status: 404, description: 'User not found' }) |
| 205 | + async GetDeanEligibleCategories( |
| 206 | + @Query() query: DeanEligibleCategoriesQueryDto, |
| 207 | + ): Promise<DeanEligibleCategoryResponseDto[]> { |
| 208 | + return this.adminService.GetDeanEligibleCategories(query.userId); |
| 209 | + } |
| 210 | + ``` |
| 211 | + - Notes: Import the new DTOs. No route ordering concern — GET `institutional-roles/dean-eligible-categories` and POST `institutional-roles` are different HTTP methods and different paths; no ambiguity. |
| 212 | + |
| 213 | +- [ ] Task 5: Add unit tests |
| 214 | + - File: `src/modules/admin/services/admin.service.spec.ts` |
| 215 | + - Action: Add new `describe('GetDeanEligibleCategories')` block with these test cases: |
| 216 | + 1. **Happy path — depth 4 resolved to depth 3:** User has CHAIRPERSON at depth 4 (programCatId=18, parentMoodleCategoryId=8) → batch-fetches parent → returns dept category (catId=8, name='CCS'). Mock `em.findOneOrFail` with `failHandler` for user lookup, `em.find` for roles (returns CHAIRPERSON role with depth-4 moodleCategory), `em.find` for batch parent fetch (returns depth-3 category). |
| 217 | + 2. **Happy path — depth 3 used directly (manual-assignment scenario):** User has CHAIRPERSON at depth 3 (catId=8) → returns that category directly. Note: Moodle sync only creates CHAIRPERSON at depth 4; depth-3 CHAIRPERSON comes from manual assignment via `POST /admin/institutional-roles`. |
| 218 | + 3. **Deduplication:** User has CHAIRPERSON at two depth-4 categories (catId=18, catId=19) both with same parentMoodleCategoryId=8 → batch-fetches one parent → returns one entry. |
| 219 | + 4. **Exclusion of existing DEAN:** User has DEAN at dept catId=8 AND CHAIRPERSON at catId=18 (child of 8) → resolved parent matches DEAN exclusion set → returns empty array. |
| 220 | + 5. **User not found:** `em.findOneOrFail` `failHandler` invoked → throws `NotFoundException`. |
| 221 | + 6. **No institutional roles:** User exists, `em.find` returns empty array → returns empty array. |
| 222 | + 7. **Mixed scenario:** User has CHAIRPERSON at programs under dept A (catId=8) and dept B (catId=12), already DEAN at dept A → returns only dept B (catId=12). |
| 223 | + - Notes: Follow existing test patterns. Mock `em.findOneOrFail` with `failHandler` support, `em.find` for institutional roles and batch parent fetch. |
| 224 | + |
| 225 | +### Acceptance Criteria |
| 226 | + |
| 227 | +- [ ] AC 1: Given a user with CHAIRPERSON roles at depth-4 categories, when `GET /admin/institutional-roles/dean-eligible-categories?userId=<uuid>` is called, then the response contains the resolved depth-3 parent departments with `moodleCategoryId` and `name` |
| 228 | +- [ ] AC 2: Given a user with CHAIRPERSON roles at depth-3 categories (manually assigned), when the endpoint is called, then those categories are returned directly |
| 229 | +- [ ] AC 3: Given a user with multiple CHAIRPERSON roles under the same department, when the endpoint is called, then only one entry per department is returned (deduplication) |
| 230 | +- [ ] AC 4: Given a user who already has a DEAN role at a department, when the endpoint is called, then that department is excluded from the results |
| 231 | +- [ ] AC 5: Given a user with no institutional roles, when the endpoint is called, then an empty array is returned |
| 232 | +- [ ] AC 6: Given an invalid userId, when the endpoint is called, then a 404 NotFoundException is returned |
| 233 | +- [ ] AC 7: Given an unauthenticated request or a non-SUPER_ADMIN user, when the endpoint is called, then a 401/403 response is returned |
| 234 | +- [ ] AC 8: Given the endpoint response, then each item's `moodleCategoryId` corresponds to a valid depth-3 MoodleCategory in the database |
| 235 | + |
| 236 | +## Additional Context |
| 237 | + |
| 238 | +### Dependencies |
| 239 | + |
| 240 | +- No new package dependencies required |
| 241 | +- All entities already imported in `AdminModule`: `User`, `UserInstitutionalRole`, `MoodleCategory` |
| 242 | +- Depends on Moodle sync having run at least once for the target user (CHAIRPERSON roles populated via `MoodleUserHydrationService.resolveInstitutionalRoles()`) |
| 243 | +- Global `ValidationPipe` must be enabled for `@IsUUID()` on the query DTO to validate at the HTTP level |
| 244 | + |
| 245 | +### Testing Strategy |
| 246 | + |
| 247 | +**Unit Tests (Task 5):** |
| 248 | + |
| 249 | +- 7 test cases covering happy paths, deduplication, exclusion, error handling, and edge cases |
| 250 | +- Mock `EntityManager` methods: `findOneOrFail` (with `failHandler`), `find` (for roles and batch parent fetch) |
| 251 | +- Follow existing patterns in `admin.service.spec.ts` |
| 252 | + |
| 253 | +**Manual Testing:** |
| 254 | + |
| 255 | +- Call `GET /admin/institutional-roles/dean-eligible-categories?userId=<uuid>` via Swagger or curl |
| 256 | +- Verify response contains correct department categories for a user with known CHAIRPERSON roles |
| 257 | +- Verify empty response for user with no roles or user who is already DEAN everywhere |
| 258 | +- Verify 404 for nonexistent userId |
| 259 | +- Verify end-to-end: select a `moodleCategoryId` from the response and send it to `POST /admin/institutional-roles` to confirm DEAN assignment succeeds |
| 260 | + |
| 261 | +### Notes |
| 262 | + |
| 263 | +- Consumer: `admin.faculytics` (React + Vite admin console) — the role assignment dialog at `src/features/admin/role-action-dialog.tsx` can replace its raw number input with a dropdown populated by this endpoint |
| 264 | +- CHAIRPERSON roles are auto-synced during login via `MoodleUserHydrationService.resolveInstitutionalRoles()` — users who have `moodle/category:manage` on a course's program category get CHAIRPERSON at that depth-4 category |
| 265 | +- A user who has never logged in will have no institutional roles and thus no eligible categories — this is expected and the admin should trigger a sync or wait for the user to log in |
| 266 | + |
| 267 | +## Review Notes |
| 268 | + |
| 269 | +- Adversarial spec review completed: 13 findings, 10 fixed, 3 skipped (noise) |
| 270 | +- Adversarial code review completed: 12 findings, 3 fixed, 9 skipped (noise/systemic/by-design) |
| 271 | +- Code fixes applied: depth-3 validation on batch-fetched parents, test for unexpected depths, test for sort order |
| 272 | +- Resolution approach: auto-fix on real findings |
0 commit comments