Skip to content

Commit 3aa2ca9

Browse files
authored
[STAGING] FAC-131 feat: add campus head role + local user provisioning (#331) (#332)
- Add CAMPUS_HEAD to UserRole enum and ScopeResolverService (Semester → Campus traversal) - Add POST /admin/users for non-Moodle local user provisioning (bcrypt + reserved "local-" prefix) - Add GET /admin/institutional-roles/campus-head-eligible-categories for depth-1 promotion - Add User.campus_source column + migration, mirror departmentSource/programSource pattern - Enforce local- namespace across Moodle inflows (sync skip guard, seed-users DTO rejection) - Extend controller guards (analytics/faculty/reports/curriculum) to allow CAMPUS_HEAD - Deny questionnaire submissions from CAMPUS_HEAD at service layer with clear message - Emit admin.user.create audit event manually from AdminUserService
1 parent 76e4b8f commit 3aa2ca9

30 files changed

Lines changed: 2856 additions & 13 deletions

_bmad-output/implementation-artifacts/tech-spec-fac-131-campus-head-role.md

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

src/entities/user.entity.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export class User extends CustomBaseEntity {
6969
@Property({ default: 'auto' })
7070
programSource!: string;
7171

72+
@Property({ default: 'auto' })
73+
campusSource!: string;
74+
7275
@OneToMany(() => MoodleToken, (token) => token.user)
7376
moodleTokens = new Collection<MoodleToken>(this);
7477

@@ -99,6 +102,7 @@ export class User extends CustomBaseEntity {
99102
user.isActive = true;
100103
user.departmentSource = 'auto';
101104
user.programSource = 'auto';
105+
user.campusSource = 'auto';
102106

103107
return user;
104108
}

src/migrations/.snapshot-faculytics_db.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8053,6 +8053,22 @@
80538053
"comment": null,
80548054
"enumItems": [],
80558055
"mappedType": "string"
8056+
},
8057+
"campus_source": {
8058+
"name": "campus_source",
8059+
"type": "varchar(255)",
8060+
"unsigned": false,
8061+
"autoincrement": false,
8062+
"primary": false,
8063+
"nullable": false,
8064+
"unique": false,
8065+
"length": 255,
8066+
"precision": null,
8067+
"scale": null,
8068+
"default": "'auto'",
8069+
"comment": null,
8070+
"enumItems": [],
8071+
"mappedType": "string"
80568072
}
80578073
},
80588074
"name": "user",
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Migration } from '@mikro-orm/migrations';
2+
3+
export class Migration20260413225718_fac131aCampusSource extends Migration {
4+
override async up(): Promise<void> {
5+
this.addSql(
6+
`alter table "user" add column "campus_source" varchar(255) not null default 'auto';`,
7+
);
8+
}
9+
10+
override async down(): Promise<void> {
11+
this.addSql(`alter table "user" drop column "campus_source";`);
12+
}
13+
}

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,23 @@ import { Test, TestingModule } from '@nestjs/testing';
33
import { AuthGuard } from '@nestjs/passport';
44
import { RolesGuard } from 'src/security/guards/roles.guard';
55
import { CurrentUserInterceptor } from 'src/modules/common/interceptors/current-user.interceptor';
6+
import { MetaDataInterceptor } from 'src/modules/common/interceptors/metadata.interceptor';
67
import { AdminController } from './admin.controller';
78
import { AdminService } from './services/admin.service';
9+
import { AdminUserService } from './services/admin-user.service';
810
import { ListUsersQueryDto } from './dto/requests/list-users-query.dto';
911
import { UpdateScopeAssignmentDto } from './dto/requests/update-scope-assignment.request.dto';
12+
import { CreateLocalUserRequestDto } from './dto/requests/create-user.request.dto';
1013

1114
describe('AdminController', () => {
1215
let controller: AdminController;
1316
let adminService: {
1417
ListUsers: jest.Mock;
1518
GetUserDetail: jest.Mock;
1619
UpdateUserScopeAssignment: jest.Mock;
20+
GetCampusHeadEligibleCategories: jest.Mock;
1721
};
22+
let adminUserService: { CreateLocalUser: jest.Mock };
1823

1924
async function buildModule(
2025
overrides: {
@@ -29,6 +34,10 @@ describe('AdminController', () => {
2934
provide: AdminService,
3035
useValue: adminService,
3136
},
37+
{
38+
provide: AdminUserService,
39+
useValue: adminUserService,
40+
},
3241
],
3342
})
3443
.overrideGuard(AuthGuard('jwt'))
@@ -44,6 +53,11 @@ describe('AdminController', () => {
4453
intercept: (_ctx: unknown, next: { handle: () => unknown }) =>
4554
next.handle(),
4655
})
56+
.overrideInterceptor(MetaDataInterceptor)
57+
.useValue({
58+
intercept: (_ctx: unknown, next: { handle: () => unknown }) =>
59+
next.handle(),
60+
})
4761
.compile();
4862
}
4963

@@ -67,6 +81,19 @@ describe('AdminController', () => {
6781
departmentSource: 'auto',
6882
programSource: 'auto',
6983
}),
84+
GetCampusHeadEligibleCategories: jest.fn().mockResolvedValue([]),
85+
};
86+
adminUserService = {
87+
CreateLocalUser: jest.fn().mockResolvedValue({
88+
id: 'user-1',
89+
username: 'local-kmartinez',
90+
firstName: 'K',
91+
lastName: 'Martinez',
92+
fullName: 'K Martinez',
93+
campus: null,
94+
defaultPasswordAssigned: false,
95+
createdAt: '2026-01-01T00:00:00.000Z',
96+
}),
7097
};
7198

7299
const module = await buildModule();
@@ -104,6 +131,33 @@ describe('AdminController', () => {
104131
);
105132
});
106133

134+
it('should delegate POST /admin/users to the admin-user service', async () => {
135+
const dto: CreateLocalUserRequestDto = {
136+
username: 'local-kmartinez',
137+
firstName: 'K',
138+
lastName: 'Martinez',
139+
password: 'TempPass1',
140+
};
141+
142+
const result = await controller.CreateLocalUser(dto);
143+
144+
expect(adminUserService.CreateLocalUser).toHaveBeenCalledWith(dto);
145+
expect(result).toMatchObject({
146+
id: 'user-1',
147+
username: 'local-kmartinez',
148+
fullName: 'K Martinez',
149+
defaultPasswordAssigned: false,
150+
});
151+
});
152+
153+
it('should delegate campus-head eligible categories lookup to the admin service', async () => {
154+
await controller.GetCampusHeadEligibleCategories({ userId: 'user-1' });
155+
156+
expect(adminService.GetCampusHeadEligibleCategories).toHaveBeenCalledWith(
157+
'user-1',
158+
);
159+
});
160+
107161
describe('authorization', () => {
108162
it('rejects unauthenticated requests via JwtAuthGuard', async () => {
109163
const module = await buildModule({

src/modules/admin/admin.controller.ts

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,25 +20,52 @@ import {
2020
} from '@nestjs/swagger';
2121
import { UseJwtGuard } from 'src/security/decorators';
2222
import { CurrentUserInterceptor } from 'src/modules/common/interceptors/current-user.interceptor';
23+
import { MetaDataInterceptor } from 'src/modules/common/interceptors/metadata.interceptor';
2324
import { UserRole } from '../auth/roles.enum';
2425
import { AdminService } from './services/admin.service';
26+
import { AdminUserService } from './services/admin-user.service';
2527
import { AssignInstitutionalRoleDto } from './dto/requests/assign-institutional-role.request.dto';
2628
import { RemoveInstitutionalRoleDto } from './dto/requests/remove-institutional-role.request.dto';
2729
import { ListUsersQueryDto } from './dto/requests/list-users-query.dto';
2830
import { DeanEligibleCategoriesQueryDto } from './dto/requests/dean-eligible-categories-query.dto';
31+
import { CampusHeadEligibleCategoriesQueryDto } from './dto/requests/campus-head-eligible-categories-query.dto';
2932
import { UpdateScopeAssignmentDto } from './dto/requests/update-scope-assignment.request.dto';
33+
import { CreateLocalUserRequestDto } from './dto/requests/create-user.request.dto';
3034
import { AdminUserDetailResponseDto } from './dto/responses/admin-user-detail.response.dto';
3135
import { AdminUserListResponseDto } from './dto/responses/admin-user-list.response.dto';
3236
import { AdminUserScopeAssignmentResponseDto } from './dto/responses/admin-user-scope-assignment.response.dto';
3337
import { DeanEligibleCategoryResponseDto } from './dto/responses/dean-eligible-category.response.dto';
38+
import { CampusHeadEligibleCategoryResponseDto } from './dto/responses/campus-head-eligible-category.response.dto';
39+
import { CreateLocalUserResponseDto } from './dto/responses/create-user.response.dto';
3440

3541
@ApiTags('Admin')
3642
@Controller('admin')
3743
@UseJwtGuard(UserRole.SUPER_ADMIN)
3844
@UseInterceptors(CurrentUserInterceptor)
3945
@ApiBearerAuth()
4046
export class AdminController {
41-
constructor(private readonly adminService: AdminService) {}
47+
constructor(
48+
private readonly adminService: AdminService,
49+
private readonly adminUserService: AdminUserService,
50+
) {}
51+
52+
@Post('users')
53+
@UseInterceptors(MetaDataInterceptor)
54+
@ApiOperation({
55+
summary:
56+
'Create a Faculytics-local user (non-Moodle, bcrypt-authenticated)',
57+
})
58+
@ApiResponse({ status: 201, type: CreateLocalUserResponseDto })
59+
@ApiResponse({
60+
status: 400,
61+
description: 'Invalid username, password, or campusId',
62+
})
63+
@ApiResponse({ status: 409, description: 'Username already exists' })
64+
async CreateLocalUser(
65+
@Body() dto: CreateLocalUserRequestDto,
66+
): Promise<CreateLocalUserResponseDto> {
67+
return this.adminUserService.CreateLocalUser(dto);
68+
}
4269

4370
@Get('users')
4471
@ApiOperation({ summary: 'List users for the admin console' })
@@ -158,9 +185,32 @@ export class AdminController {
158185
return this.adminService.GetDeanEligibleCategories(query.userId);
159186
}
160187

188+
@Get('institutional-roles/campus-head-eligible-categories')
189+
@ApiOperation({
190+
summary:
191+
'List depth-1 Moodle categories a user can be promoted to as Campus Head',
192+
})
193+
@ApiQuery({
194+
name: 'userId',
195+
required: true,
196+
type: String,
197+
description: 'UUID of the user to check eligibility for',
198+
})
199+
@ApiResponse({
200+
status: 200,
201+
type: [CampusHeadEligibleCategoryResponseDto],
202+
})
203+
@ApiResponse({ status: 404, description: 'User not found' })
204+
async GetCampusHeadEligibleCategories(
205+
@Query() query: CampusHeadEligibleCategoriesQueryDto,
206+
): Promise<CampusHeadEligibleCategoryResponseDto[]> {
207+
return this.adminService.GetCampusHeadEligibleCategories(query.userId);
208+
}
209+
161210
@Post('institutional-roles')
162211
@ApiOperation({
163-
summary: 'Assign an institutional role (DEAN/CHAIRPERSON) to a user',
212+
summary:
213+
'Assign an institutional role (DEAN/CHAIRPERSON/CAMPUS_HEAD) to a user',
164214
})
165215
@ApiResponse({ status: 200, description: 'Role assigned successfully' })
166216
@ApiResponse({ status: 404, description: 'User or category not found' })

src/modules/admin/admin.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { AdminGenerateController } from './admin-generate.controller';
2121
import { AdminService } from './services/admin.service';
2222
import { AdminFiltersService } from './services/admin-filters.service';
2323
import { AdminGenerateService } from './services/admin-generate.service';
24+
import { AdminUserService } from './services/admin-user.service';
2425
import { CommentGeneratorService } from './services/comment-generator.service';
2526

2627
@Module({
@@ -52,6 +53,7 @@ import { CommentGeneratorService } from './services/comment-generator.service';
5253
AdminService,
5354
AdminFiltersService,
5455
AdminGenerateService,
56+
AdminUserService,
5557
CommentGeneratorService,
5658
],
5759
})

src/modules/admin/dto/requests/assign-institutional-role.request.dto.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ export class AssignInstitutionalRoleDto {
88
userId: string;
99

1010
@ApiProperty({
11-
enum: [UserRole.DEAN, UserRole.CHAIRPERSON],
12-
description: 'The institutional role to assign',
11+
enum: [UserRole.DEAN, UserRole.CHAIRPERSON, UserRole.CAMPUS_HEAD],
12+
description:
13+
'The institutional role to assign (DEAN at depth 3, CHAIRPERSON at depth 4, CAMPUS_HEAD at depth 1)',
1314
})
1415
@IsEnum(UserRole)
1516
role: UserRole;
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 CampusHeadEligibleCategoriesQueryDto {
5+
@ApiProperty({
6+
description: 'UUID of the user to check campus-head eligibility for',
7+
})
8+
@IsUUID()
9+
userId: string;
10+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
2+
import {
3+
IsOptional,
4+
IsString,
5+
IsUUID,
6+
Matches,
7+
MinLength,
8+
} from 'class-validator';
9+
10+
export class CreateLocalUserRequestDto {
11+
@ApiProperty({
12+
example: 'local-kmartinez',
13+
description: 'Username — must start with reserved "local-" prefix',
14+
})
15+
@IsString()
16+
@Matches(/^local-[a-z0-9][a-z0-9._-]*$/, {
17+
message:
18+
'username must start with "local-" prefix and contain only lowercase alphanumerics, dots, dashes, or underscores',
19+
})
20+
username: string;
21+
22+
@ApiProperty({ example: 'K' })
23+
@IsString()
24+
@MinLength(1)
25+
firstName: string;
26+
27+
@ApiProperty({ example: 'Martinez' })
28+
@IsString()
29+
@MinLength(1)
30+
lastName: string;
31+
32+
@ApiPropertyOptional({
33+
description:
34+
'Password (min 6 chars). Omit to assign default "Head123#" seed.',
35+
})
36+
@IsOptional()
37+
@IsString()
38+
@MinLength(6, { message: 'password must be at least 6 characters' })
39+
password?: string;
40+
41+
@ApiPropertyOptional({
42+
description:
43+
'Optional UUID of the campus to assign. Sets campusSource="manual".',
44+
})
45+
@IsOptional()
46+
@IsUUID()
47+
campusId?: string;
48+
}

0 commit comments

Comments
 (0)