Compare commits
4 Commits
106c30683f
...
065dfd9a38
| Author | SHA1 | Date | |
|---|---|---|---|
| 065dfd9a38 | |||
| a272403f27 | |||
| 13ef357448 | |||
| a9c6578569 |
@@ -10,7 +10,6 @@ const baseUser = (overrides: Partial<TeacherCandidateUser> = {}): TeacherCandida
|
||||
id: 1,
|
||||
username: 'teacher',
|
||||
name: '测试老师',
|
||||
isActive: true,
|
||||
isArchived: false,
|
||||
studentStatus: null,
|
||||
roles: [{ code: 'teacher', name: '任课老师' }],
|
||||
@@ -29,13 +28,12 @@ describe('class teacher candidates', () => {
|
||||
expect(isTeacherCandidate(baseUser({ studentStatus: 'staff' }))).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes active students, disabled, archived, and super-admin accounts', () => {
|
||||
it('excludes active students, archived, and super-admin accounts', () => {
|
||||
const users = [
|
||||
baseUser({ id: 1, studentStatus: 'active' }),
|
||||
baseUser({ id: 2, isActive: false }),
|
||||
baseUser({ id: 3, isArchived: true }),
|
||||
baseUser({ id: 2, isArchived: true }),
|
||||
baseUser({
|
||||
id: 4,
|
||||
id: 3,
|
||||
roles: [{ code: 'super_admin', name: '超级管理员' }],
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -7,7 +7,6 @@ export interface TeacherCandidateUser {
|
||||
id: number;
|
||||
username: string;
|
||||
name?: string | null;
|
||||
isActive: boolean;
|
||||
isArchived: boolean;
|
||||
studentStatus?: string | null;
|
||||
roles?: TeacherCandidateRole[];
|
||||
@@ -18,7 +17,7 @@ const isSuperAdminRole = (role: TeacherCandidateRole) =>
|
||||
role.code === 'super_admin' || role.name === '超级管理员' || role.name === '超管';
|
||||
|
||||
export const isTeacherCandidate = (user: TeacherCandidateUser) => {
|
||||
if (!user.isActive || user.isArchived) return false;
|
||||
if (user.isArchived) return false;
|
||||
if (user.studentStatus && user.studentStatus !== 'staff') return false;
|
||||
return !(user.roles || []).some(isSuperAdminRole);
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ interface TeacherRow {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
profile: { subjects?: string[]; joinedAt?: string; qualifications?: string } | null;
|
||||
lastLoginAt: string;
|
||||
roles: { code: string; name: string }[];
|
||||
@@ -175,13 +174,6 @@ const TeachersPage: React.FC = () => {
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isActive',
|
||||
key: 'status',
|
||||
width: 90,
|
||||
render: (v: boolean) => <Tag color={v ? 'green' : 'red'}>{v ? '在职' : '停用'}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'lastLoginAt',
|
||||
|
||||
@@ -87,7 +87,6 @@ const UsersPage: React.FC = () => {
|
||||
form.setFieldsValue({
|
||||
username: record.username,
|
||||
name: record.name,
|
||||
isActive: record.isActive,
|
||||
roleIds: record.roles?.map((r: any) => r.id) || [],
|
||||
});
|
||||
setModalOpen(true);
|
||||
@@ -101,7 +100,6 @@ const UsersPage: React.FC = () => {
|
||||
await api.put(`/rbac/users/${editing.id}`, {
|
||||
username: values.username,
|
||||
name: values.name,
|
||||
isActive: values.isActive,
|
||||
roleIds: values.roleIds || [],
|
||||
});
|
||||
message.success('更新成功');
|
||||
@@ -223,26 +221,6 @@ const UsersPage: React.FC = () => {
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'isActive',
|
||||
width: 80,
|
||||
render: (v: boolean, r: any) => (
|
||||
<EditableCell
|
||||
value={String(v)}
|
||||
editor="select"
|
||||
options={[
|
||||
{ value: 'true', label: '启用' },
|
||||
{ value: 'false', label: '禁用' },
|
||||
]}
|
||||
permission="user:edit"
|
||||
disabled={r.isArchived}
|
||||
onSave={(next) => saveCell(r, 'isActive', String(next) === 'true')}
|
||||
>
|
||||
<Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>
|
||||
</EditableCell>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '最后登录',
|
||||
dataIndex: 'lastLoginAt',
|
||||
@@ -350,7 +328,7 @@ const UsersPage: React.FC = () => {
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1250 }}
|
||||
scroll={{ x: 1150 }}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
@@ -382,11 +360,6 @@ const UsersPage: React.FC = () => {
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{editing && (
|
||||
<Form.Item name="isActive" label="状态" valuePropName="checked">
|
||||
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
name="roleIds"
|
||||
label="角色分配"
|
||||
|
||||
@@ -47,4 +47,28 @@ describe('AuthService — authentication boundaries', () => {
|
||||
).rejects.toThrow('账号已失效');
|
||||
expect(userRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows a legacy disabled user because archive is the only account status', async () => {
|
||||
const userRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 3,
|
||||
username: 'legacy-disabled',
|
||||
name: '旧账号',
|
||||
passwordHash: await bcrypt.hash('secret', 4),
|
||||
isActive: false,
|
||||
isArchived: false,
|
||||
roles: [],
|
||||
}),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const service = new AuthService(
|
||||
userRepo as never,
|
||||
{ sign: jest.fn().mockReturnValue('token') } as never,
|
||||
{ getUserPermissions: jest.fn().mockResolvedValue([]) } as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.login({ username: 'legacy-disabled', password: 'secret' }, '192.0.2.11'),
|
||||
).resolves.toEqual(expect.objectContaining({ access_token: 'token' }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ export class AuthService {
|
||||
this.recordFailedAttempt(attemptKey);
|
||||
throw new UnauthorizedException('用户名或密码错误');
|
||||
}
|
||||
if (!user.isActive || user.isArchived) {
|
||||
if (user.isArchived) {
|
||||
throw new UnauthorizedException('账号已失效,请联系管理员');
|
||||
}
|
||||
const valid = await bcrypt.compare(dto.password, user.passwordHash);
|
||||
|
||||
@@ -50,16 +50,32 @@ describe('JwtStrategy', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ id: 7, isActive: false, isArchived: false, roles: [] }],
|
||||
[{ id: 7, isActive: true, isArchived: true, roles: [] }],
|
||||
[null],
|
||||
])('rejects disabled, archived, or deleted users', async (user) => {
|
||||
const userRepo = { findOne: jest.fn().mockResolvedValue(user) };
|
||||
it.each([[{ id: 7, isArchived: true, roles: [] }], [null]])(
|
||||
'rejects archived or deleted users',
|
||||
async (user) => {
|
||||
const userRepo = { findOne: jest.fn().mockResolvedValue(user) };
|
||||
const strategy = new JwtStrategy(config as never, userRepo as never);
|
||||
|
||||
await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it('accepts a legacy disabled user when the account is not archived', async () => {
|
||||
const userRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 7,
|
||||
username: 'teacher',
|
||||
isActive: false,
|
||||
isArchived: false,
|
||||
roles: [],
|
||||
}),
|
||||
};
|
||||
const strategy = new JwtStrategy(config as never, userRepo as never);
|
||||
|
||||
await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
await expect(strategy.validate({ sub: 7, username: 'teacher' })).resolves.toEqual(
|
||||
expect.objectContaining({ id: 7, username: 'teacher' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
where: { id: payload.sub },
|
||||
relations: ['roles', 'roles.permissions'],
|
||||
});
|
||||
if (!user || !user.isActive || user.isArchived) {
|
||||
if (!user || user.isArchived) {
|
||||
throw new UnauthorizedException('账号已失效,请重新登录');
|
||||
}
|
||||
|
||||
|
||||
116
apps/server/src/classes/classes.controller.spec.ts
Normal file
116
apps/server/src/classes/classes.controller.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ClassesController } from './classes.controller';
|
||||
import { ClassesService } from './classes.service';
|
||||
import { QueryClassDto } from './dto/class.dto';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { CaslAction, SubjectName } from '../authorization';
|
||||
|
||||
describe('ClassesController - class data scope', () => {
|
||||
const service = {
|
||||
getAccessibleClassIds: jest.fn(),
|
||||
assertClassAccess: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
getSchedule: jest.fn(),
|
||||
getAttendanceSummary: jest.fn(),
|
||||
getStudents: jest.fn(),
|
||||
getTeachers: jest.fn(),
|
||||
};
|
||||
const authzService = { can: jest.fn() };
|
||||
const logService = {};
|
||||
const notificationsService = {};
|
||||
let controller: ClassesController;
|
||||
|
||||
const request = (permissions: string[] = [], isSuperAdmin = false) => ({
|
||||
user: {
|
||||
id: 21,
|
||||
username: 'user',
|
||||
permissions,
|
||||
isSuperAdmin,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
authzService.can.mockImplementation((req, action, subject) => {
|
||||
if (subject !== SubjectName.Class) return false;
|
||||
if (req.user.isSuperAdmin && action === CaslAction.Manage) return true;
|
||||
if (action === CaslAction.Create) return req.user.permissions.includes('class:create');
|
||||
if (action === CaslAction.Update) return req.user.permissions.includes('class:edit');
|
||||
return false;
|
||||
});
|
||||
service.getAccessibleClassIds.mockResolvedValue(undefined);
|
||||
service.assertClassAccess.mockResolvedValue(undefined);
|
||||
service.findAll.mockResolvedValue([]);
|
||||
service.findOne.mockResolvedValue({ id: 8 });
|
||||
service.getSchedule.mockResolvedValue([]);
|
||||
service.getAttendanceSummary.mockResolvedValue({});
|
||||
service.getStudents.mockResolvedValue([]);
|
||||
service.getTeachers.mockResolvedValue([]);
|
||||
controller = new ClassesController(
|
||||
service as unknown as ClassesService,
|
||||
logService as unknown as OperationLogsService,
|
||||
notificationsService as unknown as NotificationsService,
|
||||
authzService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['class creator', request(['class:view', 'class:create'])],
|
||||
['class editor', request(['class:view', 'class:edit'])],
|
||||
['super admin', request([], true)],
|
||||
])('allows %s to list all classes', async (_label, req) => {
|
||||
await controller.findAll({}, req);
|
||||
|
||||
expect(service.getAccessibleClassIds).toHaveBeenCalledWith(21, true);
|
||||
expect(service.findAll).toHaveBeenCalledWith({}, undefined);
|
||||
});
|
||||
|
||||
it('keeps view-only users scoped to their assigned classes', async () => {
|
||||
service.getAccessibleClassIds.mockResolvedValue([8]);
|
||||
|
||||
await controller.findAll({}, request(['class:view']));
|
||||
|
||||
expect(service.getAccessibleClassIds).toHaveBeenCalledWith(21, false);
|
||||
expect(service.findAll).toHaveBeenCalledWith({}, [8]);
|
||||
});
|
||||
|
||||
it('allows a class creator to read an unassigned class through every detail endpoint', async () => {
|
||||
const req = request(['class:view', 'class:create']);
|
||||
|
||||
await controller.findOne('8', req);
|
||||
await controller.getSchedule('8', {}, req);
|
||||
await controller.getAttendanceSummary('8', {}, req);
|
||||
await controller.getStudents('8', req);
|
||||
await controller.getTeachers('8', req);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledTimes(5);
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, true);
|
||||
});
|
||||
|
||||
it('requires a view-only user to be assigned before reading class details', async () => {
|
||||
await controller.findOne('8', request(['class:view']));
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryClassDto - query transformation', () => {
|
||||
const pipe = new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true });
|
||||
|
||||
it.each([
|
||||
['false', false],
|
||||
['0', false],
|
||||
['true', true],
|
||||
['1', true],
|
||||
])('transforms isArchived=%s to %s', async (input, expected) => {
|
||||
await expect(
|
||||
pipe.transform(
|
||||
{ isArchived: input },
|
||||
{ type: 'query', metatype: QueryClassDto, data: undefined },
|
||||
),
|
||||
).resolves.toEqual({ isArchived: expected });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
Request,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
@@ -45,6 +47,7 @@ const teacherRoleLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
||||
@Controller('classes')
|
||||
export class ClassesController {
|
||||
constructor(
|
||||
@@ -54,12 +57,20 @@ export class ClassesController {
|
||||
private readonly authz: AuthorizationService,
|
||||
) {}
|
||||
|
||||
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
|
||||
// Legacy: Manage (super_admin) or Update (class:edit) grants broad class access
|
||||
const canManageAll =
|
||||
private canManageAllClasses(req: AuthenticatedRequest): boolean {
|
||||
return (
|
||||
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
|
||||
this.authz.can(req, CaslAction.Update, SubjectName.Class);
|
||||
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
||||
this.authz.can(req, CaslAction.Create, SubjectName.Class) ||
|
||||
this.authz.can(req, CaslAction.Update, SubjectName.Class)
|
||||
);
|
||||
}
|
||||
|
||||
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
|
||||
return this.service.assertClassAccess(
|
||||
req.user.id,
|
||||
classId,
|
||||
this.canManageAllClasses(req),
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -67,8 +78,7 @@ export class ClassesController {
|
||||
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.authz.can(req, CaslAction.Manage, SubjectName.Class) ||
|
||||
this.authz.can(req, CaslAction.Update, SubjectName.Class),
|
||||
this.canManageAllClasses(req),
|
||||
);
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ArrayNotEmpty,
|
||||
ValidateNested,
|
||||
Min,
|
||||
IsBoolean,
|
||||
} from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
|
||||
@@ -160,6 +161,7 @@ export class QueryClassDto {
|
||||
if (value === 'false' || value === '0') return false;
|
||||
return value;
|
||||
})
|
||||
@IsBoolean()
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -70,10 +69,6 @@ export class UpdateUserDto {
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
|
||||
@@ -92,4 +92,10 @@ describe('RBAC DTO id arrays', () => {
|
||||
])('rejects invalid, duplicate, or non-positive ids for %p', async (metatype, value) => {
|
||||
await expect(pipe.transform(value, { type: 'body', metatype })).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it('strips the retired isActive field from account updates', async () => {
|
||||
await expect(
|
||||
pipe.transform({ name: 'Alice', isActive: false }, { type: 'body', metatype: UpdateUserDto }),
|
||||
).resolves.toEqual({ name: 'Alice' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,12 @@ describe('RbacService seedData', () => {
|
||||
save: jest.fn(async (value: any) => value),
|
||||
find: jest.fn(async () => [systemAdminRole]),
|
||||
};
|
||||
const userRepo = { count: jest.fn(async () => 1), create: jest.fn(), save: jest.fn() };
|
||||
const userRepo = {
|
||||
update: jest.fn(async () => ({ affected: 0 })),
|
||||
count: jest.fn(async () => 1),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new RbacService(
|
||||
permRepo as never,
|
||||
@@ -101,7 +106,12 @@ describe('RbacService seedData', () => {
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => [teacherRole]),
|
||||
};
|
||||
const userRepo = { count: jest.fn(async () => 1), create: jest.fn(), save: jest.fn() };
|
||||
const userRepo = {
|
||||
update: jest.fn(async () => ({ affected: 0 })),
|
||||
count: jest.fn(async () => 1),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new RbacService(
|
||||
permRepo as never,
|
||||
@@ -184,6 +194,7 @@ describe('RbacService legacy role consolidation', () => {
|
||||
remove: jest.fn(async (value) => value),
|
||||
};
|
||||
const userRepo = {
|
||||
update: jest.fn(async () => ({ affected: 0 })),
|
||||
count: jest.fn(async () => 1),
|
||||
findOne: jest.fn(async () => user),
|
||||
save: jest.fn(async (value) => value),
|
||||
|
||||
@@ -296,6 +296,13 @@ export class RbacService {
|
||||
}
|
||||
|
||||
async seedData(): Promise<void> {
|
||||
const restoredLegacyUsers = await this.userRepo.update({ isActive: false }, { isActive: true });
|
||||
if (restoredLegacyUsers.affected) {
|
||||
this.logger.log(
|
||||
`已恢复 ${restoredLegacyUsers.affected} 个旧版禁用账号,账号状态现统一由归档管理`,
|
||||
);
|
||||
}
|
||||
|
||||
// Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL)
|
||||
for (const p of PRESET_PERMISSIONS) {
|
||||
const exists = await this.permRepo.findOne({ where: { code: p.code } });
|
||||
@@ -568,7 +575,6 @@ export class RbacService {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
isActive: u.isActive,
|
||||
isArchived: u.isArchived,
|
||||
studentStatus: statusMap.get(u.id) || null,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
@@ -597,7 +603,7 @@ export class RbacService {
|
||||
|
||||
async updateUser(
|
||||
id: number,
|
||||
dto: { username?: string; name?: string; isActive?: boolean; roleIds?: number[] },
|
||||
dto: { username?: string; name?: string; roleIds?: number[] },
|
||||
) {
|
||||
const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] });
|
||||
if (!user) throw new Error('用户不存在');
|
||||
@@ -607,7 +613,6 @@ export class RbacService {
|
||||
user.username = dto.username;
|
||||
}
|
||||
if (dto.name !== undefined) user.name = dto.name;
|
||||
if (dto.isActive !== undefined) user.isActive = dto.isActive;
|
||||
if (dto.roleIds !== undefined) {
|
||||
user.roles = dto.roleIds.length > 0 ? await this.resolveRoles(dto.roleIds) : [];
|
||||
}
|
||||
@@ -634,7 +639,7 @@ export class RbacService {
|
||||
async restoreUser(id: number) {
|
||||
const user = await this.userRepo.findOne({ where: { id } });
|
||||
if (!user) throw new Error('用户不存在');
|
||||
await this.userRepo.update(id, { isArchived: false });
|
||||
await this.userRepo.update(id, { isArchived: false, isActive: true });
|
||||
return { message: '用户已恢复' };
|
||||
}
|
||||
|
||||
@@ -766,7 +771,8 @@ export class RbacService {
|
||||
.where('(role.code IN (:...roleCodes) OR role.name IN (:...roleNames))', {
|
||||
roleCodes: teacherRoleCodes,
|
||||
roleNames: teacherRoleNames,
|
||||
});
|
||||
})
|
||||
.andWhere('u.isArchived = :isArchived', { isArchived: false });
|
||||
|
||||
if (query?.search) {
|
||||
qb.andWhere('(u.name LIKE :s OR u.username LIKE :s)', { s: `%${query.search}%` });
|
||||
@@ -795,7 +801,6 @@ export class RbacService {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
name: u.name,
|
||||
isActive: u.isActive,
|
||||
profile: u.profile,
|
||||
lastLoginAt: u.lastLoginAt,
|
||||
roles: u.roles || [],
|
||||
|
||||
@@ -140,7 +140,7 @@ export class StudentsService {
|
||||
: [];
|
||||
const teacherMap = new Map<number, { id: number; name: string; username: string }>();
|
||||
for (const assignment of assignments) {
|
||||
if (!assignment.user || !assignment.user.isActive || assignment.user.isArchived) continue;
|
||||
if (!assignment.user || assignment.user.isArchived) continue;
|
||||
teacherMap.set(assignment.userId, {
|
||||
id: assignment.userId,
|
||||
name: assignment.user.name || assignment.user.username,
|
||||
|
||||
Reference in New Issue
Block a user