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" 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) => { 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 = () => { const n = randomInt(0, 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) => (typeof v === "string" ? v.trim().toLowerCase() : "") const parseFirstInt = (v: string) => { const m = v.match(/\d+/) return m ? Number(m[0]) : null } const compareGradeLabel = (a: string, b: string) => { 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 } ) => { 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 = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId)) const assignedIds = await db .select({ id: classSubjectTeachers.classId }) .from(classSubjectTeachers) .where(eq(classSubjectTeachers.teacherId, teacherId)) return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)])) } 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)))) } 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 { 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) => r.subject as ClassSubject) .filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s)) }) 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(subjectRows.map((r) => [r.name as ClassSubject, 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 .filter((name) => idByName.has(name)) .map((name) => ({ classId: id, subjectId: idByName.get(name)!, 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") return id } 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 (!/^\d{6}$/.test(code)) throw new Error("Invalid invitation code") const [cls] = await db .select({ id: classes.id }) .from(classes) .where(eq(classes.invitationCode, code)) .limit(1) if (!cls) throw new Error("Invalid invitation code") await db .insert(classEnrollments) .values({ classId: cls.id, studentId: sid, status: "active" }) .onDuplicateKeyUpdate({ set: { status: "active" } }) return cls.id } 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 (!/^\d{6}$/.test(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") const [cls] = await db .select({ id: classes.id, teacherId: classes.teacherId }) .from(classes) .where(eq(classes.invitationCode, code)) .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 sid = subjectRows.find((r) => r.name === preferred)!.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") } 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(subjectRows.map((r) => [r.name as ClassSubject, r.id])) const values = DEFAULT_CLASS_SUBJECTS .filter((name) => idByName.has(name)) .map((name) => ({ classId, subjectId: idByName.get(name)!, 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"