import "server-only"; 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 => { 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 = Math.floor(Math.random() * 1_000_000) return String(n).padStart(6, "0") } export const generateUniqueInvitationCode = async (): Promise => { 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 => { const teacherId = await getSessionTeacherId() if (!teacherId) throw new Error("Teacher not found") return teacherId } export const getClassSubjects = async (): Promise => { 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 => { 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 { 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> => { 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() 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 => { 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 => { 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 => { 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 => { 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 => { 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> => { 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 => { return getTeacherSubjectIdsForClass(teacherId, classId) } /** * 获取多个班级的所有教师 ID(班主任 + 任课教师)。 * 供跨模块调用使用,避免直接查询 classes / classSubjectTeachers 表。 */ export const getTeacherIdsByClassIds = async (classIds: string[]): Promise => { 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() 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 => { 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 => { 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 => { 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 => { 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 => { 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 => { 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)。 * 供跨模块调用使用,避免直接查询 classes 表。 */ export const getClassNamesByIds = async (classIds: string[]): Promise> => { const result = new Map() 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> => { 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 })) } /** * 获取多个年级下的所有班级 ID(供 grades 模块 grade_managed scope 过滤使用)。 * 供跨模块调用使用,避免直接查询 classes 表。 */ export const getClassIdsByGradeIds = async (gradeIds: string[]): Promise => { const uniqueIds = Array.from(new Set(gradeIds.filter((v): v is string => typeof v === "string" && v.length > 0))) if (uniqueIds.length === 0) return [] const rows = await db .select({ id: classes.id }) .from(classes) .where(inArray(classes.gradeId, uniqueIds)) return rows.map((r) => r.id) } /** * 构建一个 Drizzle 子查询 SQL,用于过滤 classId IN (SELECT id FROM classes WHERE grade_id IN (...))。 * 供 grades 模块 grade_managed scope 同步构建 SQL 过滤条件使用,避免直接查询 classes 表。 */ export const getClassIdsByGradeIdsSubquery = (gradeIds: string[]) => { return db.select({ id: classes.id }).from(classes).where(inArray(classes.gradeId, gradeIds)) } export const getTeacherClasses = cache(async (params?: { teacherId?: string }): Promise => { 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`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 => { 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 => { 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 { 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() 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 { 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 { 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 { 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 { 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 { 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 = {} 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 { 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() 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() 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 { 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 { 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 { 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"