homework: - Add data-access-scans, data-access-student, data-access-utils, data-access-exam-cross - Add excellent-submissions, homework-take-confirm-dialog, homework-take-sidebar components classes: - Add class-delete-dialog, class-error-boundary, class-form-dialog, class-form-utils - Add class-list-table, class-list-toolbar, class-skeleton - Add schedule-create-dialog, schedule-delete-dialog, schedule-edit-dialog, schedule-utils - Add data-access-teacher and hooks directory course-plans: - Add course-plan-calendar, sortable-week-row, template-picker-dialog components - Add lib directory
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
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<Map<string, Record<string, number | null>>> => {
|
|
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<string, string>() // 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<string, Record<string, number | null>>()
|
|
|
|
// Create reverse map for assignment -> subject
|
|
const assignmentSubjectMap = new Map<string, string>()
|
|
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<Map<string, Record<string, number | null>>> => {
|
|
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<string, Record<string, number | null>>()
|
|
for (const [studentId, scores] of studentScores.entries()) {
|
|
const nextScores: Record<string, number | null> = {}
|
|
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<StudentEnrolledClass[]> => {
|
|
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<string | null>`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<StudentEnrolledClass | null> => {
|
|
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<ClassStudent[]> => {
|
|
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<string>()
|
|
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<string[]> {
|
|
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<string>()
|
|
for (const row of rows) {
|
|
if (row.gradeId !== null && row.gradeId.trim().length > 0) {
|
|
gradeIdSet.add(row.gradeId)
|
|
}
|
|
}
|
|
|
|
return Array.from(gradeIdSet)
|
|
}
|