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:
2026-07-12 22:59:03 +08:00
parent b6fca99390
commit cc4f4dae4e
69 changed files with 6262 additions and 1980 deletions

View 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,
);
});
});

View File

@@ -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,

View File

@@ -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,
],

View File

@@ -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(),
);
});
});

View File

@@ -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();
});
});

View File

@@ -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;