forked from wangziqi/gongxue-base
fix permissions and teacher attendance workflows
This commit is contained in:
@@ -31,6 +31,16 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: RequestUser;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('classes')
|
||||
export class ClassesController {
|
||||
@@ -40,30 +50,48 @@ export class ClassesController {
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
|
||||
const canManageAll =
|
||||
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true;
|
||||
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('class:view')
|
||||
findAll(@Query() query: QueryClassDto) {
|
||||
return this.service.findAll(query);
|
||||
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true,
|
||||
);
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('class:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
async findOne(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.findOne(+id);
|
||||
}
|
||||
|
||||
@Get(':id/schedule')
|
||||
@RequirePermission('class:view')
|
||||
getSchedule(@Param('id') id: string, @Query() query: QueryClassScheduleDto) {
|
||||
async getSchedule(
|
||||
@Param('id') id: string,
|
||||
@Query() query: QueryClassScheduleDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getSchedule(+id, query);
|
||||
}
|
||||
|
||||
@Get(':id/attendance-summary')
|
||||
@RequirePermission('class:view')
|
||||
getAttendanceSummary(
|
||||
async getAttendanceSummary(
|
||||
@Param('id') id: string,
|
||||
@Query() query: QueryClassAttendanceSummaryDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getAttendanceSummary(+id, query);
|
||||
}
|
||||
|
||||
@@ -86,14 +114,10 @@ export class ClassesController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/** 批量导入学生到班级(通过钉钉用户ID) */
|
||||
@Post(':id/students/import')
|
||||
@RequirePermission('class:edit')
|
||||
async batchImportStudents(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: BatchImportStudentsDto,
|
||||
) {
|
||||
async batchImportStudents(@Param('id') id: string, @Body() dto: BatchImportStudentsDto) {
|
||||
return this.service.batchImportStudents(+id, dto.users);
|
||||
}
|
||||
|
||||
@@ -113,11 +137,7 @@ export class ClassesController {
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('class:edit')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateClassDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -154,7 +174,12 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/roster/export')
|
||||
@RequirePermission('class:view')
|
||||
async exportRoster(@Param('id') id: string, @Res() res: Response) {
|
||||
async exportRoster(
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const classEntity = await this.service.findOne(+id);
|
||||
const classStudents = await this.service.getStudents(+id);
|
||||
|
||||
@@ -192,17 +217,14 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/students')
|
||||
@RequirePermission('class:view')
|
||||
getStudents(@Param('id') id: string) {
|
||||
async getStudents(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getStudents(+id);
|
||||
}
|
||||
|
||||
@Post(':id/students')
|
||||
@RequirePermission('class:edit')
|
||||
async addStudents(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddStudentsDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.addStudents(+id, dto.studentIds);
|
||||
await this.logService.log({
|
||||
@@ -255,17 +277,14 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/teachers')
|
||||
@RequirePermission('class:view')
|
||||
getTeachers(@Param('id') id: string) {
|
||||
async getTeachers(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getTeachers(+id);
|
||||
}
|
||||
|
||||
@Post(':id/teachers')
|
||||
@RequirePermission('class:edit')
|
||||
async addTeacher(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddTeacherDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.addTeacher(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -290,6 +309,29 @@ export class ClassesController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/teacher-assignments/:assignmentId')
|
||||
@RequirePermission('class:edit')
|
||||
async removeTeacherAssignment(
|
||||
@Param('id') id: string,
|
||||
@Param('assignmentId') assignmentId: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '班级管理',
|
||||
action: '移除教师角色',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: `移除教师分配${assignmentId}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/teachers/:userId')
|
||||
@RequirePermission('class:edit')
|
||||
async removeTeacher(
|
||||
|
||||
64
apps/server/src/classes/classes.scope.spec.ts
Normal file
64
apps/server/src/classes/classes.scope.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { ClassesService } from './classes.service';
|
||||
|
||||
describe('ClassesService — teacher data scope', () => {
|
||||
const classRepo = { find: jest.fn() };
|
||||
const classStudentRepo = { createQueryBuilder: jest.fn() };
|
||||
const classTeacherRepo = { find: jest.fn(), findOne: jest.fn() };
|
||||
|
||||
const service = new ClassesService(
|
||||
classRepo as never,
|
||||
classStudentRepo as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('returns only class ids assigned to a teacher', async () => {
|
||||
classTeacherRepo.find.mockResolvedValue([{ classId: 3 }, { classId: 5 }, { classId: 3 }]);
|
||||
|
||||
await expect(service.getAccessibleClassIds(21, false)).resolves.toEqual([3, 5]);
|
||||
});
|
||||
|
||||
it('rejects access to a class outside the teacher assignments', async () => {
|
||||
classTeacherRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.assertClassAccess(21, 9, false)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows class managers to access any class', async () => {
|
||||
await expect(service.assertClassAccess(21, 9, true)).resolves.toBeUndefined();
|
||||
expect(classTeacherRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears denormalized teacher ids when the last teacher for that role is removed', async () => {
|
||||
const classRepo = { update: jest.fn() };
|
||||
const classTeacherRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const service = new ClassesService(
|
||||
classRepo as never,
|
||||
{} as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.removeTeacher(8, 21);
|
||||
|
||||
expect(classRepo.update).toHaveBeenCalledWith(8, {
|
||||
headTeacherId: null,
|
||||
lifeTeacherId: null,
|
||||
academicTeacherId: null,
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,31 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In, Like } from 'typeorm';
|
||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Classroom, Student, StudentDingMapping } from '../entities';
|
||||
import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto, BatchImportStudentsDto } from './dto/class.dto';
|
||||
import {
|
||||
Class,
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
Classroom,
|
||||
Student,
|
||||
StudentDingMapping,
|
||||
} from '../entities';
|
||||
import { normalizeDateOnly } from '../database/date-normalization';
|
||||
import {
|
||||
CreateClassDto,
|
||||
UpdateClassDto,
|
||||
QueryClassDto,
|
||||
AddTeacherDto,
|
||||
QueryClassScheduleDto,
|
||||
QueryClassAttendanceSummaryDto,
|
||||
BatchImportStudentsDto,
|
||||
} from './dto/class.dto';
|
||||
|
||||
interface RawStudentCount {
|
||||
classId: string;
|
||||
@@ -28,7 +51,19 @@ export class ClassesService {
|
||||
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryClassDto) {
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
if (canManageAll) return undefined;
|
||||
const assignments = await this.classTeacherRepo.find({ where: { userId } });
|
||||
return [...new Set(assignments.map((assignment) => assignment.classId))];
|
||||
}
|
||||
|
||||
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
|
||||
if (canManageAll) return;
|
||||
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
|
||||
if (!assignment) throw new ForbiddenException('只能访问自己被分配的班级');
|
||||
}
|
||||
|
||||
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
|
||||
let where: Record<string, unknown> = {};
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.classType) where.classType = query.classType;
|
||||
@@ -36,6 +71,11 @@ export class ClassesService {
|
||||
// Default: hide archived, unless explicitly requested
|
||||
where.isArchived = query.isArchived ?? false;
|
||||
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
where.id = In(accessibleClassIds);
|
||||
}
|
||||
|
||||
const classes = await this.classRepo.find({
|
||||
where,
|
||||
order: { createdAt: 'DESC' as const },
|
||||
@@ -96,14 +136,21 @@ export class ClassesService {
|
||||
async create(dto: CreateClassDto) {
|
||||
const { studentIds, teachers, users, ...classData } = dto;
|
||||
|
||||
|
||||
const cls = this.classRepo.create(classData);
|
||||
const cls = this.classRepo.create({
|
||||
...classData,
|
||||
startDate: normalizeDateOnly(classData.startDate) ?? undefined,
|
||||
endDate: normalizeDateOnly(classData.endDate) ?? undefined,
|
||||
});
|
||||
const saved = await this.classRepo.save(cls);
|
||||
|
||||
// add students
|
||||
if (studentIds?.length) {
|
||||
const entries = studentIds.map((sid: number) =>
|
||||
this.classStudentRepo.create({ classId: saved.id, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }),
|
||||
this.classStudentRepo.create({
|
||||
classId: saved.id,
|
||||
studentId: sid,
|
||||
joinDate: new Date().toISOString().split('T')[0],
|
||||
}),
|
||||
);
|
||||
await this.classStudentRepo.save(entries);
|
||||
}
|
||||
@@ -111,7 +158,12 @@ export class ClassesService {
|
||||
// add teachers
|
||||
if (teachers?.length) {
|
||||
const entries = teachers.map((t) =>
|
||||
this.classTeacherRepo.create({ classId: saved.id, userId: t.userId, roleType: t.roleType, subject: t.subject }),
|
||||
this.classTeacherRepo.create({
|
||||
classId: saved.id,
|
||||
userId: t.userId,
|
||||
roleType: t.roleType,
|
||||
subject: t.subject,
|
||||
}),
|
||||
);
|
||||
await this.classTeacherRepo.save(entries);
|
||||
|
||||
@@ -127,37 +179,41 @@ export class ClassesService {
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
|
||||
|
||||
async batchImportStudents(classId: number, users: Array<{
|
||||
dingUserId: string; name: string; mobile?: string;
|
||||
}>): Promise<{ imported: number; skipped: number }> {
|
||||
async batchImportStudents(
|
||||
classId: number,
|
||||
users: Array<{
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile?: string;
|
||||
}>,
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
||||
|
||||
if (users.length === 0) return { imported: 0, skipped: 0 };
|
||||
|
||||
const dingUserIds = users.map(u => u.dingUserId);
|
||||
const dingUserIds = users.map((u) => u.dingUserId);
|
||||
|
||||
// 1. Fetch all existing ding mappings in one query
|
||||
const existingMappings = await this.studentDingMappingRepo.find({
|
||||
where: { dingUserId: In(dingUserIds) },
|
||||
});
|
||||
const dingToStudentId = new Map(existingMappings.map(m => [m.dingUserId, m.studentId]));
|
||||
const dingToStudentId = new Map(existingMappings.map((m) => [m.dingUserId, m.studentId]));
|
||||
|
||||
// 2. Batch create students for new dingUserIds
|
||||
const newUsers = users.filter(u => !dingToStudentId.has(u.dingUserId));
|
||||
const newUsers = users.filter((u) => !dingToStudentId.has(u.dingUserId));
|
||||
if (newUsers.length > 0) {
|
||||
const newStudents = newUsers.map(u =>
|
||||
const newStudents = newUsers.map((u) =>
|
||||
this.studentRepo.create({
|
||||
name: u.name,
|
||||
phone: u.mobile || `dt_${u.dingUserId}`,
|
||||
status: 'active',
|
||||
})
|
||||
}),
|
||||
);
|
||||
const savedStudents = await this.studentRepo.save(newStudents);
|
||||
|
||||
const newMappings = savedStudents.map((s, i) =>
|
||||
this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id })
|
||||
this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }),
|
||||
);
|
||||
await this.studentDingMappingRepo.save(newMappings);
|
||||
|
||||
@@ -180,12 +236,14 @@ export class ClassesService {
|
||||
|
||||
// 4. Batch insert new class-student records
|
||||
const newClassStudents = allStudentIds
|
||||
.filter(sid => !alreadyInClass.has(sid))
|
||||
.map(studentId =>
|
||||
.filter((sid) => !alreadyInClass.has(sid))
|
||||
.map((studentId) =>
|
||||
this.classStudentRepo.create({
|
||||
classId, studentId, status: 'active',
|
||||
classId,
|
||||
studentId,
|
||||
status: 'active',
|
||||
joinDate: new Date().toISOString().slice(0, 10),
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
if (newClassStudents.length > 0) {
|
||||
@@ -197,7 +255,15 @@ export class ClassesService {
|
||||
async update(id: number, dto: UpdateClassDto) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
await this.classRepo.update(id, dto);
|
||||
await this.classRepo.update(id, {
|
||||
...dto,
|
||||
...(dto.startDate !== undefined
|
||||
? { startDate: normalizeDateOnly(dto.startDate) ?? undefined }
|
||||
: {}),
|
||||
...(dto.endDate !== undefined
|
||||
? { endDate: normalizeDateOnly(dto.endDate) ?? undefined }
|
||||
: {}),
|
||||
});
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
@@ -226,7 +292,6 @@ export class ClassesService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
async getStudents(classId: number) {
|
||||
return this.classStudentRepo.find({
|
||||
where: { classId },
|
||||
@@ -243,7 +308,11 @@ export class ClassesService {
|
||||
const newIds = studentIds.filter((id) => !existingIds.has(id));
|
||||
|
||||
const entries = newIds.map((sid) =>
|
||||
this.classStudentRepo.create({ classId, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }),
|
||||
this.classStudentRepo.create({
|
||||
classId,
|
||||
studentId: sid,
|
||||
joinDate: new Date().toISOString().split('T')[0],
|
||||
}),
|
||||
);
|
||||
if (entries.length) await this.classStudentRepo.save(entries);
|
||||
|
||||
@@ -268,7 +337,12 @@ export class ClassesService {
|
||||
});
|
||||
if (existing) throw new BadRequestException('该教师已分配此角色');
|
||||
|
||||
const entry = this.classTeacherRepo.create({ classId, userId: dto.userId, roleType: dto.roleType, subject: dto.subject });
|
||||
const entry = this.classTeacherRepo.create({
|
||||
classId,
|
||||
userId: dto.userId,
|
||||
roleType: dto.roleType,
|
||||
subject: dto.subject,
|
||||
});
|
||||
await this.classTeacherRepo.save(entry);
|
||||
|
||||
await this.syncClassTeacherIds(classId);
|
||||
@@ -281,18 +355,22 @@ export class ClassesService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeTeacherAssignment(classId: number, assignmentId: number) {
|
||||
await this.classTeacherRepo.delete({ id: assignmentId, classId });
|
||||
await this.syncClassTeacherIds(classId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async syncClassTeacherIds(classId: number) {
|
||||
const teachers = await this.classTeacherRepo.find({ where: { classId } });
|
||||
const updates: Record<string, number> = {};
|
||||
const head = teachers.find((t) => t.roleType === 'head_teacher');
|
||||
const life = teachers.find((t) => t.roleType === 'life_teacher');
|
||||
const academic = teachers.find((t) => t.roleType === 'academic_teacher');
|
||||
if (head) updates.headTeacherId = head.userId;
|
||||
if (life) updates.lifeTeacherId = life.userId;
|
||||
if (academic) updates.academicTeacherId = academic.userId;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.classRepo.update(classId, updates);
|
||||
}
|
||||
await this.classRepo.update(classId, {
|
||||
headTeacherId: head?.userId ?? null,
|
||||
lifeTeacherId: life?.userId ?? null,
|
||||
academicTeacherId: academic?.userId ?? null,
|
||||
} as Partial<Class>);
|
||||
}
|
||||
|
||||
async getSchedule(classId: number, query: QueryClassScheduleDto) {
|
||||
|
||||
Reference in New Issue
Block a user