forked from wangziqi/gongxue-base
fix permissions and teacher attendance workflows
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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],
|
||||
|
||||
41
apps/server/src/schedules/schedules.scope.spec.ts
Normal file
41
apps/server/src/schedules/schedules.scope.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user