fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View File

@@ -25,6 +25,13 @@ import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity';
import { RequirePermission } from '../auth/decorators/permission.decorator';
interface RequestUser {
id: number;
username: string;
permissions?: string[];
isSuperAdmin?: boolean;
}
@UseGuards(JwtAuthGuard)
@Controller('class-schedules')
export class SchedulesController {
@@ -34,24 +41,48 @@ export class SchedulesController {
private readonly notificationsService: NotificationsService,
) {}
private canManageAllSchedules(user: RequestUser): boolean {
return (
user.isSuperAdmin === true ||
user.permissions?.includes('schedule:edit') === true ||
user.permissions?.includes('class:edit') === true
);
}
@Get()
@RequirePermission('schedule:view')
findAll(@Query() query: QueryScheduleDto) {
return this.service.findAll(query);
async findAll(@Query() query: QueryScheduleDto, @Request() req: { user: RequestUser }) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllSchedules(req.user),
);
return this.service.findAll(query, classIds);
}
@Get('weekly')
@RequirePermission('schedule:view')
getWeeklyView(@Query() query: WeeklyViewQueryDto) {
return this.service.getWeeklyView(query);
async getWeeklyView(@Query() query: WeeklyViewQueryDto, @Request() req: { user: RequestUser }) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllSchedules(req.user),
);
return this.service.getWeeklyView(query, classIds);
}
@Get('classes/:classId/teachers')
@RequirePermission('schedule:view')
async getClassTeachers(@Param('classId') classId: string, @Request() req: { user: RequestUser }) {
const classIds = await this.service.getAccessibleClassIds(
req.user.id,
this.canManageAllSchedules(req.user),
);
if (classIds && !classIds.includes(+classId)) return [];
return this.service.getClassTeachers(+classId);
}
@Get('classroom/:id/occupancy')
@RequirePermission('schedule:view')
getClassroomOccupancy(
@Param('id') id: string,
@Query('date') date?: string,
) {
getClassroomOccupancy(@Param('id') id: string, @Query('date') date?: string) {
return this.service.getClassroomOccupancy(+id, date);
}
@@ -63,7 +94,10 @@ export class SchedulesController {
@Post()
@RequirePermission('schedule:create')
async create(@Body() dto: CreateScheduleDto, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }) {
async create(
@Body() dto: CreateScheduleDto,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.service.create(dto);
@@ -83,9 +117,16 @@ export class SchedulesController {
if (error instanceof ConflictException) {
try {
const conflicts = await this.service.checkConflict(
dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate,
dto.classroomId,
dto.weekDay,
dto.startTime,
dto.endTime,
dto.startDate,
dto.endDate,
);
const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter((id): id is number => id != null))];
const teacherIds = [
...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)),
];
if (teacherIds.length > 0) {
void this.notificationsService.create({
recipientIds: teacherIds,
@@ -127,9 +168,16 @@ export class SchedulesController {
if (error instanceof ConflictException) {
try {
const conflicts = await this.service.checkConflict(
existing.classroomId, existing.weekDay, existing.startTime, existing.endTime, existing.startDate, existing.endDate,
existing.classroomId,
existing.weekDay,
existing.startTime,
existing.endTime,
existing.startDate,
existing.endDate,
);
const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter((id): id is number => id != null))];
const teacherIds = [
...new Set(conflicts.map((c) => c.teacherId).filter((id): id is number => id != null)),
];
if (teacherIds.length > 0) {
void this.notificationsService.create({
recipientIds: teacherIds,
@@ -146,7 +194,10 @@ export class SchedulesController {
@Delete(':id')
@RequirePermission('schedule:delete')
async remove(@Param('id') id: string, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }) {
async remove(
@Param('id') id: string,
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({

View File

@@ -1,13 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClassSchedule, Class, ClassroomRental } from '../entities';
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
import { SchedulesService } from './schedules.service';
import { SchedulesController } from './schedules.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental]), OperationLogsModule, NotificationsModule],
imports: [
TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental, ClassTeacher]),
OperationLogsModule,
NotificationsModule,
],
controllers: [SchedulesController],
providers: [SchedulesService],
exports: [SchedulesService],

View File

@@ -0,0 +1,41 @@
import { SchedulesService } from './schedules.service';
const createQb = () => ({
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue([]),
});
describe('SchedulesService — teacher class scope', () => {
it('filters schedule list to assigned classes when no class filter is selected', async () => {
const qb = createQb();
const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const service = new SchedulesService(
scheduleRepo as never,
{} as never,
{} as never,
{} as never,
);
await service.findAll({}, [3, 5]);
expect(qb.andWhere).toHaveBeenCalledWith('cs.classId IN (:...accessibleClassIds)', {
accessibleClassIds: [3, 5],
});
});
it('returns no schedules when teacher has no assigned classes', async () => {
const qb = createQb();
const scheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const service = new SchedulesService(
scheduleRepo as never,
{} as never,
{} as never,
{} as never,
);
await expect(service.findAll({}, [])).resolves.toEqual([]);
expect(qb.getMany).not.toHaveBeenCalled();
});
});

View File

@@ -6,6 +6,7 @@ import { SchedulesService } from './schedules.service';
import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Class } from '../entities/class.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
/** Build a mock query-builder where each chain method returns `this`. */
function mockQueryBuilder<T>(results: T[] = []) {
@@ -34,7 +35,14 @@ describe('SchedulesService — checkConflict', () => {
SchedulesService,
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
{
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
},
{
provide: getRepositoryToken(ClassTeacher),
useValue: { find: jest.fn().mockResolvedValue([]) },
},
],
}).compile();
@@ -103,7 +111,12 @@ describe('SchedulesService — checkConflict', () => {
it('overlapping classroom rental → ConflictException', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([
{ id: 10, startDate: '2026-03-01', endDate: '2026-03-31', status: 'active' } as ClassroomRental,
{
id: 10,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
} as ClassroomRental,
]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
@@ -135,7 +148,14 @@ describe('SchedulesService — getClassroomOccupancy', () => {
SchedulesService,
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
{
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
},
{
provide: getRepositoryToken(ClassTeacher),
useValue: { find: jest.fn().mockResolvedValue([]) },
},
],
}).compile();

View File

@@ -1,7 +1,12 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClassSchedule, Class, ClassroomRental } from '../entities';
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
import {
CreateScheduleDto,
@@ -18,23 +23,75 @@ export class SchedulesService {
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
@InjectRepository(ClassroomRental)
private readonly rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher)
private readonly classTeacherRepo: Repository<ClassTeacher>,
) {}
async findAll(query: QueryScheduleDto) {
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 findAll(query: QueryScheduleDto, accessibleClassIds?: number[]) {
const qb = this.scheduleRepo.createQueryBuilder('cs');
if (query.classroomId) qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
if (query.classroomId)
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });
else if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return [];
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.weekDay) qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay });
if (query.startDate) qb.andWhere('cs.startDate >= :startDate', { startDate: query.startDate });
if (query.endDate) qb.andWhere('cs.endDate <= :endDate', { endDate: query.endDate });
qb.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC');
qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC');
return qb.getMany();
}
async getClassTeachers(classId: number) {
const teachers = await this.classTeacherRepo.find({
where: { classId },
relations: ['user'],
order: { roleType: 'ASC', subject: 'ASC' },
});
return teachers.map((teacher) => ({
id: teacher.id,
userId: teacher.userId,
username: teacher.user?.username,
name: teacher.user?.name,
roleType: teacher.roleType,
subject: teacher.subject,
}));
}
private async normalizeTeacherForSchedule<
T extends { classId?: number; subject?: string; teacherId?: number | null },
>(dto: T): Promise<T> {
if (!dto.classId || !dto.subject || dto.teacherId) return dto;
const teachers = await this.classTeacherRepo.find({
where: { classId: dto.classId, roleType: 'subject_teacher', subject: dto.subject },
});
if (teachers.length === 1) {
dto.teacherId = teachers[0].userId;
}
return dto;
}
private async assertTeacherAssignedToClass(
classId: number | null | undefined,
teacherId: number | null | undefined,
) {
if (!classId || !teacherId) return;
const assignment = await this.classTeacherRepo.findOne({
where: { classId, userId: teacherId },
});
if (!assignment) throw new BadRequestException('只能选择该班级已配置的教师');
}
async findOne(id: number) {
const schedule = await this.scheduleRepo.findOne({ where: { id } });
if (!schedule) throw new NotFoundException('排课记录不存在');
@@ -42,7 +99,16 @@ export class SchedulesService {
}
async create(dto: CreateScheduleDto) {
await this.checkConflict(dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate);
await this.normalizeTeacherForSchedule(dto);
await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId);
await this.checkConflict(
dto.classroomId,
dto.weekDay,
dto.startTime,
dto.endTime,
dto.startDate,
dto.endDate,
);
const schedule = this.scheduleRepo.create(dto);
const saved = await this.scheduleRepo.save(schedule);
@@ -61,6 +127,16 @@ export class SchedulesService {
const startDate = dto.startDate ?? existing.startDate;
const endDate = dto.endDate ?? existing.endDate;
const normalized = await this.normalizeTeacherForSchedule({
...dto,
classId: dto.classId ?? existing.classId ?? undefined,
subject: dto.subject ?? existing.subject,
});
if (dto.teacherId === undefined && normalized.teacherId !== undefined) {
dto.teacherId = normalized.teacherId;
}
const teacherId = dto.teacherId ?? existing.teacherId;
await this.assertTeacherAssignedToClass(dto.classId ?? existing.classId, teacherId);
await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id);
await this.scheduleRepo.update(id, dto);
@@ -120,11 +196,15 @@ export class SchedulesService {
return conflicts;
}
async getWeeklyView(query: WeeklyViewQueryDto) {
async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) {
const qb = this.scheduleRepo.createQueryBuilder('cs');
if (query.classroomId) {
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
}
if (accessibleClassIds) {
if (accessibleClassIds.length === 0) return {};
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.startDate) {
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
}
@@ -154,16 +234,14 @@ export class SchedulesService {
.createQueryBuilder('cs')
.where('cs.classroomId = :classroomId', { classroomId })
.andWhere('cs.status = :status', { status: 'active' })
.andWhere('cs.scheduleType IN (:...scheduleTypes)', { scheduleTypes: ['INTERNAL', 'RENTAL'] });
.andWhere('cs.scheduleType IN (:...scheduleTypes)', {
scheduleTypes: ['INTERNAL', 'RENTAL'],
});
if (date) {
qb.andWhere('cs.startDate <= :date', { date })
.andWhere('cs.endDate >= :date', { date });
qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date });
}
return qb
.orderBy('cs.weekDay', 'ASC')
.addOrderBy('cs.startTime', 'ASC')
.getMany();
return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany();
}
}