import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Like, Not, In, FindOptionsWhere, IsNull } from 'typeorm'; import { Student } from '../entities/student.entity'; import { Class } from '../entities/class.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Organization } from '../entities/organization.entity'; import { StudentProfile } from '../entities/student-profile.entity'; import { StudentEnrollment } from '../entities/student-enrollment.entity'; import { ExamScore } from '../entities/exam-score.entity'; import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; import type { ExamScoreImportRow, LearningRecordImportRow, StudentEnrollmentImportRow, StudentImportRow, StudentWorkbookImport, } from './student-import'; import type { StudentAccessScope } from './student-access-scope'; @Injectable() export class StudentsService { constructor( @InjectRepository(Student) private repo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, @InjectRepository(StudentProfile) private profileRepo: Repository, @InjectRepository(StudentEnrollment) private enrollmentRepo: Repository, @InjectRepository(ExamScore) private examScoreRepo: Repository, @InjectRepository(LearningRecord) private learningRecordRepo: Repository, @InjectRepository(ResultArchive) private resultRepo: Repository, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); return [...new Set(assignments.map((assignment) => assignment.classId))]; } async getBasicLookups() { return this.repo.find({ select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'], where: { status: 'active' }, order: { name: 'ASC' }, }); } async getArchiveExportMaps(studentIds: number[]) { if (studentIds.length === 0) { return { profiles: new Map(), results: new Map(), }; } const [profiles, results] = await Promise.all([ this.profileRepo.find({ where: { studentId: In(studentIds) } }), this.resultRepo.find({ where: { studentId: In(studentIds) } }), ]); return { profiles: new Map(profiles.map((profile) => [profile.studentId, profile])), results: new Map(results.map((result) => [result.studentId, result])), }; } async findAll( query?: { name?: string; status?: string; includeArchived?: boolean; organizationId?: number | string; classId?: number | string; teacherId?: number | string; }, accessibleClassIds?: number[], ) { const where: FindOptionsWhere = {}; if (query?.name) where.name = Like(`%${query.name}%`); if (query?.organizationId) where.organizationId = Number(query.organizationId); if (query?.status) { where.status = query.status; } else if (!query?.includeArchived) { where.status = Not(In(['archived', 'staff'])); } let scopedClassIds = accessibleClassIds ? [...accessibleClassIds] : undefined; if (query?.teacherId) { const teacherAssignments = await this.classTeacherRepo.find({ where: { userId: Number(query.teacherId) }, }); const teacherClassIds = [...new Set(teacherAssignments.map((item) => item.classId))]; scopedClassIds = scopedClassIds ? scopedClassIds.filter((classId) => teacherClassIds.includes(classId)) : teacherClassIds; } if (query?.classId) { const classId = Number(query.classId); scopedClassIds = scopedClassIds ? scopedClassIds.filter((accessibleClassId) => accessibleClassId === classId) : [classId]; } if (scopedClassIds) { if (scopedClassIds.length === 0) return []; const classStudents = await this.classStudentRepo.find({ where: { classId: In(scopedClassIds), status: 'active' }, }); const studentIds = [...new Set(classStudents.map((item) => item.studentId))]; if (studentIds.length === 0) return []; where.id = In(studentIds); } return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['organization'] }); } async getFilterLookups(accessibleClassIds?: number[]) { if (accessibleClassIds && accessibleClassIds.length === 0) { return { classes: [], teachers: [] }; } const classWhere = accessibleClassIds ? { id: In(accessibleClassIds), isArchived: false } : { isArchived: false }; const classes = await this.classRepo.find({ select: ['id', 'name', 'code'], where: classWhere, order: { name: 'ASC' }, }); const teacherWhere = accessibleClassIds ? { classId: In(accessibleClassIds) } : { classId: In(classes.map((item) => item.id)), userId: Not(IsNull()) }; const assignments = classes.length ? await this.classTeacherRepo.find({ where: teacherWhere, relations: ['user'] }) : []; const teacherMap = new Map(); for (const assignment of assignments) { if (!assignment.user || assignment.user.isArchived) continue; teacherMap.set(assignment.userId, { id: assignment.userId, name: assignment.user.name || assignment.user.username, username: assignment.user.username, }); } return { classes: classes.map((item) => ({ id: item.id, name: item.name, code: item.code })), teachers: [...teacherMap.values()].sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')), }; } async findOne(id: number) { const student = await this.repo.findOne({ where: { id }, relations: ['occupancies', 'occupancies.room'], }); if (!student) throw new NotFoundException('学生不存在'); return student; } async create(dto: CreateStudentDto) { await this.assertActiveOrganization(dto.organizationId); return this.repo.save(this.repo.create(dto)); } async update(id: number, dto: UpdateStudentDto) { await this.findOne(id); if (dto.organizationId) await this.assertActiveOrganization(dto.organizationId); await this.repo.update(id, dto); return this.repo.findOne({ where: { id } }); } async remove(id: number) { const student = await this.findOne(id); if (student.status === 'archived') { throw new BadRequestException('该学生已归档'); } await this.repo.update(id, { status: 'archived' }); return { message: '已归档(数据已保留,可随时恢复)' }; } async batchRemove(ids: number[]) { if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的学生'); const students = await this.repo.find({ where: { id: In(ids) } }); const skipped: string[] = []; const targetIds: number[] = []; for (const s of students) { if (s.status === 'archived') skipped.push(s.name); else targetIds.push(s.id); } let affected = 0; if (targetIds.length > 0) { const result = await this.repo .createQueryBuilder() .update() .set({ status: 'archived' }) .where('id IN (:...ids)', { ids: targetIds }) .execute(); affected = result.affected || 0; } const message = skipped.length > 0 ? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` : `已批量归档 ${affected} 人(数据已保留,可随时恢复)`; return { message, archived: affected, skipped: skipped.length }; } async restore(id: number) { const student = await this.findOne(id); if (student.status !== 'archived') { throw new BadRequestException('该学生未被归档'); } await this.repo.update(id, { status: 'active' }); return { message: '已恢复' }; } async batchRestore(ids: number[]) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的学生'); if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { throw new BadRequestException('学生 ID 无效'); } const students = await this.repo.find({ where: { id: In(uniqueIds) } }); if (students.length !== uniqueIds.length) throw new NotFoundException('部分学生不存在'); const targetIds = students.filter((student) => student.status === 'archived').map((student) => student.id); const skipped = students.length - targetIds.length; let restored = 0; if (targetIds.length > 0) { const result = await this.repo .createQueryBuilder() .update() .set({ status: 'active' }) .where('id IN (:...ids)', { ids: targetIds }) .execute(); restored = result.affected || 0; } return { message: `已批量恢复 ${restored} 名学生`, restored, skipped }; } async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { const data = this.normalizeImportData(importData); let imported = 0; let skipped = 0; let archiveImported = 0; for (const row of data.students) { if (!row.name || !row.name.trim()) { skipped++; continue; } const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); if (exists) { skipped++; continue; } const student = await this.repo.save( this.repo.create({ name: row.name.trim(), studentNo: row.studentNo?.trim() || undefined, phone: row.phone?.trim() || undefined, idNumber: row.idNumber?.trim() || undefined, gender: row.gender || undefined, ethnicity: row.ethnicity || undefined, emergencyContact: row.emergencyContact || undefined, emergencyPhone: row.emergencyPhone || undefined, supervisor: row.supervisor || undefined, organizationId: row.organizationId || (await this.getHostOrganizationId()), }), ); archiveImported += await this.importArchiveData(student.id, row, data); imported++; } return { message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, imported, archiveImported, skipped, }; } async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { const data = this.normalizeImportData(importData); let matched = 0; let skipped = 0; let archiveImported = 0; for (const row of data.students) { // Match by phone first, then idNumber let student = row.phone?.trim() ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) : null; if (!student && row.idNumber?.trim()) { student = await this.repo.findOne({ where: { idNumber: row.idNumber.trim() } }); } if (!student) { skipped++; continue; } // Update matched student with non-empty imported fields const updates: Partial< Pick< Student, | 'name' | 'studentNo' | 'phone' | 'idNumber' | 'gender' | 'ethnicity' | 'emergencyContact' | 'emergencyPhone' | 'supervisor' | 'organizationId' > > = {}; if (row.name?.trim()) updates.name = row.name.trim(); if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); if (row.phone?.trim()) updates.phone = row.phone.trim(); if (row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); if (row.gender) updates.gender = row.gender; if (row.ethnicity) updates.ethnicity = row.ethnicity; if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; if (row.supervisor) updates.supervisor = row.supervisor; if (row.organizationId) updates.organizationId = row.organizationId; await this.repo.update(student.id, updates); archiveImported += await this.importArchiveData(student.id, row, data); matched++; } return { message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`, matched, archiveImported, skipped, }; } private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport { if (Array.isArray(importData)) { return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; } return importData; } private normalizePhone(phone?: string) { return phone?.trim() || ''; } private sameValue(left?: string | number | null, right?: string | number | null) { return String(left ?? '').trim() === String(right ?? '').trim(); } private hasProfileData(row: StudentImportRow) { return [ row.targetCollege, row.targetMajor, row.collegeSchool, row.collegeMajor, row.subjectDirection, row.grade, row.profileDate, row.notes, ].some((value) => value !== undefined && String(value).trim() !== ''); } private hasResultData(row: StudentImportRow) { return [ row.cultureFinalScore, row.professionalFinalScore, row.admissionStatus, row.admittedCollege, row.admittedMajor, ].some((value) => value !== undefined && String(value).trim() !== ''); } private async importArchiveData( studentId: number, row: StudentImportRow, data: StudentWorkbookImport, ) { const phone = this.normalizePhone(row.phone); let imported = 0; if (this.hasProfileData(row)) { await this.upsertProfileFromImport(studentId, row); imported++; } if (this.hasResultData(row)) { await this.upsertResultFromImport(studentId, row); imported++; } if (!phone) return imported; const enrollmentByClassName = new Map(); for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) { const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); if (!enrollment) continue; if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); imported++; } for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) { if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { imported++; } } for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) { if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { imported++; } } return imported; } private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); if (row.grade?.trim()) entity.grade = row.grade.trim(); if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim(); if (row.notes?.trim()) entity.notes = row.notes.trim(); await this.profileRepo.save(entity); } private async upsertResultFromImport(studentId: number, row: StudentImportRow) { const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId }); if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); await this.resultRepo.save(entity); } private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) { if (!row.courseCategory?.trim() || !row.classType?.trim()) { return null; } const existing = await this.enrollmentRepo.find({ where: { studentId } }); const entity = existing.find( (item) => this.sameValue(item.courseCategory, row.courseCategory) && this.sameValue(item.classType, row.classType) && this.sameValue(item.className, row.className) && this.sameValue(item.startDate, row.startDate), ) || this.enrollmentRepo.create({ studentId }); entity.courseCategory = row.courseCategory.trim(); entity.classType = row.classType.trim(); if (row.className?.trim()) entity.className = row.className.trim(); if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim(); if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim(); if (row.startDate?.trim()) entity.startDate = row.startDate.trim(); if (row.endDate?.trim()) entity.endDate = row.endDate.trim(); if (row.status?.trim()) entity.status = row.status.trim(); else if (!entity.status) entity.status = 'active'; return this.enrollmentRepo.save(entity); } private async upsertExamScoreFromImport( studentId: number, row: ExamScoreImportRow, enrollmentByClassName: Map, ) { if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; const existing = await this.examScoreRepo.find({ where: { studentId } }); const entity = existing.find( (item) => this.sameValue(item.examType, row.examType) && this.sameValue(item.examName, row.examName) && this.sameValue(item.subject, row.subject) && this.sameValue(item.examDate, row.examDate), ) || this.examScoreRepo.create({ studentId }); entity.examType = row.examType.trim(); entity.subject = row.subject.trim(); entity.score = row.score; if (row.examName?.trim()) entity.examName = row.examName.trim(); if (row.classAvg !== undefined) entity.classAvg = row.classAvg; if (row.rank !== undefined) entity.rank = row.rank; if (row.examDate?.trim()) entity.examDate = row.examDate.trim(); if (row.enrollmentName?.trim()) { const enrollment = enrollmentByClassName.get(row.enrollmentName.trim()); if (enrollment) entity.enrollmentId = enrollment.id; } if (!entity.status) entity.status = 'active'; await this.examScoreRepo.save(entity); return true; } private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) { if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; const existing = await this.learningRecordRepo.find({ where: { studentId } }); const entity = existing.find( (item) => this.sameValue(item.recordDate, row.recordDate) && this.sameValue(item.recordType, row.recordType) && this.sameValue(item.content, row.content), ) || this.learningRecordRepo.create({ studentId }); entity.recordDate = row.recordDate.trim(); entity.recordType = row.recordType.trim(); entity.content = row.content.trim(); if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim(); if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim(); if (!entity.status) entity.status = 'active'; await this.learningRecordRepo.save(entity); return true; } private async assertActiveOrganization(id: number) { const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } }); if (!organization) throw new BadRequestException('所属机构不存在或已归档'); } private async getHostOrganizationId() { const organization = await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' }, }); if (!organization) throw new BadRequestException('尚未配置本机构'); return organization.id; } async compareClasses(studentId: number) { const student = await this.repo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); const enrollments = await this.classStudentRepo.find({ where: { studentId }, relations: ['class'], }); if (enrollments.length === 0) { return { student, enrollments: [] }; } const classIds = enrollments.map((e) => e.classId); const attendanceRecords = await this.attendanceRepo.find({ where: { studentId, classId: In(classIds) }, }); const attendanceByClass = new Map(); for (const r of attendanceRecords) { const list = attendanceByClass.get(r.classId) || []; list.push(r); attendanceByClass.set(r.classId, list); } const comparison = enrollments.map((e) => { const records = attendanceByClass.get(e.classId) || []; const total = records.length; const present = records.filter((r) => r.status === 'present' || r.status === '正常').length; const absent = records.filter((r) => r.status === 'absent' || r.status === '缺勤').length; const late = records.filter((r) => r.status === 'late' || r.status === '迟到').length; const leave = records.filter((r) => r.status === 'leave' || r.status === '请假').length; return { classId: e.classId, className: e.class?.name || '', classType: e.class?.classType || '', startDate: e.class?.startDate || '', endDate: e.class?.endDate || '', joinDate: e.joinDate, leaveDate: e.leaveDate, status: e.status, attendanceStats: { total, present, absent, late, leave, rate: total > 0 ? Math.round((present / total) * 100) : 0, }, }; }); return { student, enrollments: comparison }; } // ------------------------------------------------------------------------- // Agent-safe query APIs — SQL-level scope + field whitelist // ------------------------------------------------------------------------- /** * Whitelisted output type for agent student searches. * NEVER exposes phone, idNumber, emergencyContact, or emergencyPhone. */ private static readonly AGENT_STUDENT_SELECT = [ 'student.id', 'student.name', 'student.studentNo', 'student.gender', 'student.status', 'student.organizationId', 'organization.name', ] as const; /** * Search students with SQL-enforced scope, field whitelist, and limit. * * @param scope — data-range discriminator (manageAll or teacher). * @param query — optional keyword, classId, organizationId, limit. * @returns formatted whitelist-only results with classIds. */ async agentSearchStudents( scope: StudentAccessScope, query?: { keyword?: string; classId?: number; organizationId?: number; limit?: number; }, ): Promise< { id: number; name: string; studentNo: string; gender: string; status: string; organizationId: number; organizationName: string; classIds: number[]; }[] > { const limit = Math.max(1, Math.min(query?.limit ?? 20, 50)); const qb = this.repo .createQueryBuilder('student') .distinct(true) .select([ 'student.id', 'student.name', 'student.studentNo', 'student.gender', 'student.status', 'student.organizationId', 'organization.name', ]) .leftJoin('student.organization', 'organization'); // ---- Scope enforcement ---- this.applyStudentScope(qb, scope, query?.classId); // ---- Filters ---- if (query?.keyword) { qb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', { keyword: `%${query.keyword}%` }, ); } if (query?.organizationId) { qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId }); } qb.orderBy('student.createdAt', 'DESC').take(limit); const rows: Record[] = await qb.getRawMany(); if (rows.length === 0) return []; // Second bounded query: classIds only for the returned student ids. // For teacher scope, the class filter MUST be re-applied so the // teacher only sees classIds they are assigned to. const studentIds = rows.map((r) => r.student_id as number); const csQb = this.classStudentRepo .createQueryBuilder('cs') .select(['cs.studentId', 'cs.classId']) .where('cs.studentId IN (:...ids)', { ids: studentIds }) .andWhere('cs.status = :status', { status: 'active' }); if (scope.type === 'teacher') { csQb.andWhere( 'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', { scopeTeacherUserId: scope.userId }, ); } const classRows = await csQb.getRawMany(); const classMap = new Map(); for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) { const sid = cr.cs_student_id; if (!classMap.has(sid)) classMap.set(sid, []); classMap.get(sid)!.push(cr.cs_class_id); } return rows.map((r) => ({ id: r.student_id as number, name: r.student_name as string, studentNo: (r.student_student_no as string) ?? '', gender: (r.student_gender as string) ?? '', status: r.student_status as string, organizationId: r.student_organization_id as number, organizationName: (r.organization_name as string) ?? '', classIds: classMap.get(r.student_id as number) ?? [], })); } /** * Get single student basic info with SQL-enforced scope + whitelist. * Returns `null` for students out of scope or non-existent (no leak). */ async agentGetStudentBasic( scope: StudentAccessScope, studentId: number, ): Promise<{ id: number; name: string; studentNo: string; gender: string; status: string; organizationId: number; organizationName: string; classIds: number[]; } | null> { const qb = this.repo .createQueryBuilder('student') .select([ 'student.id', 'student.name', 'student.studentNo', 'student.gender', 'student.status', 'student.organizationId', 'organization.name', ]) .leftJoin('student.organization', 'organization') .where('student.id = :studentId', { studentId }); this.applyStudentScope(qb, scope); const row = await qb.getRawOne(); if (!row) return null; // For teacher scope, re-apply class filter so teacher only sees // classIds they are assigned to (not ALL active classIds of the student). const csQb = this.classStudentRepo .createQueryBuilder('cs') .select(['cs.classId']) .where('cs.studentId = :studentId', { studentId }) .andWhere('cs.status = :status', { status: 'active' }); if (scope.type === 'teacher') { csQb.andWhere( 'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)', { scopeTeacherUserId: scope.userId }, ); } const classRows = await csQb.getRawMany(); return { id: row.student_id as number, name: row.student_name as string, studentNo: (row.student_student_no as string) ?? '', gender: (row.student_gender as string) ?? '', status: row.student_status as string, organizationId: row.student_organization_id as number, organizationName: (row.organization_name as string) ?? '', classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id), }; } /** * Apply data-range scope to a student QueryBuilder. * * - `manageAll`: no restriction. * - `teacher`: INNER JOIN ClassStudent → active students in the * teacher's assigned classes (via ClassTeacher). * - When `classId` is provided, it is ANDed with the scope * (intersection) — the model cannot widen access. */ private applyStudentScope( qb: ReturnType, scope: StudentAccessScope, classId?: number, ): void { if (scope.type === 'manageAll') { if (classId != null) { qb.innerJoin( 'class_student', 'cs_scope', 'cs_scope.student_id = student.id AND cs_scope.class_id = :scopeClassId AND cs_scope.status = :scopeCsStatus', { scopeClassId: classId, scopeCsStatus: 'active' }, ); } return; } // Teacher scope: active students in teacher's assigned classes const teacherClause = 'cs_scope.student_id = student.id AND cs_scope.status = :scopeCsStatus AND cs_scope.class_id IN ' + '(SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)'; qb.innerJoin('class_student', 'cs_scope', teacherClause, { scopeTeacherUserId: scope.userId, scopeCsStatus: 'active', }); if (classId != null) { qb.andWhere('cs_scope.class_id = :scopeClassId', { scopeClassId: classId }); } } }