refactor: fix all P0/P1/P2 bugs and architecture issues

Bug fixes (from bugs/ directory):

- Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions

- Fix shared/lib <-> auth circular dependency via new session.ts module

- Fix divide-by-zero guard in grades data-access

- Fix audit export data truncation (paginated fetch for full datasets)

- Fix missing transactions in homework grading and elective lottery

- Fix missing revalidatePath in course-plans actions

- Fix frontend permission checks using requirePermission instead of requireAuth

- Fix dashboard role routing using session.user.roles

- Fix student auth pattern (migrate getDemoStudentUser to users module)

- Fix ActionState return type handling in components

Code quality fixes:

- Remove 60+ as type assertions (replace with type guards)

- Remove non-null assertions (use optional chaining or explicit checks)

- Convert dynamic imports to static imports (grades, diagnostic)

- Add React.cache() wrapping for read functions

- Parallelize independent queries with Promise.all

- Add explicit return types to 30+ arrow functions

- Replace any with unknown + type guards

- Fix import type for type-only imports

- Add Zod validation schemas for classes and diagnostic modules

- Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction)

- Add console.error to silent catch blocks

- Fix permission naming consistency (exam:proctor_read -> exam:proctor:read)

Architecture doc sync:

- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Update management-modules-audit.md for P0-7 cross-module fix

Moved deleted proctoring event route to deletes/ folder.
This commit is contained in:
SpecialX
2026-06-19 05:13:09 +08:00
parent 063baffe4c
commit 49291fcc31
114 changed files with 12548 additions and 3395 deletions

View File

@@ -1,19 +1,14 @@
"use server";
import { revalidatePath } from "next/cache"
import { and, eq, sql, or } from "drizzle-orm"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import { grades, classes } from "@/shared/db/schema"
import type { ActionState } from "@/shared/types/action-state"
import {
createAdminClass,
createClassScheduleItem,
createTeacherClass,
deleteAdminClass,
deleteClassScheduleItem,
deleteTeacherClass,
enrollStudentByEmail,
enrollStudentByInvitationCode,
@@ -23,10 +18,31 @@ import {
setClassSubjectTeachers,
setStudentEnrollmentStatus,
updateAdminClass,
updateClassScheduleItem,
updateTeacherClass,
getClassGradeId,
} from "./data-access"
import { findGradeIdByHeadAndName, isGradeHead, isGradeManager } from "@/modules/school/data-access"
import {
createClassScheduleItem,
updateClassScheduleItem,
deleteClassScheduleItem,
} from "@/modules/scheduling/data-access-class-schedule"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "./types"
import {
CreateTeacherClassSchema,
UpdateTeacherClassSchema,
DeleteTeacherClassSchema,
CreateAdminClassSchema,
UpdateAdminClassSchema,
DeleteAdminClassSchema,
CreateGradeClassSchema,
UpdateGradeClassSchema,
DeleteGradeClassSchema,
CreateClassScheduleItemSchema,
UpdateClassScheduleItemSchema,
DeleteClassScheduleItemSchema,
EnrollStudentByEmailSchema,
} from "./schema"
const isClassSubject = (v: string): v is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(v as ClassSubject)
@@ -37,45 +53,42 @@ export async function createTeacherClassAction(
try {
const ctx = await requirePermission(Permissions.CLASS_CREATE)
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const parsed = CreateTeacherClassSchema.safeParse({
name: formData.get("name"),
grade: formData.get("grade"),
schoolName: formData.get("schoolName"),
schoolId: formData.get("schoolId"),
gradeId: formData.get("gradeId"),
homeroom: formData.get("homeroom"),
room: formData.get("room"),
})
if (!parsed.success) {
return { success: false, message: "Class name and grade are required" }
}
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof grade !== "string" || grade.trim().length === 0) {
return { success: false, message: "Grade is required" }
}
const { name, grade, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
if (!ctx.roles.includes("admin")) {
const userId = ctx.userId
const normalizedGradeId = typeof gradeId === "string" ? gradeId.trim() : ""
const normalizedGradeName = grade.trim().toLowerCase()
const where = normalizedGradeId
? and(eq(grades.id, normalizedGradeId), eq(grades.gradeHeadId, userId))
: and(eq(grades.gradeHeadId, userId), sql`LOWER(${grades.name}) = ${normalizedGradeName}`)
const [ownedGrade] = await db.select({ id: grades.id }).from(grades).where(where).limit(1)
if (!ownedGrade) {
const isOwner = normalizedGradeId
? await isGradeHead(normalizedGradeId, userId)
: Boolean(await findGradeIdByHeadAndName(userId, grade))
if (!isOwner) {
return { success: false, message: "Only admins and grade heads can create classes" }
}
}
try {
const id = await createTeacherClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
schoolName: schoolName ?? null,
schoolId: schoolId ?? null,
name,
grade,
gradeId: typeof gradeId === "string" ? gradeId : null,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
gradeId: gradeId ?? null,
homeroom: homeroom ?? null,
room: room ?? null,
})
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
@@ -98,27 +111,31 @@ export async function updateTeacherClassAction(
try {
await requirePermission(Permissions.CLASS_UPDATE)
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
if (typeof classId !== "string" || classId.trim().length === 0) {
const parsed = UpdateTeacherClassSchema.safeParse({
classId,
schoolName: formData.get("schoolName"),
schoolId: formData.get("schoolId"),
name: formData.get("name"),
grade: formData.get("grade"),
gradeId: formData.get("gradeId"),
homeroom: formData.get("homeroom"),
room: formData.get("room"),
})
if (!parsed.success) {
return { success: false, message: "Missing class id" }
}
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, homeroom, room } = parsed.data
try {
await updateTeacherClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
await updateTeacherClass(validatedClassId, {
schoolName: schoolName ?? undefined,
schoolId: schoolId ?? undefined,
name: name ?? undefined,
grade: grade ?? undefined,
gradeId: gradeId ?? undefined,
homeroom: homeroom ?? undefined,
room: room ?? undefined,
})
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
@@ -137,12 +154,13 @@ export async function deleteTeacherClassAction(classId: string): Promise<ActionS
try {
await requirePermission(Permissions.CLASS_DELETE)
if (typeof classId !== "string" || classId.trim().length === 0) {
const parsed = DeleteTeacherClassSchema.safeParse({ classId })
if (!parsed.success) {
return { success: false, message: "Missing class id" }
}
try {
await deleteTeacherClass(classId)
await deleteTeacherClass(parsed.data.classId)
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/schedule")
@@ -163,46 +181,38 @@ export async function createGradeClassAction(
try {
const ctx = await requirePermission(Permissions.CLASS_CREATE)
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const parsed = CreateGradeClassSchema.safeParse({
name: formData.get("name"),
gradeId: formData.get("gradeId"),
teacherId: formData.get("teacherId"),
schoolName: formData.get("schoolName"),
schoolId: formData.get("schoolId"),
grade: formData.get("grade"),
homeroom: formData.get("homeroom"),
room: formData.get("room"),
})
if (!parsed.success) {
return { success: false, message: "Class name, grade and teacher are required" }
}
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof gradeId !== "string" || gradeId.trim().length === 0) {
return { success: false, message: "Grade selection is required" }
}
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
return { success: false, message: "Teacher is required" }
}
const { name, gradeId, teacherId, schoolName, schoolId, grade, homeroom, room } = parsed.data
// Verify access
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
.limit(1)
if (!managedGrade) {
const isManager = await isGradeManager(gradeId, ctx.userId)
if (!isManager) {
return { success: false, message: "You do not have permission to create classes for this grade" }
}
try {
const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
schoolName: schoolName ?? null,
schoolId: schoolId ?? null,
name,
grade: typeof grade === "string" ? grade : "", // Should be passed from UI based on selected grade
grade: grade ?? "", // Should be passed from UI based on selected grade
gradeId,
teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
homeroom: homeroom ?? null,
room: room ?? null,
})
revalidatePath("/management/grade/classes")
return { success: true, message: "Class created successfully", data: id }
@@ -223,73 +233,62 @@ export async function updateGradeClassAction(
try {
const ctx = await requirePermission(Permissions.CLASS_UPDATE)
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const subjectTeachers = formData.get("subjectTeachers")
if (typeof classId !== "string" || classId.trim().length === 0) {
const parsed = UpdateGradeClassSchema.safeParse({
classId,
schoolName: formData.get("schoolName"),
schoolId: formData.get("schoolId"),
name: formData.get("name"),
grade: formData.get("grade"),
gradeId: formData.get("gradeId"),
teacherId: formData.get("teacherId"),
homeroom: formData.get("homeroom"),
room: formData.get("room"),
})
if (!parsed.success) {
return { success: false, message: "Missing class id" }
}
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
const subjectTeachers = formData.get("subjectTeachers")
// Verify access: Check if the class belongs to a managed grade
const [cls] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!cls || !cls.gradeId) {
const classGradeId = await getClassGradeId(validatedClassId)
if (!classGradeId) {
return { success: false, message: "Class not found or not linked to a grade" }
}
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
.limit(1)
if (!managedGrade) {
const isManager = await isGradeManager(classGradeId, ctx.userId)
if (!isManager) {
return { success: false, message: "You do not have permission to update this class" }
}
// If changing gradeId, verify target grade too
if (typeof gradeId === "string" && gradeId !== cls.gradeId) {
const [targetGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
.limit(1)
if (!targetGrade) {
return { success: false, message: "You do not have permission to move class to this grade" }
}
if (typeof gradeId === "string" && gradeId !== classGradeId) {
const isTargetManager = await isGradeManager(gradeId, ctx.userId)
if (!isTargetManager) {
return { success: false, message: "You do not have permission to move class to this grade" }
}
}
try {
await updateAdminClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
teacherId: typeof teacherId === "string" ? teacherId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
await updateAdminClass(validatedClassId, {
schoolName: schoolName ?? undefined,
schoolId: schoolId ?? undefined,
name: name ?? undefined,
grade: grade ?? undefined,
gradeId: gradeId ?? undefined,
teacherId: teacherId ?? undefined,
homeroom: homeroom ?? undefined,
room: room ?? undefined,
})
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
const parsed = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
const parsedTeachers = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsedTeachers)) throw new Error("Invalid subject teachers")
await setClassSubjectTeachers({
classId,
assignments: parsed.flatMap((item) => {
classId: validatedClassId,
assignments: parsedTeachers.flatMap((item) => {
if (!item || typeof item !== "object") return []
const subject = (item as { subject?: unknown }).subject
const teacherId = (item as { teacherId?: unknown }).teacherId
@@ -322,33 +321,26 @@ export async function deleteGradeClassAction(classId: string): Promise<ActionSta
try {
const ctx = await requirePermission(Permissions.CLASS_DELETE)
if (typeof classId !== "string" || classId.trim().length === 0) {
const parsed = DeleteGradeClassSchema.safeParse({ classId })
if (!parsed.success) {
return { success: false, message: "Missing class id" }
}
const { classId: validatedClassId } = parsed.data
// Verify access
const [cls] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!cls || !cls.gradeId) {
const classGradeId = await getClassGradeId(validatedClassId)
if (!classGradeId) {
return { success: false, message: "Class not found or not linked to a grade" }
}
const [managedGrade] = await db
.select({ id: grades.id })
.from(grades)
.where(and(eq(grades.id, cls.gradeId), or(eq(grades.gradeHeadId, ctx.userId), eq(grades.teachingHeadId, ctx.userId))))
.limit(1)
if (!managedGrade) {
const isManager = await isGradeManager(classGradeId, ctx.userId)
if (!isManager) {
return { success: false, message: "You do not have permission to delete this class" }
}
try {
await deleteAdminClass(classId)
await deleteAdminClass(validatedClassId)
revalidatePath("/management/grade/classes")
return { success: true, message: "Class deleted successfully" }
} catch (error) {
@@ -368,16 +360,16 @@ export async function enrollStudentByEmailAction(
try {
await requirePermission(Permissions.CLASS_ENROLL)
const email = formData.get("email")
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Please select a class" }
}
if (typeof email !== "string" || email.trim().length === 0) {
return { success: false, message: "Student email is required" }
const parsed = EnrollStudentByEmailSchema.safeParse({
classId,
email: formData.get("email"),
})
if (!parsed.success) {
return { success: false, message: "Please select a class and provide student email" }
}
try {
await enrollStudentByEmail(classId, email)
await enrollStudentByEmail(parsed.data.classId, parsed.data.email)
revalidatePath("/teacher/classes/students")
revalidatePath("/teacher/classes/my")
return { success: true, message: "Student added successfully" }
@@ -508,38 +500,29 @@ export async function createClassScheduleItemAction(
try {
await requirePermission(Permissions.CLASS_SCHEDULE)
const classId = formData.get("classId")
const weekday = formData.get("weekday")
const startTime = formData.get("startTime")
const endTime = formData.get("endTime")
const course = formData.get("course")
const location = formData.get("location")
const parsed = CreateClassScheduleItemSchema.safeParse({
classId: formData.get("classId"),
weekday: formData.get("weekday"),
course: formData.get("course"),
startTime: formData.get("startTime"),
endTime: formData.get("endTime"),
location: formData.get("location"),
})
if (!parsed.success) {
return { success: false, message: "Invalid schedule item data" }
}
if (typeof classId !== "string" || classId.trim().length === 0) {
return { success: false, message: "Please select a class" }
}
if (typeof weekday !== "string" || weekday.trim().length === 0) {
return { success: false, message: "Weekday is required" }
}
const weekdayNum = Number(weekday)
if (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7) {
return { success: false, message: "Invalid weekday" }
}
if (typeof course !== "string" || course.trim().length === 0) {
return { success: false, message: "Course is required" }
}
if (typeof startTime !== "string" || typeof endTime !== "string") {
return { success: false, message: "Time is required" }
}
const { classId, weekday, course, startTime, endTime, location } = parsed.data
try {
const id = await createClassScheduleItem({
classId,
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7,
// weekday 已被 Zod 校验为 1-7 的整数,断言为 Weekday 联合类型
weekday: weekday as 1 | 2 | 3 | 4 | 5 | 6 | 7,
startTime,
endTime,
course,
location: typeof location === "string" ? location : null,
location: location ?? null,
})
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item created successfully", data: id }
@@ -560,30 +543,30 @@ export async function updateClassScheduleItemAction(
try {
await requirePermission(Permissions.CLASS_SCHEDULE)
const classId = formData.get("classId")
const weekday = formData.get("weekday")
const startTime = formData.get("startTime")
const endTime = formData.get("endTime")
const course = formData.get("course")
const location = formData.get("location")
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
return { success: false, message: "Missing schedule id" }
const parsed = UpdateClassScheduleItemSchema.safeParse({
scheduleId,
classId: formData.get("classId"),
weekday: formData.get("weekday") || undefined,
course: formData.get("course"),
startTime: formData.get("startTime"),
endTime: formData.get("endTime"),
location: formData.get("location"),
})
if (!parsed.success) {
return { success: false, message: "Missing or invalid schedule id" }
}
const weekdayNum = typeof weekday === "string" && weekday.trim().length > 0 ? Number(weekday) : undefined
if (weekdayNum !== undefined && (!Number.isInteger(weekdayNum) || weekdayNum < 1 || weekdayNum > 7)) {
return { success: false, message: "Invalid weekday" }
}
const { scheduleId: validatedScheduleId, classId, weekday, course, startTime, endTime, location } = parsed.data
try {
await updateClassScheduleItem(scheduleId, {
classId: typeof classId === "string" ? classId : undefined,
weekday: weekdayNum as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined,
startTime: typeof startTime === "string" ? startTime : undefined,
endTime: typeof endTime === "string" ? endTime : undefined,
course: typeof course === "string" ? course : undefined,
location: typeof location === "string" ? location : undefined,
await updateClassScheduleItem(validatedScheduleId, {
classId: classId ?? undefined,
// weekday 已被 Zod 校验为 1-7 的整数或 null/undefined断言为 Weekday 联合类型
weekday: (weekday ?? undefined) as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined,
startTime: startTime ?? undefined,
endTime: endTime ?? undefined,
course: course ?? undefined,
location: location ?? undefined,
})
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item updated successfully" }
@@ -600,12 +583,13 @@ export async function deleteClassScheduleItemAction(scheduleId: string): Promise
try {
await requirePermission(Permissions.CLASS_SCHEDULE)
if (typeof scheduleId !== "string" || scheduleId.trim().length === 0) {
const parsed = DeleteClassScheduleItemSchema.safeParse({ scheduleId })
if (!parsed.success) {
return { success: false, message: "Missing schedule id" }
}
try {
await deleteClassScheduleItem(scheduleId)
await deleteClassScheduleItem(parsed.data.scheduleId)
revalidatePath("/teacher/classes/schedule")
return { success: true, message: "Schedule item deleted successfully" }
} catch (error) {
@@ -624,35 +608,32 @@ export async function createAdminClassAction(
try {
await requirePermission(Permissions.CLASS_CREATE)
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const parsed = CreateAdminClassSchema.safeParse({
name: formData.get("name"),
grade: formData.get("grade"),
teacherId: formData.get("teacherId"),
schoolName: formData.get("schoolName"),
schoolId: formData.get("schoolId"),
gradeId: formData.get("gradeId"),
homeroom: formData.get("homeroom"),
room: formData.get("room"),
})
if (!parsed.success) {
return { success: false, message: "Class name, grade and teacher are required" }
}
if (typeof name !== "string" || name.trim().length === 0) {
return { success: false, message: "Class name is required" }
}
if (typeof grade !== "string" || grade.trim().length === 0) {
return { success: false, message: "Grade is required" }
}
if (typeof teacherId !== "string" || teacherId.trim().length === 0) {
return { success: false, message: "Teacher is required" }
}
const { name, grade, teacherId, schoolName, schoolId, gradeId, homeroom, room } = parsed.data
try {
const id = await createAdminClass({
schoolName: typeof schoolName === "string" ? schoolName : null,
schoolId: typeof schoolId === "string" ? schoolId : null,
schoolName: schoolName ?? null,
schoolId: schoolId ?? null,
name,
grade,
gradeId: typeof gradeId === "string" ? gradeId : null,
gradeId: gradeId ?? null,
teacherId,
homeroom: typeof homeroom === "string" ? homeroom : null,
room: typeof room === "string" ? room : null,
homeroom: homeroom ?? null,
room: room ?? null,
})
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
@@ -676,39 +657,43 @@ export async function updateAdminClassAction(
try {
await requirePermission(Permissions.CLASS_UPDATE)
const schoolName = formData.get("schoolName")
const schoolId = formData.get("schoolId")
const name = formData.get("name")
const grade = formData.get("grade")
const gradeId = formData.get("gradeId")
const teacherId = formData.get("teacherId")
const homeroom = formData.get("homeroom")
const room = formData.get("room")
const subjectTeachers = formData.get("subjectTeachers")
if (typeof classId !== "string" || classId.trim().length === 0) {
const parsed = UpdateAdminClassSchema.safeParse({
classId,
schoolName: formData.get("schoolName"),
schoolId: formData.get("schoolId"),
name: formData.get("name"),
grade: formData.get("grade"),
gradeId: formData.get("gradeId"),
teacherId: formData.get("teacherId"),
homeroom: formData.get("homeroom"),
room: formData.get("room"),
})
if (!parsed.success) {
return { success: false, message: "Missing class id" }
}
const { classId: validatedClassId, schoolName, schoolId, name, grade, gradeId, teacherId, homeroom, room } = parsed.data
const subjectTeachers = formData.get("subjectTeachers")
try {
await updateAdminClass(classId, {
schoolName: typeof schoolName === "string" ? schoolName : undefined,
schoolId: typeof schoolId === "string" ? schoolId : undefined,
name: typeof name === "string" ? name : undefined,
grade: typeof grade === "string" ? grade : undefined,
gradeId: typeof gradeId === "string" ? gradeId : undefined,
teacherId: typeof teacherId === "string" ? teacherId : undefined,
homeroom: typeof homeroom === "string" ? homeroom : undefined,
room: typeof room === "string" ? room : undefined,
await updateAdminClass(validatedClassId, {
schoolName: schoolName ?? undefined,
schoolId: schoolId ?? undefined,
name: name ?? undefined,
grade: grade ?? undefined,
gradeId: gradeId ?? undefined,
teacherId: teacherId ?? undefined,
homeroom: homeroom ?? undefined,
room: room ?? undefined,
})
if (typeof subjectTeachers === "string" && subjectTeachers.trim().length > 0) {
const parsed = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsed)) throw new Error("Invalid subject teachers")
const parsedTeachers = JSON.parse(subjectTeachers) as unknown
if (!Array.isArray(parsedTeachers)) throw new Error("Invalid subject teachers")
await setClassSubjectTeachers({
classId,
assignments: parsed.flatMap((item) => {
classId: validatedClassId,
assignments: parsedTeachers.flatMap((item) => {
if (!item || typeof item !== "object") return []
const subject = (item as { subject?: unknown }).subject
const teacherId = (item as { teacherId?: unknown }).teacherId
@@ -744,12 +729,13 @@ export async function deleteAdminClassAction(classId: string): Promise<ActionSta
try {
await requirePermission(Permissions.CLASS_DELETE)
if (typeof classId !== "string" || classId.trim().length === 0) {
const parsed = DeleteAdminClassSchema.safeParse({ classId })
if (!parsed.success) {
return { success: false, message: "Missing class id" }
}
try {
await deleteAdminClass(classId)
await deleteAdminClass(parsed.data.classId)
revalidatePath("/admin/school/classes")
revalidatePath("/teacher/classes/my")
revalidatePath("/teacher/classes/students")

View File

@@ -31,6 +31,12 @@ import {
isDuplicateInvitationCodeError,
} from "./data-access"
const isClassSubject = (v: unknown): v is ClassSubject =>
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
const toClassSubject = (v: string): ClassSubject | null =>
isClassSubject(v) ? v : null
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
const [rows, subjectRows] = await Promise.all([
(async () => {
@@ -79,7 +85,8 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
asc(classes.homeroom),
asc(classes.room)
)
} catch {
} catch (error) {
console.error("getAdminClasses primary query failed, falling back:", error)
return await db
.select({
id: classes.id,
@@ -132,8 +139,8 @@ export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> =>
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
for (const r of subjectRows) {
const subject = r.subject as ClassSubject
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
const subject = toClassSubject(r.subject)
if (!subject) continue
const teacher =
typeof r.teacherId === "string" && r.teacherId.length > 0
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
@@ -234,7 +241,8 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
asc(classes.homeroom),
asc(classes.room)
)
} catch {
} catch (error) {
console.error("getGradeManagedClasses primary query failed:", error)
return []
}
})(),
@@ -256,8 +264,8 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
for (const r of subjectRows) {
const subject = r.subject as ClassSubject
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
const subject = toClassSubject(r.subject)
if (!subject) continue
const teacher =
typeof r.teacherId === "string" && r.teacherId.length > 0
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
@@ -300,7 +308,7 @@ export const getGradeManagedClasses = cache(async (userId: string): Promise<Admi
return list
})
export const getManagedGrades = cache(async (userId: string) => {
export const getManagedGrades = cache(async (userId: string): Promise<{ id: string; name: string; schoolId: string; schoolName: string }[]> => {
return await db
.select({
id: grades.id,
@@ -346,7 +354,11 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
const idByName = new Map<ClassSubject, string>()
for (const r of subjectRows) {
const subject = toClassSubject(r.name)
if (subject) idByName.set(subject, r.id)
}
await db.transaction(async (tx) => {
await tx.insert(classes).values({
@@ -362,13 +374,11 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
teacherId,
})
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId: id,
subjectId: idByName.get(name)!,
teacherId: null,
}))
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
const subjectId = idByName.get(name)
if (!subjectId) return []
return [{ classId: id, subjectId, teacherId: null }]
})
await tx.insert(classSubjectTeachers).values(values)
})
return id
@@ -378,8 +388,6 @@ export async function createAdminClass(data: CreateTeacherClassInput & { teacher
}
}
throw new Error("Failed to create class")
return id
}
export async function updateAdminClass(

View File

@@ -9,23 +9,21 @@ import {
classEnrollments,
classSchedule,
} from "@/shared/db/schema"
import {
insertClassScheduleItem,
updateClassScheduleItemById,
deleteClassScheduleItemById,
} from "@/modules/scheduling/data-access"
import type {
ClassScheduleItem,
CreateClassScheduleItemInput,
StudentScheduleItem,
UpdateClassScheduleItemInput,
} from "./types"
import {
getAccessibleClassIdsForTeacher,
getSessionTeacherId,
getTeacherIdForMutations,
} from "./data-access"
const isWeekday = (n: unknown): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
typeof n === "number" && n >= 1 && n <= 7 && Number.isInteger(n)
const toWeekday = (n: number): 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
isWeekday(n) ? n : 1
export const getStudentSchedule = cache(async (studentId: string): Promise<StudentScheduleItem[]> => {
const id = studentId.trim()
if (!id) return []
@@ -51,7 +49,7 @@ export const getStudentSchedule = cache(async (studentId: string): Promise<Stude
id: r.id,
classId: r.classId,
className: r.className,
weekday: r.weekday as StudentScheduleItem["weekday"],
weekday: toWeekday(r.weekday),
startTime: r.startTime,
endTime: r.endTime,
course: r.course,
@@ -90,7 +88,7 @@ export const getClassSchedule = cache(
return rows.map((r) => ({
id: r.id,
classId: r.classId,
weekday: r.weekday as ClassScheduleItem["weekday"],
weekday: toWeekday(r.weekday),
startTime: r.startTime,
endTime: r.endTime,
course: r.course,
@@ -98,133 +96,3 @@ export const getClassSchedule = cache(
}))
}
)
const isTimeHHMM = (v: string) => /^\d{2}:\d{2}$/.test(v)
export async function createClassScheduleItem(data: CreateClassScheduleItemInput): Promise<string> {
const teacherId = await getTeacherIdForMutations()
const classId = data.classId.trim()
const course = data.course.trim()
const startTime = data.startTime.trim()
const endTime = data.endTime.trim()
const location = data.location?.trim() || null
const weekday = data.weekday
if (!classId) throw new Error("Class is required")
if (!course) throw new Error("Course is required")
if (!isTimeHHMM(startTime) || !isTimeHHMM(endTime)) throw new Error("Invalid time format")
if (startTime >= endTime) throw new Error("Start time must be earlier than end time")
if (weekday < 1 || weekday > 7) throw new Error("Invalid weekday")
const [owned] = await db
.select({ id: classes.id })
.from(classes)
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
.limit(1)
if (!owned) throw new Error("Class not found")
// Delegate DB write to scheduling module (unified write entry point)
return insertClassScheduleItem({
classId,
weekday,
startTime,
endTime,
course,
location,
})
}
export async function updateClassScheduleItem(scheduleId: string, data: UpdateClassScheduleItemInput): Promise<void> {
const teacherId = await getTeacherIdForMutations()
const id = scheduleId.trim()
if (!id) throw new Error("Missing schedule id")
const [existing] = await db
.select({
id: classSchedule.id,
classId: classSchedule.classId,
startTime: classSchedule.startTime,
endTime: classSchedule.endTime,
})
.from(classSchedule)
.innerJoin(classes, eq(classes.id, classSchedule.classId))
.where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId)))
.limit(1)
if (!existing) throw new Error("Schedule item not found")
const update: Partial<typeof classSchedule.$inferSelect> = {}
if (typeof data.classId === "string") {
const nextClassId = data.classId.trim()
if (!nextClassId) throw new Error("Class is required")
const [ownedNext] = await db
.select({ id: classes.id })
.from(classes)
.where(and(eq(classes.id, nextClassId), eq(classes.teacherId, teacherId)))
.limit(1)
if (!ownedNext) throw new Error("Class not found")
update.classId = nextClassId
}
if (typeof data.weekday === "number") {
if (data.weekday < 1 || data.weekday > 7) throw new Error("Invalid weekday")
update.weekday = data.weekday
}
if (typeof data.course === "string") {
const course = data.course.trim()
if (!course) throw new Error("Course is required")
update.course = course
}
const nextStart = typeof data.startTime === "string" ? data.startTime.trim() : undefined
const nextEnd = typeof data.endTime === "string" ? data.endTime.trim() : undefined
if (nextStart !== undefined) {
if (!isTimeHHMM(nextStart)) throw new Error("Invalid time format")
update.startTime = nextStart
}
if (nextEnd !== undefined) {
if (!isTimeHHMM(nextEnd)) throw new Error("Invalid time format")
update.endTime = nextEnd
}
if (update.startTime !== undefined || update.endTime !== undefined) {
const mergedStart = update.startTime ?? existing.startTime
const mergedEnd = update.endTime ?? existing.endTime
if (typeof mergedStart === "string" && typeof mergedEnd === "string" && mergedStart >= mergedEnd) {
throw new Error("Start time must be earlier than end time")
}
}
if (data.location !== undefined) {
update.location = data.location?.trim() || null
}
if (Object.keys(update).length === 0) return
// Delegate DB write to scheduling module (unified write entry point)
await updateClassScheduleItemById(id, update)
}
export async function deleteClassScheduleItem(scheduleId: string): Promise<void> {
const teacherId = await getTeacherIdForMutations()
const id = scheduleId.trim()
if (!id) throw new Error("Missing schedule id")
const [owned] = await db
.select({ id: classSchedule.id })
.from(classSchedule)
.innerJoin(classes, eq(classes.id, classSchedule.classId))
.where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId)))
.limit(1)
if (!owned) throw new Error("Schedule item not found")
// Delegate DB write to scheduling module (unified write entry point)
await deleteClassScheduleItemById(id)
}

View File

@@ -1,21 +1,23 @@
import "server-only";
import { cache } from "react"
import { and, asc, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { and, asc, count, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
grades,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
schools,
subjects,
exams,
} from "@/shared/db/schema"
import {
getAssignmentIdsForStudents,
getAssignmentMaxScoreById,
getAssignmentTargetCounts,
getHomeworkAssignmentsByIds,
getHomeworkAssignmentsWithSubject,
getHomeworkSubmissionsForStudents,
} from "@/modules/homework/data-access-classes"
import type {
ClassHomeworkInsights,
ClassHomeworkAssignmentStats,
@@ -23,6 +25,7 @@ import type {
GradeHomeworkInsights,
ScoreStats,
} from "./types"
import type { HomeworkSubmissionRecord } from "@/modules/homework/data-access-classes"
import {
getAccessibleClassIdsForTeacher,
getSessionTeacherId,
@@ -52,6 +55,74 @@ const toScoreStats = (scores: number[]): ScoreStats => {
}
}
const buildLatestSubmissionByKey = (
submissions: HomeworkSubmissionRecord[]
): Map<string, HomeworkSubmissionRecord> => {
const map = new Map<string, HomeworkSubmissionRecord>()
for (const s of submissions) {
const key = `${s.assignmentId}:${s.studentId}`
if (!map.has(key)) map.set(key, s)
}
return map
}
const computeAssignmentStats = (params: {
assignments: Array<{
id: string
title: string
status: string | null
createdAt: Date
dueAt: Date | null
subjectName?: string | null
}>
studentIds: string[]
latestByKey: Map<string, HomeworkSubmissionRecord>
maxScoreByAssignmentId: Map<string, number>
targetCountByAssignmentId: Map<string, number>
}): { stats: ClassHomeworkAssignmentStats[]; allScored: number[] } => {
const { assignments, studentIds, latestByKey, maxScoreByAssignmentId, targetCountByAssignmentId } = params
const allScored: number[] = []
const nowMs = Date.now()
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
let submittedCount = 0
let gradedCount = 0
const scores: number[] = []
const dueMs = a.dueAt ? a.dueAt.getTime() : null
for (const studentId of studentIds) {
const s = latestByKey.get(`${a.id}:${studentId}`)
if (!s) continue
const status = s.status ?? "started"
if (status === "submitted" || status === "graded") submittedCount += 1
if (status === "graded" || typeof s.score === "number") gradedCount += 1
if (typeof s.score === "number") scores.push(s.score)
}
allScored.push(...scores)
return {
assignmentId: a.id,
title: a.title,
status: a.status ?? "draft",
subject: a.subjectName ?? null,
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
targetCount,
submittedCount,
gradedCount,
scoreStats: toScoreStats(scores),
}
})
return { stats, allScored }
}
export const getClassHomeworkInsights = cache(
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
const teacherId = params.teacherId ?? (await getSessionTeacherId())
@@ -127,12 +198,7 @@ export const getClassHomeworkInsights = cache(
}
}
const assignmentIdRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
const assignmentIds = await getAssignmentIdsForStudents(studentIds)
if (assignmentIds.length === 0) {
return {
class: {
@@ -151,26 +217,11 @@ export const getClassHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
if (subjectIdFilter.length > 0) {
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
}
const assignments = await db
.select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
status: homeworkAssignments.status,
createdAt: homeworkAssignments.createdAt,
dueAt: homeworkAssignments.dueAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(...assignmentConditions))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
const assignments = await getHomeworkAssignmentsWithSubject({
assignmentIds,
subjectIdFilter: subjectIdFilter.length > 0 ? subjectIdFilter : undefined,
limit,
})
const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) {
@@ -190,86 +241,19 @@ export const getClassHomeworkInsights = cache(
}
}
const maxScoreRows = await db
.select({
assignmentId: homeworkAssignmentQuestions.assignmentId,
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
getAssignmentMaxScoreById(usedAssignmentIds),
getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
])
const maxScoreByAssignmentId = new Map<string, number>()
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
const targetCountRows = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(
and(
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
inArray(homeworkAssignmentTargets.studentId, studentIds)
)
)
.groupBy(homeworkAssignmentTargets.assignmentId)
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
inArray(homeworkSubmissions.studentId, studentIds)
),
orderBy: [desc(homeworkSubmissions.createdAt)],
})
const latestByKey = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const key = `${s.assignmentId}:${s.studentId}`
if (!latestByKey.has(key)) latestByKey.set(key, s)
}
const allScored: number[] = []
const nowMs = Date.now()
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
let submittedCount = 0
let gradedCount = 0
const scores: number[] = []
const dueMs = a.dueAt ? a.dueAt.getTime() : null
for (const studentId of studentIds) {
const s = latestByKey.get(`${a.id}:${studentId}`)
if (!s) continue
const status = (s.status ?? "started") as string
if (status === "submitted" || status === "graded") submittedCount += 1
if (status === "graded" || typeof s.score === "number") gradedCount += 1
if (typeof s.score === "number") scores.push(s.score)
}
allScored.push(...scores)
return {
assignmentId: a.id,
title: a.title,
status: (a.status as string) ?? "draft",
subject: a.subjectName,
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
targetCount,
submittedCount,
gradedCount,
scoreStats: toScoreStats(scores),
}
const latestByKey = buildLatestSubmissionByKey(submissions)
const { stats, allScored } = computeAssignmentStats({
assignments,
studentIds,
latestByKey,
maxScoreByAssignmentId,
targetCountByAssignmentId,
})
const overallScores = toScoreStats(allScored)
@@ -390,12 +374,7 @@ export const getGradeHomeworkInsights = cache(
}
}
const assignmentIdRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
const assignmentIds = await getAssignmentIdsForStudents(studentIds)
if (assignmentIds.length === 0) {
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() }
@@ -421,11 +400,7 @@ export const getGradeHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignments = await db.query.homeworkAssignments.findMany({
where: inArray(homeworkAssignments.id, assignmentIds),
orderBy: [desc(homeworkAssignments.createdAt)],
limit,
})
const assignments = await getHomeworkAssignmentsByIds({ assignmentIds, limit })
const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) {
@@ -452,85 +427,19 @@ export const getGradeHomeworkInsights = cache(
}
}
const maxScoreRows = await db
.select({
assignmentId: homeworkAssignmentQuestions.assignmentId,
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
getAssignmentMaxScoreById(usedAssignmentIds),
getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
])
const maxScoreByAssignmentId = new Map<string, number>()
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
const targetCountRows = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(
and(
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
inArray(homeworkAssignmentTargets.studentId, studentIds)
)
)
.groupBy(homeworkAssignmentTargets.assignmentId)
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
inArray(homeworkSubmissions.studentId, studentIds)
),
orderBy: [desc(homeworkSubmissions.createdAt)],
})
const latestByKey = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const key = `${s.assignmentId}:${s.studentId}`
if (!latestByKey.has(key)) latestByKey.set(key, s)
}
const allScored: number[] = []
const nowMs = Date.now()
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
let submittedCount = 0
let gradedCount = 0
const scores: number[] = []
const dueMs = a.dueAt ? a.dueAt.getTime() : null
for (const studentId of studentIds) {
const s = latestByKey.get(`${a.id}:${studentId}`)
if (!s) continue
const status = (s.status ?? "started") as string
if (status === "submitted" || status === "graded") submittedCount += 1
if (status === "graded" || typeof s.score === "number") gradedCount += 1
if (typeof s.score === "number") scores.push(s.score)
}
allScored.push(...scores)
return {
assignmentId: a.id,
title: a.title,
status: (a.status as string) ?? "draft",
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
targetCount,
submittedCount,
gradedCount,
scoreStats: toScoreStats(scores),
}
const latestByKey = buildLatestSubmissionByKey(submissions)
const { stats, allScored } = computeAssignmentStats({
assignments,
studentIds,
latestByKey,
maxScoreByAssignmentId,
targetCountByAssignmentId,
})
const overallScores = toScoreStats(allScored)

View File

@@ -1,19 +1,20 @@
import "server-only";
import { cache } from "react"
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
subjects,
exams,
users,
} from "@/shared/db/schema"
import {
getAssignmentIdsForStudents,
getHomeworkSubmissionsForAssignments,
getPublishedHomeworkAssignmentsWithSubject,
} from "@/modules/homework/data-access-classes"
import type {
ClassStudent,
StudentEnrolledClass,
@@ -29,31 +30,12 @@ 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
const assignmentTargets = await db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
// 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 assignment details including subject from linked exam
const assignments = await db
.select({
id: homeworkAssignments.id,
createdAt: homeworkAssignments.createdAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(
inArray(homeworkAssignments.id, assignmentIds),
eq(homeworkAssignments.status, "published")
))
.orderBy(desc(homeworkAssignments.createdAt))
// 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 = ["体育", "音乐", "美术"]
@@ -70,17 +52,8 @@ export const getStudentsSubjectScores = cache(
const targetAssignmentIds = Array.from(subjectAssignments.values())
if (targetAssignmentIds.length === 0) return new Map()
// 4. Get submissions for these assignments
const submissions = await db
.select({
studentId: homeworkSubmissions.studentId,
assignmentId: homeworkSubmissions.assignmentId,
score: homeworkSubmissions.score,
createdAt: homeworkSubmissions.createdAt,
})
.from(homeworkSubmissions)
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
.orderBy(desc(homeworkSubmissions.createdAt))
// 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>>()
@@ -95,11 +68,11 @@ export const getStudentsSubjectScores = cache(
const subject = assignmentSubjectMap.get(s.assignmentId)
if (!subject) continue
if (!studentScores.has(s.studentId)) {
studentScores.set(s.studentId, {})
const existing = studentScores.get(s.studentId)
const scores = existing ?? {}
if (!existing) {
studentScores.set(s.studentId, scores)
}
const scores = studentScores.get(s.studentId)!
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
if (scores[subject] === undefined) {
scores[subject] = s.score
@@ -183,7 +156,8 @@ export const getStudentClasses = cache(async (studentId: string): Promise<Studen
.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 {
} catch (error) {
console.error("getStudentClasses primary query failed, falling back:", error)
return await db
.select({
id: classes.id,

View File

@@ -26,6 +26,12 @@ import type {
import { getClassHomeworkInsights } from "./data-access-stats"
import { getClassSchedule } from "./data-access-schedule"
const isClassSubject = (v: unknown): v is ClassSubject =>
typeof v === "string" && (DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(v)
const toClassSubject = (v: string): ClassSubject | null =>
isClassSubject(v) ? v : null
export const getSessionTeacherId = async (): Promise<string | null> => {
const { auth } = await import("@/auth")
const session = await auth()
@@ -118,14 +124,44 @@ export const compareClassLike = (
}
export const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId))
const assignedIds = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, teacherId))
const [ownedIds, assignedIds] = await Promise.all([
db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId)),
db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, teacherId)),
])
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
}
/**
* Verify that a teacher owns a class (teacherId match on classes row).
* Used by scheduling module to gate classSchedule writes.
*/
export async function verifyTeacherOwnsClass(classId: string, teacherId: string): Promise<boolean> {
const [owned] = await db
.select({ id: classes.id })
.from(classes)
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
.limit(1)
return Boolean(owned)
}
export const getClassGradeIdsByClassIds = async (classIds: string[]): Promise<Map<string, string>> => {
if (classIds.length === 0) return new Map()
const rows = await db
.select({ id: classes.id, gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, classIds))
const map = new Map<string, string>()
for (const row of rows) {
if (typeof row.gradeId === "string" && row.gradeId.trim().length > 0) {
map.set(row.id, row.gradeId)
}
}
return map
}
export const getTeacherSubjectIdsForClass = async (teacherId: string, classId: string): Promise<string[]> => {
const rows = await db
.select({ subjectId: classSubjectTeachers.subjectId })
@@ -134,6 +170,178 @@ export const getTeacherSubjectIdsForClass = async (teacherId: string, classId: s
return Array.from(new Set(rows.map((r) => String(r.subjectId))))
}
/**
* 获取班级的教师 ID班主任
* 供跨模块调用使用,避免直接查询 classes 表。
*/
export const getClassTeacherById = async (classId: string): Promise<string | null> => {
const [row] = await db
.select({ teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
return row?.teacherId ?? null
}
/**
* 获取班级所有学生 ID不限状态
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
*/
export const getStudentIdsByClassId = async (classId: string): Promise<string[]> => {
const rows = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(eq(classEnrollments.classId, classId))
return rows.map((r) => r.studentId)
}
/**
* 获取多个班级的所有学生 ID不限状态
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
*/
export const getStudentIdsByClassIds = async (classIds: string[]): Promise<string[]> => {
if (classIds.length === 0) return []
const rows = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(inArray(classEnrollments.classId, classIds))
return Array.from(new Set(rows.map((r) => r.studentId)))
}
/**
* 获取班级所有活跃学生 IDstatus = 'active')。
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
*/
export const getActiveStudentIdsByClassId = async (classId: string): Promise<string[]> => {
const rows = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
return rows.map((r) => r.studentId)
}
/**
* 获取教师在一个班级所教的科目 ID 列表。
* 参数顺序为 (classId, teacherId),供跨模块调用使用。
*/
export const getTeacherSubjectIdsByClass = async (classId: string, teacherId: string): Promise<string[]> => {
return getTeacherSubjectIdsForClass(teacherId, classId)
}
/**
* 获取学生当前活跃班级的 ID。
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
*/
export const getStudentActiveClassId = async (studentId: string): Promise<string | null> => {
const [row] = await db
.select({ classId: classEnrollments.classId })
.from(classEnrollments)
.where(and(eq(classEnrollments.studentId, studentId), eq(classEnrollments.status, "active")))
.orderBy(asc(classEnrollments.createdAt))
.limit(1)
return row?.classId ?? null
}
/**
* 获取学生当前活跃班级对应的年级 ID。
* 供跨模块调用使用,避免直接查询 classEnrollments/classes 表。
*/
export const getStudentActiveGradeId = async (studentId: string): Promise<string | null> => {
const [row] = await db
.select({ gradeId: classes.gradeId })
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(and(eq(classEnrollments.studentId, studentId), eq(classEnrollments.status, "active")))
.orderBy(asc(classEnrollments.createdAt))
.limit(1)
return row?.gradeId ?? null
}
/**
* 校验班级是否存在。
* 供跨模块调用使用,避免直接查询 classes 表。
*/
export const getClassExists = async (classId: string): Promise<boolean> => {
const [row] = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
return Boolean(row)
}
/**
* 获取班级名称。
* 供跨模块调用使用,避免直接查询 classes 表。
*/
export const getClassNameById = async (classId: string): Promise<string | null> => {
const [row] = await db
.select({ name: classes.name })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
return row?.name ?? null
}
/**
* 获取班级关联的年级 ID。
* 供跨模块调用使用,避免直接查询 classes 表。
*/
export const getClassGradeId = async (classId: string): Promise<string | null> => {
const [row] = await db
.select({ gradeId: classes.gradeId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
return row?.gradeId ?? null
}
/**
* 获取多个班级关联的年级 ID 列表(去重,过滤空值)。
* 供跨模块调用使用,避免直接查询 classes 表。
*/
export const getGradeIdsByClassIds = async (classIds: string[]): Promise<string[]> => {
if (classIds.length === 0) return []
const rows = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, classIds))
return rows
.map((r) => r.gradeId)
.filter((id): id is string => typeof id === "string" && id.length > 0)
}
/**
* 批量获取班级名称Map<classId, name>)。
* 供跨模块调用使用,避免直接查询 classes 表。
*/
export const getClassNamesByIds = async (classIds: string[]): Promise<Map<string, string>> => {
const result = new Map<string, string>()
const uniqueIds = Array.from(new Set(classIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
if (uniqueIds.length === 0) return result
const rows = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(inArray(classes.id, uniqueIds))
for (const r of rows) result.set(r.id, r.name)
return result
}
/**
* 获取指定年级下的所有班级id + name
* 供跨模块调用使用,避免直接查询 classes 表。
*/
export const getClassesByGradeId = async (gradeId: string): Promise<Array<{ id: string; name: string }>> => {
if (!gradeId) return []
const rows = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.gradeId, gradeId))
return rows.map((r) => ({ id: r.id, name: r.name }))
}
export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise<TeacherClass[]> => {
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
@@ -237,8 +445,8 @@ export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]
.orderBy(asc(subjects.name))
return rows
.map((r) => r.subject as ClassSubject)
.filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s))
.map((r) => toClassSubject(r.subject))
.filter((s): s is ClassSubject => s !== null)
})
export async function createTeacherClass(data: CreateTeacherClassInput): Promise<string> {
@@ -263,7 +471,11 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
const idByName = new Map<ClassSubject, string>()
for (const r of subjectRows) {
const subject = toClassSubject(r.name)
if (subject) idByName.set(subject, r.id)
}
await db.transaction(async (tx) => {
await tx.insert(classes).values({
@@ -279,13 +491,11 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
teacherId,
})
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId: id,
subjectId: idByName.get(name)!,
teacherId: null,
}))
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
const subjectId = idByName.get(name)
if (!subjectId) return []
return [{ classId: id, subjectId, teacherId: null }]
})
await tx.insert(classSubjectTeachers).values(values)
})
return id
@@ -295,8 +505,6 @@ export async function createTeacherClass(data: CreateTeacherClassInput): Promise
}
}
throw new Error("Failed to create class")
return id
}
export async function ensureClassInvitationCode(classId: string): Promise<string> {
@@ -558,15 +766,17 @@ export async function setClassSubjectTeachers(params: {
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
const idByName = new Map(subjectRows.map((r) => [r.name as ClassSubject, r.id]))
const idByName = new Map<ClassSubject, string>()
for (const r of subjectRows) {
const subject = toClassSubject(r.name)
if (subject) idByName.set(subject, r.id)
}
const values = DEFAULT_CLASS_SUBJECTS
.filter((name) => idByName.has(name))
.map((name) => ({
classId,
subjectId: idByName.get(name)!,
teacherId: teacherBySubject.get(name) ?? null,
}))
const values = DEFAULT_CLASS_SUBJECTS.flatMap((name) => {
const subjectId = idByName.get(name)
if (!subjectId) return []
return [{ classId, subjectId, teacherId: teacherBySubject.get(name) ?? null }]
})
await db
.insert(classSubjectTeachers)

View File

@@ -0,0 +1,157 @@
import { z } from "zod"
// ============ Teacher Class Schemas ============
/** 教师创建班级 */
export const CreateTeacherClassSchema = z.object({
name: z.string().trim().min(1),
grade: z.string().trim().min(1),
schoolName: z.string().nullable().optional(),
schoolId: z.string().nullable().optional(),
gradeId: z.string().nullable().optional(),
homeroom: z.string().nullable().optional(),
room: z.string().nullable().optional(),
})
export type CreateTeacherClassInput = z.infer<typeof CreateTeacherClassSchema>
/** 教师更新班级 */
export const UpdateTeacherClassSchema = z.object({
classId: z.string().trim().min(1),
schoolName: z.string().nullable().optional(),
schoolId: z.string().nullable().optional(),
name: z.string().nullable().optional(),
grade: z.string().nullable().optional(),
gradeId: z.string().nullable().optional(),
homeroom: z.string().nullable().optional(),
room: z.string().nullable().optional(),
})
export type UpdateTeacherClassInput = z.infer<typeof UpdateTeacherClassSchema>
/** 教师删除班级 */
export const DeleteTeacherClassSchema = z.object({
classId: z.string().trim().min(1),
})
export type DeleteTeacherClassInput = z.infer<typeof DeleteTeacherClassSchema>
// ============ Admin Class Schemas ============
/** 管理员创建班级 */
export const CreateAdminClassSchema = z.object({
name: z.string().trim().min(1),
grade: z.string().trim().min(1),
teacherId: z.string().trim().min(1),
schoolName: z.string().nullable().optional(),
schoolId: z.string().nullable().optional(),
gradeId: z.string().nullable().optional(),
homeroom: z.string().nullable().optional(),
room: z.string().nullable().optional(),
})
export type CreateAdminClassInput = z.infer<typeof CreateAdminClassSchema>
/** 管理员更新班级 */
export const UpdateAdminClassSchema = z.object({
classId: z.string().trim().min(1),
schoolName: z.string().nullable().optional(),
schoolId: z.string().nullable().optional(),
name: z.string().nullable().optional(),
grade: z.string().nullable().optional(),
gradeId: z.string().nullable().optional(),
teacherId: z.string().nullable().optional(),
homeroom: z.string().nullable().optional(),
room: z.string().nullable().optional(),
})
export type UpdateAdminClassInput = z.infer<typeof UpdateAdminClassSchema>
/** 管理员删除班级 */
export const DeleteAdminClassSchema = z.object({
classId: z.string().trim().min(1),
})
export type DeleteAdminClassInput = z.infer<typeof DeleteAdminClassSchema>
// ============ Grade Class Schemas ============
/** 年级主任创建班级 */
export const CreateGradeClassSchema = z.object({
name: z.string().trim().min(1),
gradeId: z.string().trim().min(1),
teacherId: z.string().trim().min(1),
schoolName: z.string().nullable().optional(),
schoolId: z.string().nullable().optional(),
grade: z.string().nullable().optional(),
homeroom: z.string().nullable().optional(),
room: z.string().nullable().optional(),
})
export type CreateGradeClassInput = z.infer<typeof CreateGradeClassSchema>
/** 年级主任更新班级 */
export const UpdateGradeClassSchema = z.object({
classId: z.string().trim().min(1),
schoolName: z.string().nullable().optional(),
schoolId: z.string().nullable().optional(),
name: z.string().nullable().optional(),
grade: z.string().nullable().optional(),
gradeId: z.string().nullable().optional(),
teacherId: z.string().nullable().optional(),
homeroom: z.string().nullable().optional(),
room: z.string().nullable().optional(),
})
export type UpdateGradeClassInput = z.infer<typeof UpdateGradeClassSchema>
/** 年级主任删除班级 */
export const DeleteGradeClassSchema = z.object({
classId: z.string().trim().min(1),
})
export type DeleteGradeClassInput = z.infer<typeof DeleteGradeClassSchema>
// ============ Class Schedule Item Schemas ============
/** 创建课表项 */
export const CreateClassScheduleItemSchema = z.object({
classId: z.string().trim().min(1),
weekday: z.coerce.number().int().min(1).max(7),
course: z.string().trim().min(1),
startTime: z.string().min(1),
endTime: z.string().min(1),
location: z.string().nullable().optional(),
})
export type CreateClassScheduleItemInput = z.infer<typeof CreateClassScheduleItemSchema>
/** 更新课表项 */
export const UpdateClassScheduleItemSchema = z.object({
scheduleId: z.string().trim().min(1),
classId: z.string().nullable().optional(),
weekday: z.coerce.number().int().min(1).max(7).nullable().optional(),
course: z.string().nullable().optional(),
startTime: z.string().nullable().optional(),
endTime: z.string().nullable().optional(),
location: z.string().nullable().optional(),
})
export type UpdateClassScheduleItemInput = z.infer<typeof UpdateClassScheduleItemSchema>
/** 删除课表项 */
export const DeleteClassScheduleItemSchema = z.object({
scheduleId: z.string().trim().min(1),
})
export type DeleteClassScheduleItemInput = z.infer<typeof DeleteClassScheduleItemSchema>
// ============ Enrollment Schemas ============
/** 通过邮箱注册学生 */
export const EnrollStudentByEmailSchema = z.object({
classId: z.string().trim().min(1),
email: z.string().trim().min(1),
})
export type EnrollStudentByEmailInput = z.infer<typeof EnrollStudentByEmailSchema>

View File

@@ -100,24 +100,6 @@ export type ClassScheduleItem = {
location?: string | null
}
export type CreateClassScheduleItemInput = {
classId: string
weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime: string
endTime: string
course: string
location?: string | null
}
export type UpdateClassScheduleItemInput = {
classId?: string
weekday?: 1 | 2 | 3 | 4 | 5 | 6 | 7
startTime?: string
endTime?: string
course?: string
location?: string | null
}
export type StudentEnrolledClass = {
id: string
schoolName?: string | null