- Update classes data-access (invitations, main) for invitation management - Update course-plans actions, data-access, and types - Update diagnostic data-access for report queries - Update questions data-access for question bank queries - Update settings actions, ai-provider-settings-card, data-access, and types - Update student course-filters, student-courses-view, student-schedule-filters, student-schedule-view - Update layout app-sidebar, site-header, and navigation config
967 lines
33 KiB
TypeScript
967 lines
33 KiB
TypeScript
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<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 = Math.floor(Math.random() * 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)))
|
||
}
|
||
|
||
/**
|
||
* 获取班级所有活跃学生 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/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 }))
|
||
}
|
||
|
||
/**
|
||
* 获取多个年级下的所有班级 ID(供 grades 模块 grade_managed scope 过滤使用)。
|
||
* 供跨模块调用使用,避免直接查询 classes 表。
|
||
*/
|
||
export const getClassIdsByGradeIds = async (gradeIds: string[]): Promise<string[]> => {
|
||
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<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"
|