forked from wangziqi/gongxue-base
feat: add CASL authorization and AI configuration
This commit is contained in:
50
apps/server/src/students/student-access-scope.factory.ts
Normal file
50
apps/server/src/students/student-access-scope.factory.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CaslAbilityFactory } from '../authorization/casl-ability.factory';
|
||||
import { CaslAction, SubjectName } from '../authorization/casl.constants';
|
||||
import type { AgentToolContext } from '../agent-tools/agent-tool.types';
|
||||
import type { StudentAccessScope } from './student-access-scope';
|
||||
|
||||
/**
|
||||
* Constructs a {@link StudentAccessScope} from the trusted server-side
|
||||
* principal carried in {@link AgentToolContext}.
|
||||
*
|
||||
* ## Scope rules
|
||||
*
|
||||
* | Condition | Scope |
|
||||
* |---|---|
|
||||
* | `isSuperAdmin` | `manageAll` |
|
||||
* | `class:edit` domain ability (`Update Class`) | `manageAll` |
|
||||
* | Everything else | `teacher(userId)` |
|
||||
*
|
||||
* These rules mirror the HTTP-layer logic so agent tools are consistent
|
||||
* with the web dashboard. The ability is constructed fresh from the
|
||||
* principal each time — callers cannot pre-forge it.
|
||||
*/
|
||||
@Injectable()
|
||||
export class StudentAccessScopeFactory {
|
||||
constructor(private readonly abilityFactory: CaslAbilityFactory) {}
|
||||
|
||||
/**
|
||||
* Build a scope from the authenticated context.
|
||||
*
|
||||
* The ability is constructed from the principal fields inside the context
|
||||
* — every call is a fresh derivation.
|
||||
*/
|
||||
buildScope(context: AgentToolContext): StudentAccessScope {
|
||||
const ability = this.abilityFactory.createForUser({
|
||||
permissions: context.permissions,
|
||||
isSuperAdmin: context.isSuperAdmin,
|
||||
});
|
||||
|
||||
if (context.isSuperAdmin) {
|
||||
return { type: 'manageAll' };
|
||||
}
|
||||
|
||||
// class:edit (Update Class) grants full student scope
|
||||
if (ability.can(CaslAction.Update, SubjectName.Class)) {
|
||||
return { type: 'manageAll' };
|
||||
}
|
||||
|
||||
return { type: 'teacher', userId: context.userId };
|
||||
}
|
||||
}
|
||||
14
apps/server/src/students/student-access-scope.ts
Normal file
14
apps/server/src/students/student-access-scope.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Data-range discriminator for student queries in the agent-tool layer.
|
||||
*
|
||||
* - `manageAll`: The principal may read every student unconditionally
|
||||
* (super admin or equivalent "full student scope" capability).
|
||||
* - `teacher`: The principal is restricted to active students in the
|
||||
* classes where they are a {@link ClassTeacher} (userId must be set).
|
||||
*
|
||||
* NEVER propagate a raw boolean `isSuperAdmin` into new Agent APIs —
|
||||
* use this discriminated union so callers are explicit about intent.
|
||||
*/
|
||||
export type StudentAccessScope =
|
||||
| { readonly type: 'manageAll' }
|
||||
| { readonly type: 'teacher'; readonly userId: number };
|
||||
363
apps/server/src/students/students.agent-api.spec.ts
Normal file
363
apps/server/src/students/students.agent-api.spec.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { StudentsService } from './students.service';
|
||||
import { StudentAccessScope } from './student-access-scope';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock helpers — simulate TypeORM QueryBuilder with raw-column naming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QbMock extends Record<string, jest.Mock> {
|
||||
select: jest.Mock;
|
||||
distinct: jest.Mock;
|
||||
leftJoin: jest.Mock;
|
||||
innerJoin: jest.Mock;
|
||||
where: jest.Mock;
|
||||
andWhere: jest.Mock;
|
||||
orderBy: jest.Mock;
|
||||
take: jest.Mock;
|
||||
getRawMany: jest.Mock;
|
||||
getRawOne: jest.Mock;
|
||||
setParameter: jest.Mock;
|
||||
}
|
||||
|
||||
function makeQb(rawMany: unknown[] = [], rawOne: unknown | null = null): QbMock {
|
||||
const qb: QbMock = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
distinct: jest.fn().mockReturnThis(),
|
||||
leftJoin: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
setParameter: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue(rawMany),
|
||||
getRawOne: jest.fn().mockResolvedValue(rawOne),
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
/** StudentsService constructor args in order. */
|
||||
function makeService(
|
||||
studentRawMany: unknown[] = [],
|
||||
studentRawOne: unknown | null = null,
|
||||
classStudentRawMany: unknown[] = [],
|
||||
innerJoinOnStudentQb?: (qb: QbMock) => void,
|
||||
): {
|
||||
service: StudentsService;
|
||||
studentQb: QbMock;
|
||||
classStudentQb: QbMock;
|
||||
} {
|
||||
const studentQb = makeQb(studentRawMany, studentRawOne ?? studentRawMany[0] ?? null);
|
||||
const classStudentQb = makeQb(classStudentRawMany);
|
||||
|
||||
if (innerJoinOnStudentQb) innerJoinOnStudentQb(studentQb);
|
||||
|
||||
const studentRepo = { createQueryBuilder: jest.fn().mockReturnValue(studentQb) };
|
||||
const classStudentRepo = { createQueryBuilder: jest.fn().mockReturnValue(classStudentQb) };
|
||||
|
||||
const service = new StudentsService(
|
||||
studentRepo as never,
|
||||
classStudentRepo as never,
|
||||
{} as never, // classRepo
|
||||
{} as never, // attendanceRepo
|
||||
{} as never, // classTeacherRepo
|
||||
{} as never, // organizationRepo
|
||||
);
|
||||
|
||||
return { service, studentQb, classStudentQb };
|
||||
}
|
||||
|
||||
const manageAll: StudentAccessScope = { type: 'manageAll' };
|
||||
const teacher: StudentAccessScope = { type: 'teacher', userId: 42 };
|
||||
|
||||
// TypeORM getRawMany/getRawOne uses snake_case column aliases
|
||||
const sampleRaw = {
|
||||
student_id: 1,
|
||||
student_name: '张三',
|
||||
student_student_no: 'S001',
|
||||
student_gender: '男',
|
||||
student_status: 'active',
|
||||
student_organization_id: 10,
|
||||
organization_name: '杭州校区',
|
||||
};
|
||||
|
||||
const expectedOutput = {
|
||||
id: 1,
|
||||
name: '张三',
|
||||
studentNo: 'S001',
|
||||
gender: '男',
|
||||
status: 'active',
|
||||
organizationId: 10,
|
||||
organizationName: '杭州校区',
|
||||
classIds: [5],
|
||||
};
|
||||
|
||||
// classStudentRepo raw aliases: cs.studentId → cs_student_id, cs.classId → cs_class_id
|
||||
const classStudentRaw = [
|
||||
{ cs_student_id: 1, cs_class_id: 5 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('StudentsService — agent-safe query APIs', () => {
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — scope enforcement in SQL
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — scope enforcement in SQL', () => {
|
||||
it('manageAll scope does NOT add teacher/class restrictions', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).not.toContain('class_teacher');
|
||||
expect(innerJoinArgs).not.toContain('scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('teacher scope adds class_student join with class_teacher subquery', async () => {
|
||||
const { service, studentQb } = makeService([], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(teacher, {});
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_teacher');
|
||||
expect(innerJoinArgs).toContain('scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('teacher scope returns empty array when no students match', async () => {
|
||||
const { service } = makeService([], null, []);
|
||||
|
||||
const result = await service.agentSearchStudents(teacher, {});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — classId intersection
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — classId intersection', () => {
|
||||
it('classId triggers class_student join for manageAll', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { classId: 5 });
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_student');
|
||||
expect(innerJoinArgs).toContain('scopeClassId');
|
||||
});
|
||||
|
||||
it('classId ANDs with teacher scope (intersection, not widening)', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(teacher, { classId: 3 });
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_teacher');
|
||||
expect(innerJoinArgs).toContain('scopeTeacherUserId');
|
||||
|
||||
// classId adds an andWhere, not replacing the teacher join
|
||||
const andWhereArgs = studentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereArgs).toContain('scopeClassId');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — field whitelist
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — field whitelist', () => {
|
||||
it('select list excludes sensitive fields', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
const selectCalls = studentQb.select.mock.calls.flat().join(' ');
|
||||
const forbidden = ['phone', 'idNumber', 'emergencyContact', 'emergencyPhone'];
|
||||
for (const field of forbidden) {
|
||||
expect(selectCalls).not.toContain(field);
|
||||
}
|
||||
});
|
||||
|
||||
it('maps raw columns to formatted output', async () => {
|
||||
const { service } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
const result = await service.agentSearchStudents(manageAll, {});
|
||||
expect(result).toEqual([expectedOutput]);
|
||||
});
|
||||
|
||||
it('output never contains phone/idNumber', async () => {
|
||||
const { service } = makeService([{ ...sampleRaw, student_phone: '13800138000' }], null, classStudentRaw);
|
||||
|
||||
const result = await service.agentSearchStudents(manageAll, {});
|
||||
if (result.length > 0) {
|
||||
const keys = Object.keys(result[0]);
|
||||
expect(keys).not.toContain('phone');
|
||||
expect(keys).not.toContain('idNumber');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — limit
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — limit', () => {
|
||||
it('clamps limit to max 50', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { limit: 200 });
|
||||
expect(studentQb.take).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('clamps limit to min 1', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { limit: 0 });
|
||||
expect(studentQb.take).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('defaults limit to 20 when not specified', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
expect(studentQb.take).toHaveBeenCalledWith(20);
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentSearchStudents — filters
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentSearchStudents — filters', () => {
|
||||
it('keyword goes to SQL WHERE', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { keyword: '张三' });
|
||||
|
||||
const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls);
|
||||
expect(andWhereStr).toContain('LIKE');
|
||||
expect(andWhereStr).toContain('keyword');
|
||||
});
|
||||
|
||||
it('organizationId goes to SQL WHERE', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, { organizationId: 10 });
|
||||
|
||||
const andWhereStr = JSON.stringify(studentQb.andWhere.mock.calls);
|
||||
expect(andWhereStr).toContain('orgId');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// agentGetStudentBasic
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('agentGetStudentBasic', () => {
|
||||
it('returns formatted result for student in scope', async () => {
|
||||
// getRawOne returns first element
|
||||
const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw);
|
||||
|
||||
const result = await service.agentGetStudentBasic(manageAll, 1);
|
||||
expect(result).toEqual(expectedOutput);
|
||||
});
|
||||
|
||||
it('returns null when student not in scope', async () => {
|
||||
const { service } = makeService([], null, []);
|
||||
|
||||
const result = await service.agentGetStudentBasic(teacher, 999);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('enforces teacher scope at SQL level via subquery', async () => {
|
||||
const { service, studentQb } = makeService([sampleRaw], sampleRaw, []);
|
||||
|
||||
await service.agentGetStudentBasic(teacher, 1);
|
||||
|
||||
const innerJoinArgs = studentQb.innerJoin.mock.calls.flat().join(' ');
|
||||
expect(innerJoinArgs).toContain('class_student');
|
||||
expect(innerJoinArgs).toContain('class_teacher');
|
||||
});
|
||||
|
||||
it('output whitelist excludes sensitive fields', async () => {
|
||||
const { service } = makeService([sampleRaw], sampleRaw, classStudentRaw);
|
||||
|
||||
const result = await service.agentGetStudentBasic(manageAll, 1);
|
||||
const keys = Object.keys(result!);
|
||||
expect(keys).not.toContain('phone');
|
||||
expect(keys).not.toContain('idNumber');
|
||||
expect(keys).not.toContain('emergencyContact');
|
||||
expect(keys).not.toContain('emergencyPhone');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P1-3: classIds scope enforcement (second query re-applies teacher filter)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P1-3: classIds second query enforces teacher scope', () => {
|
||||
it('agentSearchStudents teacher scope: second class query includes teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(teacher, {});
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).toContain('class_teacher');
|
||||
expect(andWhereCalls).toContain(':scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('agentSearchStudents manageAll scope: second class query has NO teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([sampleRaw], null, classStudentRaw);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).not.toContain('class_teacher');
|
||||
});
|
||||
|
||||
it('agentGetStudentBasic teacher scope: second class query includes teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw);
|
||||
|
||||
await service.agentGetStudentBasic(teacher, 1);
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).toContain('class_teacher');
|
||||
expect(andWhereCalls).toContain(':scopeTeacherUserId');
|
||||
});
|
||||
|
||||
it('agentGetStudentBasic manageAll: second class query has NO teacher filter', async () => {
|
||||
const { service, classStudentQb } = makeService([], sampleRaw, classStudentRaw);
|
||||
|
||||
await service.agentGetStudentBasic(manageAll, 1);
|
||||
|
||||
const andWhereCalls = classStudentQb.andWhere.mock.calls.flat().join(' ');
|
||||
expect(andWhereCalls).not.toContain('class_teacher');
|
||||
});
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// P2-3: DISTINCT to prevent duplicate students from teacher multi-class join
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
describe('P2-3: DISTINCT in main student query', () => {
|
||||
it('agentSearchStudents teacher scope calls distinct(true)', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(teacher, {});
|
||||
|
||||
expect(studentQb.distinct).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('agentSearchStudents manageAll scope also calls distinct(true)', async () => {
|
||||
const { service, studentQb } = makeService([], null, []);
|
||||
|
||||
await service.agentSearchStudents(manageAll, {});
|
||||
|
||||
expect(studentQb.distinct).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,8 +26,14 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: AuthenticatedUser;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('students')
|
||||
export class StudentsController {
|
||||
@@ -35,13 +41,14 @@ export class StudentsController {
|
||||
private service: StudentsService,
|
||||
private logService: OperationLogsService,
|
||||
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
|
||||
private authz: AuthorizationService,
|
||||
) {}
|
||||
|
||||
private canManageAllStudents(user: { isSuperAdmin?: boolean; permissions?: string[] }): boolean {
|
||||
private canManageAllStudents(req: AuthenticatedRequest): boolean {
|
||||
return (
|
||||
user.isSuperAdmin === true ||
|
||||
user.permissions?.includes('student:edit') === true ||
|
||||
user.permissions?.includes('class:edit') === true
|
||||
this.authz.can(req, CaslAction.Manage, SubjectName.Student) ||
|
||||
// Legacy: class:edit grants broad student access for teacher scoping
|
||||
this.authz.can(req, CaslAction.Update, SubjectName.Class)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,11 +59,11 @@ export class StudentsController {
|
||||
@Query('status') status: string | undefined,
|
||||
@Query('includeArchived') includeArchived: string | undefined,
|
||||
@Query('organizationId') organizationId: string | undefined,
|
||||
@Request() req: { user: { id: number; isSuperAdmin?: boolean; permissions?: string[] } },
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllStudents(req.user),
|
||||
this.canManageAllStudents(req),
|
||||
);
|
||||
return this.service.findAll(
|
||||
{
|
||||
@@ -78,7 +85,7 @@ export class StudentsController {
|
||||
) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllStudents(req.user),
|
||||
this.canManageAllStudents(req),
|
||||
);
|
||||
const students = await this.service.findAll(
|
||||
{ includeArchived: includeArchived === 'true' },
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { StudentsService } from './students.service';
|
||||
import { StudentAccessScopeFactory } from './student-access-scope.factory';
|
||||
import { StudentsController } from './students.controller';
|
||||
|
||||
@Module({
|
||||
@@ -21,7 +22,7 @@ import { StudentsController } from './students.controller';
|
||||
]),
|
||||
],
|
||||
controllers: [StudentsController],
|
||||
providers: [StudentsService],
|
||||
exports: [StudentsService],
|
||||
providers: [StudentsService, StudentAccessScopeFactory],
|
||||
exports: [StudentsService, StudentAccessScopeFactory],
|
||||
})
|
||||
export class StudentsModule {}
|
||||
|
||||
@@ -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