Files
NextEdu/src/modules/classes/data-access.ts
SpecialX 4833930834 feat(attendance,elective): 考勤与选修课模块审计重构 — P0 修复 + i18n + Error Boundary
审计报告:docs/architecture/audit/attendance-elective-audit-report.md

P0 修复:
- attendance: getAttendanceStats 统计失真(仅基于前 20 条记录)改为 SQL 聚合查询
- attendance: getClassStudentsForAttendance 跨模块直查 classEnrollments 改为调用 classes data-access
- attendance: update/delete Action 新增资源归属校验(assertRecordOwnership)
- elective: update/delete/openSelection/closeSelection/runLottery Action 新增资源归属校验(assertCourseOwnership)

i18n 接入:
- 新增 attendance/elective 命名空间(zh-CN + en)
- attendance-stats-cards 接入 useTranslations
- elective-course-list/form 接入 useTranslations

类型安全(P1):
- elective-course-form: 移除 as 断言,改用类型守卫 isSelectionMode
- elective-course-list: 移除 null as never 类型逃逸,改用泛型

Error Boundary:
- 新增 admin/teacher attendance error.tsx
- 新增 admin/student elective error.tsx

架构图同步:
- 004: 修正 attendance/elective/parent 章节的导出函数、文件清单、已知问题
- 005: 修正 actions 的 usedBy(标记无调用方的死代码)、新增 issues 字段、更新依赖矩阵
2026-06-22 16:17:00 +08:00

946 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only";
import { randomInt } from "node:crypto"
import { cache } from "react"
import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
classSubjectTeachers,
subjects,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types"
import type {
ClassSubject,
CreateTeacherClassInput,
TeacherOption,
TeacherClass,
UpdateTeacherClassInput,
} from "./types"
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()
const userId = String(session?.user?.id ?? "").trim()
if (!userId) return null
const [teacher] = await db
.select({ id: users.id })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, userId), eq(roles.name, "teacher")))
.limit(1)
return teacher?.id ?? null
}
// Strict subjectId-based mapping: no aliasing
export const isDuplicateInvitationCodeError = (err: unknown): boolean => {
if (!err) return false
const msg = err instanceof Error ? err.message : String(err)
const m = msg.toLowerCase()
return m.includes("duplicate") && (m.includes("invitation") || m.includes("invitation_code"))
}
const generateInvitationCode = (): string => {
const n = randomInt(0, 1_000_000)
return String(n).padStart(6, "0")
}
export const generateUniqueInvitationCode = async (): Promise<string> => {
for (let attempt = 0; attempt < 40; attempt += 1) {
const code = generateInvitationCode()
const [existing] = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.invitationCode, code))
.limit(1)
if (!existing) return code
}
throw new Error("Failed to generate invitation code")
}
export const getTeacherIdForMutations = async (): Promise<string> => {
const teacherId = await getSessionTeacherId()
if (!teacherId) throw new Error("Teacher not found")
return teacherId
}
export const getClassSubjects = async (): Promise<string[]> => {
const rows = await db.query.subjects.findMany({
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
const names = rows.map((r) => r.name.trim()).filter((n) => n.length > 0)
return Array.from(new Set(names))
}
const normalizeSortText = (v: string | null | undefined): string =>
typeof v === "string" ? v.trim().toLowerCase() : ""
const parseFirstInt = (v: string): number | null => {
const m = v.match(/\d+/)
return m ? Number(m[0]) : null
}
const compareGradeLabel = (a: string, b: string): number => {
const aNum = parseFirstInt(a)
const bNum = parseFirstInt(b)
if (typeof aNum === "number" && typeof bNum === "number" && aNum !== bNum) return aNum - bNum
return a.localeCompare(b)
}
export const compareClassLike = (
a: { schoolName?: string | null; grade: string; name: string; homeroom?: string | null; room?: string | null },
b: { schoolName?: string | null; grade: string; name: string; homeroom?: string | null; room?: string | null }
): number => {
const schoolCmp = normalizeSortText(a.schoolName).localeCompare(normalizeSortText(b.schoolName))
if (schoolCmp !== 0) return schoolCmp
const gradeCmp = compareGradeLabel(a.grade, b.grade)
if (gradeCmp !== 0) return gradeCmp
const nameCmp = normalizeSortText(a.name).localeCompare(normalizeSortText(b.name))
if (nameCmp !== 0) return nameCmp
const hrCmp = normalizeSortText(a.homeroom).localeCompare(normalizeSortText(b.homeroom))
if (hrCmp !== 0) return hrCmp
return normalizeSortText(a.room).localeCompare(normalizeSortText(b.room))
}
export const getAccessibleClassIdsForTeacher = async (teacherId: string): Promise<string[]> => {
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 })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.teacherId, teacherId), eq(classSubjectTeachers.classId, classId)))
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/name/email按姓名升序。
* 供跨模块调用使用(如考勤点名),避免直接查询 classEnrollments 表。
*/
export const getClassActiveStudentsWithInfo = async (
classId: string
): Promise<Array<{ id: string; name: string; email: string }>> => {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(classEnrollments)
.innerJoin(users, eq(users.id, classEnrollments.studentId))
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
.orderBy(asc(users.name))
return rows.map((r) => ({ id: r.id, name: r.name ?? "Unknown", email: r.email }))
}
/**
* 获取教师在一个班级所教的科目 ID 列表。
* 参数顺序为 (classId, teacherId),供跨模块调用使用。
*/
export const getTeacherSubjectIdsByClass = async (classId: string, teacherId: string): Promise<string[]> => {
return getTeacherSubjectIdsForClass(teacherId, classId)
}
/**
* 获取多个班级的所有教师 ID班主任 + 任课教师)。
* 供跨模块调用使用,避免直接查询 classes / classSubjectTeachers 表。
*/
export const getTeacherIdsByClassIds = async (classIds: string[]): Promise<string[]> => {
if (classIds.length === 0) return []
const [homeroomRows, subjectRows] = await Promise.all([
db
.select({ teacherId: classes.teacherId })
.from(classes)
.where(inArray(classes.id, classIds)),
db
.select({ teacherId: classSubjectTeachers.teacherId })
.from(classSubjectTeachers)
.where(inArray(classSubjectTeachers.classId, classIds)),
])
const set = new Set<string>()
for (const r of homeroomRows) {
if (r.teacherId) set.add(r.teacherId)
}
for (const r of subjectRows) {
if (r.teacherId) set.add(r.teacherId)
}
return Array.from(set)
}
/**
* 获取学生当前活跃班级的 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 与名称(一次 JOIN 查询)。
* 供跨模块调用使用,避免分别查询 classEnrollments 与 classes 表。
*/
export const getStudentActiveClass = async (
studentId: string,
): Promise<{ classId: string; className: string } | null> => {
const [row] = await db
.select({ classId: classes.id, className: classes.name })
.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 ?? 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 []
const rows = await (async () => {
try {
const allIds = await getAccessibleClassIdsForTeacher(teacherId)
if (allIds.length === 0) return []
return await db
.select({
id: classes.id,
schoolName: classes.schoolName,
name: classes.name,
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
})
.from(classes)
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(inArray(classes.id, allIds))
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} catch (error) {
console.error("getTeacherClasses query failed:", error)
return []
}
})()
const list = rows.map((r) => ({
id: r.id,
schoolName: r.schoolName,
name: r.name,
grade: r.grade,
homeroom: r.homeroom,
room: r.room,
invitationCode: r.invitationCode ?? null,
studentCount: Number(r.studentCount ?? 0),
}))
list.sort(compareClassLike)
// Fetch recent assignments for trends and schedule
const listWithTrends = await Promise.all(
list.map(async (c) => {
const [insights, schedule] = await Promise.all([
getClassHomeworkInsights({ classId: c.id, teacherId, limit: 7 }),
getClassSchedule({ classId: c.id, teacherId }),
])
const recentAssignments = insights
? insights.assignments.map((a) => ({
id: a.assignmentId,
title: a.title,
status: a.status,
subject: a.subject,
isActive: a.isActive,
isOverdue: a.isOverdue,
dueAt: a.dueAt ? new Date(a.dueAt) : null,
submittedCount: a.submittedCount,
targetCount: a.targetCount,
avgScore: a.scoreStats.avg,
medianScore: a.scoreStats.median,
}))
: []
return { ...c, recentAssignments, schedule }
})
)
return listWithTrends
})
export const getTeacherOptions = cache(async (): Promise<TeacherOption[]> => {
const rows = await db
.select({ id: users.id, name: users.name, email: users.email })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(roles.name, "teacher"))
.orderBy(asc(users.createdAt))
return rows.map((r) => ({
id: r.id,
name: r.name ?? "Unnamed",
email: r.email,
}))
})
export const getTeacherTeachingSubjects = cache(async (): Promise<ClassSubject[]> => {
const teacherId = await getSessionTeacherId()
if (!teacherId) return []
const rows = await db
.select({ subject: subjects.name })
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(eq(classSubjectTeachers.teacherId, teacherId))
.groupBy(subjects.name)
.orderBy(asc(subjects.name))
return rows
.map((r) => toClassSubject(r.subject))
.filter((s): s is ClassSubject => s !== null)
})
export async function createTeacherClass(data: CreateTeacherClassInput): Promise<string> {
const teacherId = await getTeacherIdForMutations()
const id = createId()
const schoolName = data.schoolName?.trim() || null
const schoolId = data.schoolId?.trim() || null
const name = data.name.trim()
const grade = data.grade.trim()
const gradeId = data.gradeId?.trim() || null
const homeroom = data.homeroom?.trim() || null
const room = data.room?.trim() || null
if (!name) throw new Error("Name is required")
if (!grade) throw new Error("Grade is required")
for (let attempt = 0; attempt < 20; attempt += 1) {
const invitationCode = await generateUniqueInvitationCode()
try {
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
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({
id,
schoolName,
schoolId,
name,
grade,
gradeId,
homeroom,
room,
invitationCode,
teacherId,
})
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
} catch (err) {
if (isDuplicateInvitationCodeError(err)) continue
throw err
}
}
throw new Error("Failed to create class")
}
export async function ensureClassInvitationCode(classId: string): Promise<string> {
const teacherId = await getTeacherIdForMutations()
const id = classId.trim()
if (!id) throw new Error("Missing class id")
const [owned] = await db
.select({ id: classes.id, invitationCode: classes.invitationCode })
.from(classes)
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
.limit(1)
if (!owned) throw new Error("Class not found")
const existing = owned.invitationCode
if (typeof existing === "string" && /^\d{6}$/.test(existing)) return existing
for (let attempt = 0; attempt < 40; attempt += 1) {
const code = await generateUniqueInvitationCode()
try {
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
return code
} catch (err) {
if (isDuplicateInvitationCodeError(err)) continue
throw err
}
}
throw new Error("Failed to generate invitation code")
}
export async function regenerateClassInvitationCode(classId: string): Promise<string> {
const teacherId = await getTeacherIdForMutations()
const id = classId.trim()
if (!id) throw new Error("Missing class id")
const [owned] = await db
.select({ id: classes.id })
.from(classes)
.where(and(eq(classes.id, id), eq(classes.teacherId, teacherId)))
.limit(1)
if (!owned) throw new Error("Class not found")
for (let attempt = 0; attempt < 40; attempt += 1) {
const code = await generateUniqueInvitationCode()
try {
await db.update(classes).set({ invitationCode: code }).where(eq(classes.id, id))
return code
} catch (err) {
if (isDuplicateInvitationCodeError(err)) continue
throw err
}
}
throw new Error("Failed to generate invitation code")
}
export async function enrollStudentByInvitationCode(studentId: string, invitationCode: string): Promise<string> {
const sid = studentId.trim()
const code = invitationCode.trim()
if (!sid) throw new Error("Missing student id")
if (!code) throw new Error("Invalid invitation code")
// v3优先走新邀请码体系validateInvitationCode 内部含 fallback 到旧 classes.invitationCode
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
const result = await validateInvitationCode(code)
if (!result.valid || !result.classId) {
throw new Error("Invalid invitation code")
}
await db
.insert(classEnrollments)
.values({ classId: result.classId, studentId: sid, status: "active" })
.onDuplicateKeyUpdate({ set: { status: "active" } })
// 消耗新表邀请码(旧表无计数,跳过)
if (result.codeId) {
await consumeInvitationCode(code)
}
return result.classId
}
export async function enrollTeacherByInvitationCode(
teacherId: string,
invitationCode: string,
subject: string | null
): Promise<string> {
const tid = teacherId.trim()
const code = invitationCode.trim()
if (!tid) throw new Error("Missing teacher id")
if (!code) throw new Error("Invalid invitation code")
const [teacher] = await db
.select({ id: users.id })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(users.id, tid), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
// v3优先走新邀请码体系validateInvitationCode 内部含 fallback 到旧 classes.invitationCode
const { validateInvitationCode, consumeInvitationCode } = await import("./data-access-invitations")
const result = await validateInvitationCode(code)
if (!result.valid || !result.classId) {
throw new Error("Invalid invitation code")
}
const [cls] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.id, result.classId))
.limit(1)
if (!cls) throw new Error("Invalid invitation code")
if (cls.teacherId === tid) return cls.id
const subjectValue = typeof subject === "string" ? subject.trim() : ""
const [existing] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (existing && !subjectValue) return cls.id
if (subjectValue) {
const [subRow] = await db.select({ id: subjects.id }).from(subjects).where(eq(subjects.name, subjectValue)).limit(1)
if (!subRow) throw new Error("Subject not found")
const sid = subRow.id
const [mapping] = await db
.select({ teacherId: classSubjectTeachers.teacherId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid)))
.limit(1)
if (mapping?.teacherId && mapping.teacherId !== tid) throw new Error("Subject already assigned")
if (mapping?.teacherId === tid) return cls.id
if (!mapping) {
await db
.insert(classSubjectTeachers)
.values({ classId: cls.id, subjectId: sid, teacherId: null })
.onDuplicateKeyUpdate({ set: { teacherId: sql`${classSubjectTeachers.teacherId}` } })
}
const [existingSubject] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (existingSubject) return cls.id
await db
.update(classSubjectTeachers)
.set({ teacherId: tid })
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), isNull(classSubjectTeachers.teacherId)))
const [assigned] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(and(eq(classSubjectTeachers.classId, cls.id), eq(classSubjectTeachers.subjectId, sid), eq(classSubjectTeachers.teacherId, tid)))
.limit(1)
if (!assigned) throw new Error("Subject already assigned")
} else {
const subjectRows = await db
.select({ id: classSubjectTeachers.subjectId, name: subjects.name })
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.where(and(eq(classSubjectTeachers.classId, cls.id), isNull(classSubjectTeachers.teacherId)))
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
if (!preferred) throw new Error("Class already has assigned teachers")
const subjectRow = subjectRows.find((r) => r.name === preferred)
if (!subjectRow) throw new Error("Subject not found")
const sid = subjectRow.id
await db
.update(classSubjectTeachers)
.set({ teacherId: tid })
.where(
and(
eq(classSubjectTeachers.classId, cls.id),
eq(classSubjectTeachers.subjectId, sid),
isNull(classSubjectTeachers.teacherId)
)
)
const [assigned] = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(
and(
eq(classSubjectTeachers.classId, cls.id),
eq(classSubjectTeachers.subjectId, sid),
eq(classSubjectTeachers.teacherId, tid)
)
)
.limit(1)
if (!assigned) throw new Error("Class already has assigned teachers")
}
// 消耗新表邀请码(旧表无计数,跳过)
if (result.codeId) {
await consumeInvitationCode(code)
}
return cls.id
}
export async function updateTeacherClass(classId: string, data: UpdateTeacherClassInput): Promise<void> {
const teacherId = await getTeacherIdForMutations()
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")
const update: Partial<typeof classes.$inferSelect> = {}
if (data.schoolName !== undefined) update.schoolName = data.schoolName?.trim() || null
if (data.schoolId !== undefined) update.schoolId = data.schoolId?.trim() || null
if (typeof data.name === "string") update.name = data.name.trim()
if (typeof data.grade === "string") update.grade = data.grade.trim()
if (data.gradeId !== undefined) update.gradeId = data.gradeId?.trim() || null
if (data.homeroom !== undefined) update.homeroom = data.homeroom?.trim() || null
if (data.room !== undefined) update.room = data.room?.trim() || null
if (Object.keys(update).length === 0) return
await db
.update(classes)
.set(update)
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
}
export async function setClassSubjectTeachers(params: {
classId: string
assignments: Array<{ subject: ClassSubject; teacherId: string | null }>
}): Promise<void> {
const classId = params.classId.trim()
if (!classId) throw new Error("Missing class id")
const [existing] = await db.select({ id: classes.id }).from(classes).where(eq(classes.id, classId)).limit(1)
if (!existing) throw new Error("Class not found")
const teacherIds = params.assignments
.map((a) => a.teacherId)
.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
if (teacherIds.length > 0) {
const rows = await db
.select({ id: users.id })
.from(users)
.innerJoin(usersToRoles, eq(usersToRoles.userId, users.id))
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(roles.name, "teacher"), inArray(users.id, teacherIds)))
if (rows.length !== new Set(teacherIds).size) throw new Error("Teacher not found")
}
const teacherBySubject = new Map<ClassSubject, string | null>()
for (const a of params.assignments) {
if (!DEFAULT_CLASS_SUBJECTS.includes(a.subject)) continue
teacherBySubject.set(a.subject, typeof a.teacherId === "string" && a.teacherId.trim().length > 0 ? a.teacherId.trim() : null)
}
// Map subject names to ids
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.name, DEFAULT_CLASS_SUBJECTS))
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.flatMap((name) => {
const subjectId = idByName.get(name)
if (!subjectId) return []
return [{ classId, subjectId, teacherId: teacherBySubject.get(name) ?? null }]
})
await db
.insert(classSubjectTeachers)
.values(values)
.onDuplicateKeyUpdate({ set: { teacherId: sql`VALUES(${classSubjectTeachers.teacherId})` } })
}
export async function deleteTeacherClass(classId: string): Promise<void> {
const teacherId = await getTeacherIdForMutations()
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")
await db
.delete(classes)
.where(and(eq(classes.id, classId), eq(classes.teacherId, teacherId)))
}
export async function enrollStudentByEmail(classId: string, email: string): Promise<void> {
const teacherId = await getTeacherIdForMutations()
const normalized = email.trim().toLowerCase()
if (!normalized) throw new Error("Student email is required")
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")
const [student] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, normalized))
.limit(1)
if (!student) throw new Error("Student not found")
const [studentRole] = await db
.select({ id: usersToRoles.userId })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(and(eq(usersToRoles.userId, student.id), eq(roles.name, "student")))
.limit(1)
if (!studentRole) throw new Error("User is not a student")
await db
.insert(classEnrollments)
.values({ classId, studentId: student.id, status: "active" })
.onDuplicateKeyUpdate({ set: { status: "active" } })
}
export async function setStudentEnrollmentStatus(classId: string, studentId: string, status: "active" | "inactive"): Promise<void> {
const teacherId = await getTeacherIdForMutations()
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")
const [existing] = await db
.select({ classId: classEnrollments.classId })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
.limit(1)
if (!existing) throw new Error("Enrollment not found")
await db
.update(classEnrollments)
.set({ status })
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.studentId, studentId)))
}
// Re-export from split files for backward compatibility
export * from "./data-access-stats"
export * from "./data-access-schedule"
export * from "./data-access-students"
export * from "./data-access-admin"