Skip to content

Commit ac34a71

Browse files
authored
FAC-109 feat: streamline dean promotion flow (#256) (#257)
* feat: add dean-eligible categories endpoint for streamlined dean promotion Add GET /admin/institutional-roles/dean-eligible-categories?userId=<uuid> that resolves a user's CHAIRPERSON roles up the Moodle category tree to eligible depth-3 department categories, enabling the admin console to replace a raw number input with a proper dropdown for DEAN assignment. Closes #249 * docs: add tech-spec for dean promotion flow
1 parent a1c945d commit ac34a71

6 files changed

Lines changed: 555 additions & 1 deletion

File tree

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
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 setcollect `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 3add directly to candidates `Map<number, MoodleCategory>`
183+
- Depth 4collect `parentMoodleCategoryId` for batch fetch
184+
- Other depthsskip
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 queryno `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 concernGET `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 pathdepth 4 resolved to depth 3:** User has CHAIRPERSON at depth 4 (programCatId=18, parentMoodleCategoryId=8) → batch-fetches parentreturns 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 pathdepth 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=8batch-fetches one parentreturns 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 setreturns empty array.
220+
5. **User not found:** `em.findOneOrFail` `failHandler` invokedthrows `NotFoundException`.
221+
6. **No institutional roles:** User exists, `em.find` returns empty arrayreturns 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 Areturns 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 categoriesthis 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

src/modules/admin/admin.controller.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import { AdminService } from './services/admin.service';
1212
import { AssignInstitutionalRoleDto } from './dto/requests/assign-institutional-role.request.dto';
1313
import { RemoveInstitutionalRoleDto } from './dto/requests/remove-institutional-role.request.dto';
1414
import { ListUsersQueryDto } from './dto/requests/list-users-query.dto';
15+
import { DeanEligibleCategoriesQueryDto } from './dto/requests/dean-eligible-categories-query.dto';
1516
import { AdminUserListResponseDto } from './dto/responses/admin-user-list.response.dto';
17+
import { DeanEligibleCategoryResponseDto } from './dto/responses/dean-eligible-category.response.dto';
1618

1719
@ApiTags('Admin')
1820
@Controller('admin')
@@ -86,6 +88,24 @@ export class AdminController {
8688
return this.adminService.ListUsers(query);
8789
}
8890

91+
@Get('institutional-roles/dean-eligible-categories')
92+
@ApiOperation({
93+
summary: 'List eligible department categories for DEAN promotion',
94+
})
95+
@ApiQuery({
96+
name: 'userId',
97+
required: true,
98+
type: String,
99+
description: 'UUID of the user to check eligibility for',
100+
})
101+
@ApiResponse({ status: 200, type: [DeanEligibleCategoryResponseDto] })
102+
@ApiResponse({ status: 404, description: 'User not found' })
103+
async GetDeanEligibleCategories(
104+
@Query() query: DeanEligibleCategoriesQueryDto,
105+
): Promise<DeanEligibleCategoryResponseDto[]> {
106+
return this.adminService.GetDeanEligibleCategories(query.userId);
107+
}
108+
89109
@Post('institutional-roles')
90110
@ApiOperation({
91111
summary: 'Assign an institutional role (DEAN/CHAIRPERSON) to a user',
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsUUID } from 'class-validator';
3+
4+
export class DeanEligibleCategoriesQueryDto {
5+
@ApiProperty({
6+
description: 'UUID of the user to check dean eligibility for',
7+
})
8+
@IsUUID()
9+
userId: string;
10+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { MoodleCategory } from 'src/entities/moodle-category.entity';
3+
4+
export class DeanEligibleCategoryResponseDto {
5+
@ApiProperty({
6+
description: 'Moodle category ID for the department',
7+
example: 8,
8+
})
9+
moodleCategoryId: number;
10+
11+
@ApiProperty({ description: 'Department name', example: 'CCS' })
12+
name: string;
13+
14+
static Map(category: MoodleCategory): DeanEligibleCategoryResponseDto {
15+
return {
16+
moodleCategoryId: category.moodleCategoryId,
17+
name: category.name,
18+
};
19+
}
20+
}

0 commit comments

Comments
 (0)