import "server-only"; import { cache } from "react" import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm" import { db } from "@/shared/db" import { classes, classEnrollments, subjects, users, } from "@/shared/db/schema" import { getAssignmentIdsForStudents, getHomeworkSubmissionsForAssignments, getPublishedHomeworkAssignmentsWithSubject, } from "@/modules/homework/data-access-classes" import type { ClassStudent, StudentEnrolledClass, } from "./types" import { compareClassLike, getAccessibleClassIdsForTeacher, getSessionTeacherId, getTeacherSubjectIdsForClass, } from "./data-access" export const getStudentsSubjectScores = cache( async (studentIds: string[]): Promise>> => { if (studentIds.length === 0) return new Map() // 1. Find assignments targeted at these students (via homework module data-access) const assignmentIds = await getAssignmentIdsForStudents(studentIds) if (assignmentIds.length === 0) return new Map() // 2. Get published assignment details including subject from linked exam (via homework module) const assignments = await getPublishedHomeworkAssignmentsWithSubject({ assignmentIds }) // 3. Filter subjects (exclude PE, Music, Art) const excludeSubjects = ["体育", "音乐", "美术"] const subjectAssignments = new Map() // subject -> assignmentId (latest) for (const a of assignments) { if (!a.subjectName) continue if (excludeSubjects.includes(a.subjectName)) continue if (!subjectAssignments.has(a.subjectName)) { subjectAssignments.set(a.subjectName, a.id) } } const targetAssignmentIds = Array.from(subjectAssignments.values()) if (targetAssignmentIds.length === 0) return new Map() // 4. Get submissions for these assignments (via homework module) const submissions = await getHomeworkSubmissionsForAssignments(targetAssignmentIds) // 5. Map back to subject scores per student const studentScores = new Map>() // Create reverse map for assignment -> subject const assignmentSubjectMap = new Map() for (const [subject, id] of subjectAssignments.entries()) { assignmentSubjectMap.set(id, subject) } for (const s of submissions) { const subject = assignmentSubjectMap.get(s.assignmentId) if (!subject) continue const existing = studentScores.get(s.studentId) const scores = existing ?? {} if (!existing) { studentScores.set(s.studentId, scores) } // Only set if not already set (since we ordered by desc createdAt, first one is latest) if (scores[subject] === undefined) { scores[subject] = s.score } } return studentScores } ) export const getClassStudentSubjectScoresV2 = cache( async (params: { classId: string; teacherId?: string }): Promise>> => { const teacherId = params.teacherId ?? (await getSessionTeacherId()) if (!teacherId) return new Map() const classId = params.classId.trim() if (!classId) return new Map() const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId) if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return new Map() const [classRow] = await db .select({ id: classes.id, teacherId: classes.teacherId }) .from(classes) .where(eq(classes.id, classId)) .limit(1) if (!classRow) return new Map() const isHomeroomTeacher = classRow.teacherId === teacherId const subjectIds = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId) if (!isHomeroomTeacher && subjectIds.length === 0) return new Map() const enrollments = await db .select({ studentId: classEnrollments.studentId }) .from(classEnrollments) .where(and( eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active") )) const studentIds = enrollments.map((e) => e.studentId) const studentScores = await getStudentsSubjectScores(studentIds) if (subjectIds.length === 0) return studentScores // Map subjectIds to names for filtering const subjectRows = await db .select({ id: subjects.id, name: subjects.name }) .from(subjects) .where(inArray(subjects.id, subjectIds)) const allowed = new Set(subjectRows.map((s) => s.name)) const filtered = new Map>() for (const [studentId, scores] of studentScores.entries()) { const nextScores: Record = {} for (const [subject, score] of Object.entries(scores)) { if (allowed.has(subject)) nextScores[subject] = score } filtered.set(studentId, nextScores) } return filtered } ) export const getStudentClasses = cache(async (studentId: string): Promise => { const id = studentId.trim() if (!id) return [] const rows = await (async () => { try { return await db .select({ id: classes.id, schoolName: classes.schoolName, name: classes.name, grade: classes.grade, homeroom: classes.homeroom, room: classes.room, teacherName: users.name, teacherEmail: users.email, }) .from(classEnrollments) .innerJoin(classes, eq(classes.id, classEnrollments.classId)) .leftJoin(users, eq(users.id, classes.teacherId)) .where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) .orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) } catch (error) { console.error("getStudentClasses primary query failed, falling back:", error) return await db .select({ id: classes.id, schoolName: sql`NULL`.as("schoolName"), name: classes.name, grade: classes.grade, homeroom: classes.homeroom, room: classes.room, teacherName: users.name, teacherEmail: users.email, }) .from(classEnrollments) .innerJoin(classes, eq(classes.id, classEnrollments.classId)) .leftJoin(users, eq(users.id, classes.teacherId)) .where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active"))) .orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room)) } })() const list = rows.map((r) => ({ id: r.id, schoolName: r.schoolName, name: r.name, grade: r.grade, homeroom: r.homeroom, room: r.room, teacherName: r.teacherName, teacherEmail: r.teacherEmail, })) list.sort(compareClassLike) return list }) /** * Get a single class details for a student (verifies enrollment). * Returns null if the student is not enrolled in the class. */ export const getStudentClassById = cache( async (studentId: string, classId: string): Promise => { const sid = studentId.trim() const cid = classId.trim() if (!sid || !cid) return null const rows = await db .select({ id: classes.id, schoolName: classes.schoolName, name: classes.name, grade: classes.grade, homeroom: classes.homeroom, room: classes.room, teacherName: users.name, teacherEmail: users.email, }) .from(classEnrollments) .innerJoin(classes, eq(classes.id, classEnrollments.classId)) .leftJoin(users, eq(users.id, classes.teacherId)) .where(and(eq(classEnrollments.studentId, sid), eq(classEnrollments.classId, cid), eq(classEnrollments.status, "active"))) .limit(1) if (rows.length === 0) return null const r = rows[0] return { id: r.id, schoolName: r.schoolName, name: r.name, grade: r.grade, homeroom: r.homeroom, room: r.room, teacherName: r.teacherName, teacherEmail: r.teacherEmail, } } ) export const getClassStudents = cache( async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise => { const teacherId = params?.teacherId ?? (await getSessionTeacherId()) if (!teacherId) return [] const classId = params?.classId?.trim() const q = params?.q?.trim().toLowerCase() const status = params?.status?.trim().toLowerCase() const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId) if (accessibleIds.length === 0) return [] const conditions: SQL[] = [inArray(classes.id, accessibleIds)] if (classId) { conditions.push(eq(classes.id, classId)) } if (status === "active" || status === "inactive") { conditions.push(eq(classEnrollments.status, status)) } if (q && q.length > 0) { const needle = `%${q}%` conditions.push( sql`(LOWER(COALESCE(${users.name}, '')) LIKE ${needle} OR LOWER(${users.email}) LIKE ${needle})` ) } const rows = await db .select({ id: users.id, name: users.name, email: users.email, image: users.image, gender: users.gender, classId: classes.id, className: classes.name, status: classEnrollments.status, joinedAt: classEnrollments.createdAt, }) .from(classEnrollments) .innerJoin(classes, eq(classes.id, classEnrollments.classId)) .innerJoin(users, eq(users.id, classEnrollments.studentId)) .where(and(...conditions)) .orderBy(asc(users.name), asc(users.email)) return rows.map((r) => ({ id: r.id, name: r.name ?? "Unnamed", email: r.email, image: r.image, gender: r.gender, classId: r.classId, className: r.className, status: r.status, joinedAt: r.joinedAt, })) } ) // --------------------------------------------------------------------------- // DataScope resolver helpers (P1-5/P1-6 audit fix) // These lightweight functions return only the IDs needed by the RBAC // data-scope resolver, so shared/lib/auth-guard no longer queries classes/ // classEnrollments/classSubjectTeachers tables directly. // --------------------------------------------------------------------------- /** * Get a student's class IDs and grade IDs for DataScope resolution. * Joins classEnrollments with classes to resolve gradeId per enrollment. */ export async function getStudentScopeData( studentId: string, ): Promise<{ classIds: string[]; gradeIds: string[] }> { const rows = await db .select({ classId: classEnrollments.classId, gradeId: classes.gradeId }) .from(classEnrollments) .innerJoin(classes, eq(classEnrollments.classId, classes.id)) .where(eq(classEnrollments.studentId, studentId)) const classIds = rows.map((r) => r.classId) const gradeIdSet = new Set() for (const row of rows) { if (row.gradeId !== null && row.gradeId.trim().length > 0) { gradeIdSet.add(row.gradeId) } } return { classIds, gradeIds: Array.from(gradeIdSet), } } /** * Get grade IDs for a list of student IDs (used by parent DataScope). * Queries classEnrollments JOIN classes for all students in a single query. */ export async function getGradeIdsForStudentIds( studentIds: string[], ): Promise { if (studentIds.length === 0) return [] const rows = await db .select({ gradeId: classes.gradeId }) .from(classEnrollments) .innerJoin(classes, eq(classEnrollments.classId, classes.id)) .where(inArray(classEnrollments.studentId, studentIds)) const gradeIdSet = new Set() for (const row of rows) { if (row.gradeId !== null && row.gradeId.trim().length > 0) { gradeIdSet.add(row.gradeId) } } return Array.from(gradeIdSet) }