Skip to content

Commit 1cfa419

Browse files
authored
Release April 03, 2026 — FAC-107–112: Moodle Sync Fixes, Dean Promotion, Versioned Questionnaires & Role Derivation (#267)
* fix: docker url binding (#245) (#246) * FAC-107 fix: populate campus, program, and department on users during sync and login#251 (#252) Closes #247 The user table's campus_id, program_id, and department_id columns were never populated. This adds scope field derivation in two code paths: - Enrollment sync: new backfillUserScopes() phase runs after enrollment upserts, deriving fields from the course → program → department chain for all synced users. - Login hydration: new deriveUserScopes() sets scope fields before persisting the user, ensuring immediate consistency on login. Campus uses a two-tier strategy: username prefix lookup (covers most Moodle users) with a fallback to the category hierarchy for non-standard usernames. Also exposes program and department in the GET /auth/me response and adds them to the UserLoader populate list. * FAC-108 fix: bullmq queue prefix isolation (#254) (#255) * 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 * FAC-110 feat: add admin endpoint for detailed info about a single user (#258) (#259) Add GET /admin/users/:id endpoint (SUPER_ADMIN only) returning full user profile with active enrollments and institutional role assignments. - New AdminUserDetailResponseDto with enrollment and institutional role sub-DTOs - Enrollments filtered by isActive and course.isActive to exclude stale data - Institutional roles include moodleCategory context (name, depth, source) - ParseUUIDPipe validates path param; parallel queries via Promise.all - 32 tests passing (6 new), lint clean * FAC-111 feat: add questionnaire version templating (#261) (#262) * FAC-111 feat: add questionnaire version templating Add POST /questionnaires/:id/versions/from-template endpoint that deep-copies schemaSnapshot from an existing version into a new DRAFT version. Wrapped in UnitOfWork transaction with validation for archived questionnaires, same-questionnaire ownership, draft-source rejection, and single-draft enforcement. * chore: add tech-spec for questionnaire version templating * FAC-112 feat: derive user roles during Moodle sync (#265) (#266) * FAC-112 feat: derive user roles during Moodle sync Add Phase 5 to enrollment sync pipeline that derives user roles from existing enrollment and institutional role data, eliminating the requirement for users to log in before roles appear. - Add protected-roles guard to updateRolesFromEnrollments() to preserve SUPER_ADMIN and ADMIN roles through derivation - Add deriveUserRoles() to EnrollmentSyncService with batch-load, group-by-user, and always-flush pattern - Wire Phase 5 after backfillUserScopes() with non-fatal error handling - Add 10 unit tests for role derivation logic - Fix pre-existing lint error in questionnaire-types.spec.ts Closes #264 * chore: add tech-spec for derive-roles-during-sync
1 parent 10c942b commit 1cfa419

27 files changed

Lines changed: 2678 additions & 8 deletions

_bmad-output/implementation-artifacts/tech-spec-admin-user-detail.md

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

_bmad-output/implementation-artifacts/tech-spec-derive-roles-during-sync.md

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

_bmad-output/implementation-artifacts/tech-spec-questionnaire-version-templating.md

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

_bmad-output/implementation-artifacts/tech-spec-streamline-dean-promotion.md

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

docker-compose.deploy.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ services:
77
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in shell env}
88
POSTGRES_DB: postgres
99
command: postgres -c shared_buffers=512MB -c work_mem=8MB
10+
ports:
11+
- '127.0.0.1:5432:5432'
1012
volumes:
1113
- pg_data:/var/lib/postgresql/data
1214
- ./deploy/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro

src/entities/user.entity.spec.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { User } from './user.entity';
2+
import { UserRole } from '../modules/auth/roles.enum';
3+
import { Enrollment } from './enrollment.entity';
4+
import { UserInstitutionalRole } from './user-institutional-role.entity';
5+
6+
function stubEnrollment(role: string, isActive = true): Enrollment {
7+
return { role, isActive } as unknown as Enrollment;
8+
}
9+
10+
function stubInstRole(role: string): UserInstitutionalRole {
11+
return { role } as unknown as UserInstitutionalRole;
12+
}
13+
14+
describe('User.updateRolesFromEnrollments', () => {
15+
let user: User;
16+
17+
beforeEach(() => {
18+
user = new User();
19+
user.roles = [];
20+
});
21+
22+
it('should preserve SUPER_ADMIN alongside enrollment-derived roles', () => {
23+
user.roles = [UserRole.SUPER_ADMIN];
24+
25+
user.updateRolesFromEnrollments([stubEnrollment('student')]);
26+
27+
expect(user.roles).toEqual(
28+
expect.arrayContaining([UserRole.SUPER_ADMIN, UserRole.STUDENT]),
29+
);
30+
expect(user.roles).toHaveLength(2);
31+
});
32+
33+
it('should preserve ADMIN alongside enrollment-derived roles', () => {
34+
user.roles = [UserRole.ADMIN];
35+
36+
user.updateRolesFromEnrollments([stubEnrollment('editingteacher')]);
37+
38+
expect(user.roles).toEqual(
39+
expect.arrayContaining([UserRole.ADMIN, UserRole.FACULTY]),
40+
);
41+
expect(user.roles).toHaveLength(2);
42+
});
43+
44+
it('should derive roles from enrollments without protected roles', () => {
45+
user.updateRolesFromEnrollments([
46+
stubEnrollment('student'),
47+
stubEnrollment('editingteacher'),
48+
]);
49+
50+
expect(user.roles).toEqual(
51+
expect.arrayContaining([UserRole.STUDENT, UserRole.FACULTY]),
52+
);
53+
expect(user.roles).toHaveLength(2);
54+
});
55+
56+
it('should keep SUPER_ADMIN when no enrollments and no institutional roles', () => {
57+
user.roles = [UserRole.SUPER_ADMIN];
58+
59+
user.updateRolesFromEnrollments([]);
60+
61+
expect(user.roles).toEqual([UserRole.SUPER_ADMIN]);
62+
});
63+
64+
it('should include both enrollment and institutional roles', () => {
65+
user.updateRolesFromEnrollments(
66+
[stubEnrollment('teacher')],
67+
[stubInstRole(UserRole.DEAN)],
68+
);
69+
70+
expect(user.roles).toEqual(
71+
expect.arrayContaining([UserRole.FACULTY, UserRole.DEAN]),
72+
);
73+
expect(user.roles).toHaveLength(2);
74+
});
75+
76+
it('should map manager enrollment role to DEAN via MoodleRoleMapping', () => {
77+
user.updateRolesFromEnrollments([stubEnrollment('manager')]);
78+
79+
expect(user.roles).toEqual(expect.arrayContaining([UserRole.DEAN]));
80+
expect(user.roles).toHaveLength(1);
81+
});
82+
83+
it('should return empty roles when no protected roles, no enrollments, no institutional roles', () => {
84+
user.updateRolesFromEnrollments([]);
85+
86+
expect(user.roles).toEqual([]);
87+
});
88+
89+
it('should ignore inactive enrollments', () => {
90+
user.updateRolesFromEnrollments([
91+
stubEnrollment('student', false),
92+
stubEnrollment('editingteacher', false),
93+
]);
94+
95+
expect(user.roles).toEqual([]);
96+
});
97+
98+
it('should deduplicate roles from multiple enrollments with the same role', () => {
99+
user.updateRolesFromEnrollments([
100+
stubEnrollment('student'),
101+
stubEnrollment('student'),
102+
stubEnrollment('student'),
103+
]);
104+
105+
expect(user.roles).toEqual([UserRole.STUDENT]);
106+
});
107+
108+
it('should fall back to uppercased role string for unknown Moodle roles', () => {
109+
user.updateRolesFromEnrollments([stubEnrollment('coursecreator')]);
110+
111+
expect(user.roles).toEqual(['COURSECREATOR']);
112+
});
113+
});

src/entities/user.entity.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export class User extends CustomBaseEntity {
9494
enrollments: Enrollment[],
9595
institutionalRoles: UserInstitutionalRole[] = [],
9696
) {
97+
const protectedRoles = this.roles.filter(
98+
(r) => r === UserRole.SUPER_ADMIN || r === UserRole.ADMIN,
99+
);
100+
97101
const enrollmentRoles = enrollments
98102
.filter((e) => e.isActive)
99103
.map(
@@ -108,8 +112,8 @@ export class User extends CustomBaseEntity {
108112
(ir.role.toUpperCase() as unknown as UserRole),
109113
);
110114

111-
this.roles = [...new Set([...enrollmentRoles, ...instRoles])].filter(
112-
Boolean,
113-
);
115+
this.roles = [
116+
...new Set([...protectedRoles, ...enrollmentRoles, ...instRoles]),
117+
].filter(Boolean);
114118
}
115119
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe('AdminController', () => {
2121
currentPage: 1,
2222
},
2323
}),
24+
GetUserDetail: jest.fn().mockResolvedValue({}),
2425
};
2526

2627
const module: TestingModule = await Test.createTestingModule({
@@ -41,6 +42,12 @@ describe('AdminController', () => {
4142
controller = module.get(AdminController);
4243
});
4344

45+
it('should delegate user detail to the admin service', async () => {
46+
await controller.GetUserDetail('user-1');
47+
48+
expect(adminService.GetUserDetail).toHaveBeenCalledWith('user-1');
49+
});
50+
4451
it('should delegate user listing to the admin service', async () => {
4552
const query: ListUsersQueryDto = {
4653
search: 'john',

src/modules/admin/admin.controller.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
import { Body, Controller, Delete, Get, Post, Query } from '@nestjs/common';
1+
import {
2+
Body,
3+
Controller,
4+
Delete,
5+
Get,
6+
Param,
7+
ParseUUIDPipe,
8+
Post,
9+
Query,
10+
} from '@nestjs/common';
211
import {
312
ApiBearerAuth,
413
ApiOperation,
14+
ApiParam,
515
ApiQuery,
616
ApiResponse,
717
ApiTags,
@@ -12,7 +22,10 @@ import { AdminService } from './services/admin.service';
1222
import { AssignInstitutionalRoleDto } from './dto/requests/assign-institutional-role.request.dto';
1323
import { RemoveInstitutionalRoleDto } from './dto/requests/remove-institutional-role.request.dto';
1424
import { ListUsersQueryDto } from './dto/requests/list-users-query.dto';
25+
import { DeanEligibleCategoriesQueryDto } from './dto/requests/dean-eligible-categories-query.dto';
26+
import { AdminUserDetailResponseDto } from './dto/responses/admin-user-detail.response.dto';
1527
import { AdminUserListResponseDto } from './dto/responses/admin-user-list.response.dto';
28+
import { DeanEligibleCategoryResponseDto } from './dto/responses/dean-eligible-category.response.dto';
1629

1730
@ApiTags('Admin')
1831
@Controller('admin')
@@ -86,6 +99,36 @@ export class AdminController {
8699
return this.adminService.ListUsers(query);
87100
}
88101

102+
@Get('users/:id')
103+
@ApiOperation({ summary: 'Get detailed information about a single user' })
104+
@ApiParam({ name: 'id', type: String, description: 'User UUID' })
105+
@ApiResponse({ status: 200, type: AdminUserDetailResponseDto })
106+
@ApiResponse({ status: 400, description: 'Invalid UUID format' })
107+
@ApiResponse({ status: 404, description: 'User not found' })
108+
async GetUserDetail(
109+
@Param('id', ParseUUIDPipe) id: string,
110+
): Promise<AdminUserDetailResponseDto> {
111+
return this.adminService.GetUserDetail(id);
112+
}
113+
114+
@Get('institutional-roles/dean-eligible-categories')
115+
@ApiOperation({
116+
summary: 'List eligible department categories for DEAN promotion',
117+
})
118+
@ApiQuery({
119+
name: 'userId',
120+
required: true,
121+
type: String,
122+
description: 'UUID of the user to check eligibility for',
123+
})
124+
@ApiResponse({ status: 200, type: [DeanEligibleCategoryResponseDto] })
125+
@ApiResponse({ status: 404, description: 'User not found' })
126+
async GetDeanEligibleCategories(
127+
@Query() query: DeanEligibleCategoriesQueryDto,
128+
): Promise<DeanEligibleCategoryResponseDto[]> {
129+
return this.adminService.GetDeanEligibleCategories(query.userId);
130+
}
131+
89132
@Post('institutional-roles')
90133
@ApiOperation({
91134
summary: 'Assign an institutional role (DEAN/CHAIRPERSON) to a user',

src/modules/admin/admin.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { Module } from '@nestjs/common';
22
import { MikroOrmModule } from '@mikro-orm/nestjs';
33
import { Campus } from 'src/entities/campus.entity';
4+
import { Course } from 'src/entities/course.entity';
45
import { Department } from 'src/entities/department.entity';
6+
import { Enrollment } from 'src/entities/enrollment.entity';
57
import { MoodleCategory } from 'src/entities/moodle-category.entity';
68
import { Program } from 'src/entities/program.entity';
79
import { Semester } from 'src/entities/semester.entity';
@@ -16,7 +18,9 @@ import { AdminFiltersService } from './services/admin-filters.service';
1618
imports: [
1719
MikroOrmModule.forFeature([
1820
Campus,
21+
Course,
1922
Department,
23+
Enrollment,
2024
MoodleCategory,
2125
Program,
2226
Semester,

0 commit comments

Comments
 (0)