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:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取班级所有活跃学生 ID(status = '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)
|
||||
|
||||
157
src/modules/classes/schema.ts
Normal file
157
src/modules/classes/schema.ts
Normal 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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user