Files
gongxue-base/apps/server/src/students/students.service.ts

784 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<Student>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
@InjectRepository(StudentProfile) private profileRepo: Repository<StudentProfile>,
@InjectRepository(StudentEnrollment) private enrollmentRepo: Repository<StudentEnrollment>,
@InjectRepository(ExamScore) private examScoreRepo: Repository<ExamScore>,
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
) {}
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 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<number, StudentProfile>(),
results: new Map<number, ResultArchive>(),
};
}
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<Student> = {};
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<number, { id: number; name: string; username: string }>();
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 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<string, StudentEnrollment>();
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<string, StudentEnrollment>,
) {
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<number, AttendanceRecord[]>();
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<string, unknown>[] = 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<number, number[]>();
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<typeof this.repo.createQueryBuilder>,
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 });
}
}
}