forked from wangziqi/gongxue-base
fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
126
apps/server/src/schedules/schedules.controller.spec.ts
Normal file
126
apps/server/src/schedules/schedules.controller.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
const teacherRequest = {
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||
headers: {},
|
||||
};
|
||||
|
||||
const scheduleDto = {
|
||||
classId: 8,
|
||||
classroomId: 3,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '数学',
|
||||
};
|
||||
|
||||
describe('SchedulesController — class data scope', () => {
|
||||
const service = {
|
||||
getAccessibleClassIds: jest.fn(),
|
||||
assertClassAccess: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
getClassroomOccupancy: jest.fn(),
|
||||
maskScheduleOccupancy: jest.fn((schedule) => ({
|
||||
...schedule,
|
||||
id: null,
|
||||
classId: null,
|
||||
subject: '已占用',
|
||||
teacherId: null,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
})),
|
||||
checkConflict: jest.fn(),
|
||||
};
|
||||
const logService = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const notificationsService = { create: jest.fn() };
|
||||
const ability = { can: jest.fn().mockReturnValue(false) };
|
||||
const authzService = { abilityForRequest: jest.fn().mockReturnValue(ability) };
|
||||
let controller: SchedulesController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
ability.can.mockReturnValue(false);
|
||||
service.getAccessibleClassIds.mockResolvedValue([8]);
|
||||
controller = new SchedulesController(
|
||||
service as unknown as SchedulesService,
|
||||
logService as unknown as OperationLogsService,
|
||||
notificationsService as unknown as NotificationsService,
|
||||
authzService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the requested class before creating a schedule', async () => {
|
||||
service.create.mockResolvedValue({ id: 1, ...scheduleDto });
|
||||
|
||||
await controller.create(scheduleDto, teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(service.create).toHaveBeenCalledWith(scheduleDto);
|
||||
});
|
||||
|
||||
it('checks both the current and destination class before moving a schedule', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
|
||||
service.update.mockResolvedValue({ id: 4, ...scheduleDto, classId: 9 });
|
||||
|
||||
await controller.update('4', { classId: 9 }, teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
|
||||
expect(service.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 9, false);
|
||||
});
|
||||
|
||||
it('checks the owning class before returning full schedule details', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
|
||||
|
||||
await controller.findOne('4', teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
});
|
||||
|
||||
it('checks the owning class before deleting a schedule', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
|
||||
service.remove.mockResolvedValue({ success: true });
|
||||
|
||||
await controller.remove('4', teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(service.remove).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('returns only masked occupancy blocks from the classroom occupancy endpoint', async () => {
|
||||
service.getClassroomOccupancy.mockResolvedValue([
|
||||
{ id: 2, classId: 99, subject: '英语', teacherId: 7, notes: '隐私', classroomId: 3 },
|
||||
]);
|
||||
|
||||
const result = await controller.getClassroomOccupancy('3', undefined, teacherRequest as never);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: null,
|
||||
classId: null,
|
||||
subject: '已占用',
|
||||
teacherId: null,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain('英语');
|
||||
expect(JSON.stringify(result)).not.toContain('隐私');
|
||||
});
|
||||
|
||||
it('rejects records without a class instead of exposing full details to a scoped teacher', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto, classId: null });
|
||||
|
||||
await expect(controller.findOne('4', teacherRequest as never)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,10 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ForbiddenException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AuthorizationService,
|
||||
CaslAction,
|
||||
SubjectName,
|
||||
} from '../authorization';
|
||||
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
@@ -56,6 +53,22 @@ export class SchedulesController {
|
||||
);
|
||||
}
|
||||
|
||||
private assertClassAccess(req: { user: RequestUser }, classId: number) {
|
||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllSchedules(req));
|
||||
}
|
||||
|
||||
private async getAuthorizedSchedule(id: number, req: { user: RequestUser }) {
|
||||
const schedule = await this.service.findOne(id);
|
||||
if (schedule.classId == null) {
|
||||
if (!this.canManageAllSchedules(req)) {
|
||||
throw new ForbiddenException('无权访问该排课详情');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
await this.assertClassAccess(req, schedule.classId);
|
||||
return schedule;
|
||||
}
|
||||
|
||||
@Get('lookups')
|
||||
@RequirePermission('schedule:view')
|
||||
async getLookups(@Request() req: { user: RequestUser }) {
|
||||
@@ -99,14 +112,29 @@ export class SchedulesController {
|
||||
|
||||
@Get('classroom/:id/occupancy')
|
||||
@RequirePermission('schedule:view')
|
||||
getClassroomOccupancy(@Param('id') id: string, @Query('date') date?: string) {
|
||||
return this.service.getClassroomOccupancy(+id, date);
|
||||
async getClassroomOccupancy(
|
||||
@Param('id') id: string,
|
||||
@Query('date') date: string | undefined,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const schedules = await this.service.getClassroomOccupancy(+id, date);
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllSchedules(req),
|
||||
);
|
||||
if (!classIds) return schedules.map((schedule) => ({ ...schedule, canViewDetails: true }));
|
||||
const allowed = new Set(classIds);
|
||||
return schedules.map((schedule) =>
|
||||
schedule.classId !== null && allowed.has(schedule.classId)
|
||||
? { ...schedule, canViewDetails: true }
|
||||
: this.service.maskScheduleOccupancy(schedule),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('schedule:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(+id);
|
||||
async findOne(@Param('id') id: string, @Request() req: { user: RequestUser }) {
|
||||
return this.getAuthorizedSchedule(+id, req);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -116,6 +144,7 @@ export class SchedulesController {
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.assertClassAccess(req as { user: RequestUser }, dto.classId);
|
||||
try {
|
||||
const result = await this.service.create(dto);
|
||||
await this.logService.log({
|
||||
@@ -152,7 +181,9 @@ export class SchedulesController {
|
||||
content: `教室${dto.classroomId} 周${dto.weekDay} ${dto.startTime}-${dto.endTime} 与已有排课冲突`,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Best-effort conflict notification must not hide the original conflict.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -166,7 +197,10 @@ export class SchedulesController {
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const existing = await this.service.findOne(+id);
|
||||
const existing = await this.getAuthorizedSchedule(+id, req as { user: RequestUser });
|
||||
if (dto.classId !== undefined && dto.classId !== existing.classId) {
|
||||
await this.assertClassAccess(req as { user: RequestUser }, dto.classId);
|
||||
}
|
||||
try {
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -203,7 +237,9 @@ export class SchedulesController {
|
||||
content: `教室${existing.classroomId} 周${existing.weekDay} ${existing.startTime}-${existing.endTime} (更新) 与已有排课冲突`,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Best-effort conflict notification must not hide the original conflict.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -216,6 +252,7 @@ export class SchedulesController {
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.getAuthorizedSchedule(+id, req as { user: RequestUser });
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
|
||||
import {
|
||||
ClassSchedule,
|
||||
Class,
|
||||
Classroom,
|
||||
ClassroomRental,
|
||||
ClassTeacher,
|
||||
AttendanceSession,
|
||||
} from '../entities';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
@@ -8,7 +15,14 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental, ClassTeacher]),
|
||||
TypeOrmModule.forFeature([
|
||||
ClassSchedule,
|
||||
Class,
|
||||
Classroom,
|
||||
ClassroomRental,
|
||||
ClassTeacher,
|
||||
AttendanceSession,
|
||||
]),
|
||||
OperationLogsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
|
||||
@@ -39,3 +39,67 @@ describe('SchedulesService — teacher class scope', () => {
|
||||
expect(qb.getMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesService — shared classroom occupancy visibility', () => {
|
||||
it('shows other classes as masked busy blocks while preserving assigned-class details', async () => {
|
||||
const qb = createQb();
|
||||
qb.getMany.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 3,
|
||||
classroomId: 10,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '数学',
|
||||
teacherId: 8,
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
notes: '本班备注',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
classId: 99,
|
||||
classroomId: 10,
|
||||
weekDay: 1,
|
||||
startTime: '10:00',
|
||||
endTime: '11:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '其他班隐私科目',
|
||||
teacherId: 9,
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
notes: '其他班备注',
|
||||
},
|
||||
]);
|
||||
const service = new SchedulesService(
|
||||
{ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const result = await service.getWeeklyView({}, [3]);
|
||||
const blocks = result[10][1];
|
||||
|
||||
expect(blocks[0]).toEqual(expect.objectContaining({ subject: '数学', canViewDetails: true }));
|
||||
expect(blocks[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
subject: '已占用',
|
||||
classId: null,
|
||||
teacherId: null,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(blocks[1])).not.toContain('其他班隐私科目');
|
||||
expect(JSON.stringify(blocks[1])).not.toContain('其他班备注');
|
||||
expect(qb.andWhere).not.toHaveBeenCalledWith(
|
||||
'cs.classId IN (:...accessibleClassIds)',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ 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';
|
||||
import { AttendanceSession } from '../entities/attendance-session.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
|
||||
/** Build a mock query-builder where each chain method returns `this`. */
|
||||
function mockQueryBuilder<T>(results: T[] = []) {
|
||||
@@ -20,6 +22,45 @@ function mockQueryBuilder<T>(results: T[] = []) {
|
||||
return qb;
|
||||
}
|
||||
|
||||
describe('SchedulesService — getLookups', () => {
|
||||
it('includes active classrooms that have never been scheduled', async () => {
|
||||
const classroom = { id: 7, name: '新教室', building: 'A座' } as Classroom;
|
||||
const classroomRepo = { find: jest.fn().mockResolvedValue([classroom]) };
|
||||
const scheduleQb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
distinct: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{
|
||||
provide: getRepositoryToken(ClassSchedule),
|
||||
useValue: { createQueryBuilder: jest.fn().mockReturnValue(scheduleQb) },
|
||||
},
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: {} },
|
||||
{ provide: getRepositoryToken(ClassTeacher), useValue: {} },
|
||||
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const service = module.get(SchedulesService);
|
||||
|
||||
await expect(service.getLookups([1])).resolves.toMatchObject({ classrooms: [classroom] });
|
||||
expect(classroomRepo.find).toHaveBeenCalledWith({
|
||||
where: expect.any(Object),
|
||||
select: ['id', 'name', 'building', 'floor', 'roomType'],
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesService — checkConflict', () => {
|
||||
let service: SchedulesService;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'createQueryBuilder'>>;
|
||||
@@ -34,6 +75,7 @@ describe('SchedulesService — checkConflict', () => {
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
@@ -43,6 +85,10 @@ describe('SchedulesService — checkConflict', () => {
|
||||
provide: getRepositoryToken(ClassTeacher),
|
||||
useValue: { find: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AttendanceSession),
|
||||
useValue: { count: jest.fn().mockResolvedValue(0) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -141,13 +187,13 @@ describe('SchedulesService — checkConflict', () => {
|
||||
describe('SchedulesService — getClassroomOccupancy', () => {
|
||||
let service: SchedulesService;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'createQueryBuilder'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
useValue: { createQueryBuilder: jest.fn() },
|
||||
@@ -156,6 +202,10 @@ describe('SchedulesService — getClassroomOccupancy', () => {
|
||||
provide: getRepositoryToken(ClassTeacher),
|
||||
useValue: { find: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AttendanceSession),
|
||||
useValue: { count: jest.fn().mockResolvedValue(0) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -191,3 +241,69 @@ describe('SchedulesService — getClassroomOccupancy', () => {
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('cs.endDate >= :date', { date: '2026-03-15' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesService — remove', () => {
|
||||
let service: SchedulesService;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'findOne' | 'remove'>>;
|
||||
let attendanceSessionRepo: jest.Mocked<Pick<Repository<AttendanceSession>, 'count'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{
|
||||
provide: getRepositoryToken(ClassSchedule),
|
||||
useValue: { findOne: jest.fn(), remove: jest.fn() },
|
||||
},
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
useValue: { createQueryBuilder: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ClassTeacher),
|
||||
useValue: { find: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AttendanceSession),
|
||||
useValue: { count: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SchedulesService>(SchedulesService);
|
||||
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
|
||||
attendanceSessionRepo = module.get(getRepositoryToken(AttendanceSession));
|
||||
});
|
||||
|
||||
it('deletes a schedule with no attendance sessions', async () => {
|
||||
const schedule = { id: 1, subject: '数学' } as ClassSchedule;
|
||||
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
|
||||
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
|
||||
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(0);
|
||||
|
||||
const result = await service.remove(1);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(scheduleRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
|
||||
expect(scheduleRepo.remove).toHaveBeenCalledWith(schedule);
|
||||
});
|
||||
|
||||
it('rejects deletion when attendance sessions exist', async () => {
|
||||
const schedule = { id: 2, subject: '英语' } as ClassSchedule;
|
||||
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
|
||||
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
|
||||
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(3);
|
||||
|
||||
await expect(service.remove(2)).rejects.toThrow(ConflictException);
|
||||
expect(scheduleRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for non-existent schedule', async () => {
|
||||
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(999)).rejects.toThrow('排课记录不存在');
|
||||
expect(scheduleRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,19 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
|
||||
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
import {
|
||||
ClassSchedule,
|
||||
Class,
|
||||
Classroom,
|
||||
ClassroomRental,
|
||||
ClassTeacher,
|
||||
AttendanceSession,
|
||||
} from '../entities';
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
UpdateScheduleDto,
|
||||
@@ -21,10 +28,13 @@ export class SchedulesService {
|
||||
@InjectRepository(ClassSchedule)
|
||||
private readonly scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
|
||||
@InjectRepository(Classroom) private readonly classroomRepo: Repository<Classroom>,
|
||||
@InjectRepository(ClassroomRental)
|
||||
private readonly rentalRepo: Repository<ClassroomRental>,
|
||||
@InjectRepository(ClassTeacher)
|
||||
private readonly classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(AttendanceSession)
|
||||
private readonly attendanceSessionRepo: Repository<AttendanceSession>,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
@@ -33,6 +43,31 @@ export class SchedulesService {
|
||||
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('只能管理自己被分配班级的排课');
|
||||
}
|
||||
|
||||
maskScheduleOccupancy(schedule: ClassSchedule) {
|
||||
return {
|
||||
id: null,
|
||||
classId: null,
|
||||
classroomId: schedule.classroomId,
|
||||
weekDay: schedule.weekDay,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
startDate: schedule.startDate,
|
||||
endDate: schedule.endDate,
|
||||
subject: '已占用',
|
||||
teacherId: null,
|
||||
scheduleType: schedule.scheduleType,
|
||||
status: schedule.status,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getLookups(accessibleClassIds?: number[]) {
|
||||
const classes = accessibleClassIds
|
||||
? accessibleClassIds.length > 0
|
||||
@@ -47,24 +82,15 @@ export class SchedulesService {
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
|
||||
const classroomRows = await this.scheduleRepo
|
||||
.createQueryBuilder('schedule')
|
||||
.select('classroom.id', 'classroomId')
|
||||
.addSelect('classroom.name', 'classroomName')
|
||||
.addSelect('classroom.building', 'classroomBuilding')
|
||||
.innerJoin('schedule.classroom', 'classroom')
|
||||
.distinct(true)
|
||||
.orderBy('classroom.building', 'ASC')
|
||||
.addOrderBy('classroom.name', 'ASC')
|
||||
.getRawMany();
|
||||
const classrooms = await this.classroomRepo.find({
|
||||
where: { status: Not('archived') },
|
||||
select: ['id', 'name', 'building', 'floor', 'roomType'],
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
classes,
|
||||
classrooms: classroomRows.map((row) => ({
|
||||
id: Number(row.classroomId),
|
||||
name: String(row.classroomName ?? ''),
|
||||
building: String(row.classroomBuilding ?? ''),
|
||||
})),
|
||||
classrooms,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +207,16 @@ export class SchedulesService {
|
||||
async remove(id: number) {
|
||||
const schedule = await this.scheduleRepo.findOne({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('排课记录不存在');
|
||||
|
||||
const sessionCount = await this.attendanceSessionRepo.count({
|
||||
where: { scheduleId: id },
|
||||
});
|
||||
if (sessionCount > 0) {
|
||||
throw new ConflictException(
|
||||
`无法删除已产生 ${sessionCount} 个考勤场次的排课。请先取消或停用排课以保护历史考勤数据。`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.scheduleRepo.remove(schedule);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -236,10 +272,6 @@ export class SchedulesService {
|
||||
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 });
|
||||
}
|
||||
@@ -253,12 +285,25 @@ export class SchedulesService {
|
||||
.addOrderBy('cs.startTime', 'ASC')
|
||||
.getMany();
|
||||
|
||||
const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null;
|
||||
const visibleSchedules = schedules.map((schedule) => {
|
||||
const canViewDetails =
|
||||
allowedClassIds === null ||
|
||||
(schedule.classId !== null && allowedClassIds.has(schedule.classId));
|
||||
if (canViewDetails) return { ...schedule, canViewDetails: true };
|
||||
|
||||
// Other classes remain visible only as a room/time occupancy block.
|
||||
// Do not expose class, subject, teacher, notes, or internal record IDs.
|
||||
return this.maskScheduleOccupancy(schedule);
|
||||
});
|
||||
|
||||
// Group by classroomId → weekDay
|
||||
const matrix: Record<number, Record<number, typeof schedules>> = {};
|
||||
for (const s of schedules) {
|
||||
if (!matrix[s.classroomId]) matrix[s.classroomId] = {};
|
||||
if (!matrix[s.classroomId][s.weekDay]) matrix[s.classroomId][s.weekDay] = [];
|
||||
matrix[s.classroomId][s.weekDay].push(s);
|
||||
const matrix: Record<number, Record<number, typeof visibleSchedules>> = {};
|
||||
for (const schedule of visibleSchedules) {
|
||||
if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {};
|
||||
if (!matrix[schedule.classroomId][schedule.weekDay])
|
||||
matrix[schedule.classroomId][schedule.weekDay] = [];
|
||||
matrix[schedule.classroomId][schedule.weekDay].push(schedule);
|
||||
}
|
||||
|
||||
return matrix;
|
||||
|
||||
Reference in New Issue
Block a user