Files
NextEdu/src/modules/classes/data-access.ts

657 lines
22 KiB
TypeScript

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<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) => {
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<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) => (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<string[]> => {
const ownedIds = await db.select({ id: classes.id }).from(classes).where(eq(classes.teacherId, teacherId))
const assignedIds = await db
.select({ id: classSubjectTeachers.classId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, teacherId))
return Array.from(new Set([...ownedIds.map((x) => x.id), ...assignedIds.map((x) => x.id)]))
}
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))))
}
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 {
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) => r.subject as ClassSubject)
.filter((s) => DEFAULT_CLASS_SUBJECTS.includes(s))
})
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(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<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 (!/^\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<string> {
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<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(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<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"