forked from wangziqi/gongxue-base
feat: add CASL authorization and AI configuration
This commit is contained in:
@@ -8,6 +8,7 @@ import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
|
||||
import type { StudentAccessScope } from './student-access-scope';
|
||||
|
||||
@Injectable()
|
||||
export class StudentsService {
|
||||
@@ -224,7 +225,7 @@ export class StudentsService {
|
||||
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 as Partial<Student>);
|
||||
await this.repo.update(student.id, updates);
|
||||
matched++;
|
||||
}
|
||||
return {
|
||||
@@ -302,4 +303,228 @@ export class StudentsService {
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user