refactor: P0-1/2/4 解耦修复 - 拆分过耦合文件 + dashboard 解耦

This commit is contained in:
SpecialX
2026-06-18 01:45:55 +08:00
parent 220061d62e
commit 62be0b9404
18 changed files with 2534 additions and 2130 deletions

View File

@@ -0,0 +1,441 @@
import "server-only";
import { cache } from "react"
import { and, asc, eq, inArray, or, sql } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
classSubjectTeachers,
grades,
schools,
subjects,
roles,
users,
usersToRoles,
} from "@/shared/db/schema"
import { DEFAULT_CLASS_SUBJECTS } from "./types"
import type {
AdminClassListItem,
ClassSubject,
ClassSubjectTeacherAssignment,
CreateTeacherClassInput,
TeacherOption,
UpdateTeacherClassInput,
} from "./types"
import {
compareClassLike,
generateUniqueInvitationCode,
isDuplicateInvitationCodeError,
} from "./data-access"
export const getAdminClasses = cache(async (): Promise<AdminClassListItem[]> => {
const [rows, subjectRows] = await Promise.all([
(async () => {
try {
return await db
.select({
id: classes.id,
schoolName: classes.schoolName,
schoolId: classes.schoolId,
name: classes.name,
grade: classes.grade,
gradeId: classes.gradeId,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
createdAt: classes.createdAt,
updatedAt: classes.updatedAt,
})
.from(classes)
.innerJoin(users, eq(users.id, classes.teacherId))
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.groupBy(
classes.id,
classes.schoolName,
classes.schoolId,
classes.name,
classes.grade,
classes.gradeId,
classes.homeroom,
classes.room,
classes.invitationCode,
users.id,
users.name,
users.email,
classes.createdAt,
classes.updatedAt
)
.orderBy(
asc(classes.schoolName),
asc(classes.grade),
asc(classes.name),
asc(classes.homeroom),
asc(classes.room)
)
} catch {
return await db
.select({
id: classes.id,
schoolName: sql<string | null>`NULL`.as("schoolName"),
schoolId: sql<string | null>`NULL`.as("schoolId"),
name: classes.name,
grade: classes.grade,
gradeId: sql<string | null>`NULL`.as("gradeId"),
homeroom: classes.homeroom,
room: classes.room,
invitationCode: sql<string | null>`NULL`.as("invitationCode"),
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
createdAt: classes.createdAt,
updatedAt: classes.updatedAt,
})
.from(classes)
.innerJoin(users, eq(users.id, classes.teacherId))
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.groupBy(
classes.id,
classes.name,
classes.grade,
classes.homeroom,
classes.room,
users.id,
users.name,
users.email,
classes.createdAt,
classes.updatedAt
)
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
}
})(),
db
.select({
classId: classSubjectTeachers.classId,
subject: subjects.name,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
for (const r of subjectRows) {
const subject = r.subject as ClassSubject
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
const teacher =
typeof r.teacherId === "string" && r.teacherId.length > 0
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
: null
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
bySubject.set(subject, teacher)
subjectsByClassId.set(r.classId, bySubject)
}
const list = rows.map((r) => {
const bySubject = subjectsByClassId.get(r.id)
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
subject,
teacher: bySubject?.get(subject) ?? null,
}))
return {
id: r.id,
schoolName: r.schoolName,
schoolId: r.schoolId,
name: r.name,
grade: r.grade,
gradeId: r.gradeId,
homeroom: r.homeroom,
room: r.room,
invitationCode: r.invitationCode ?? null,
teacher: {
id: r.teacherId,
name: r.teacherName ?? "Unnamed",
email: r.teacherEmail,
},
subjectTeachers,
studentCount: Number(r.studentCount ?? 0),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}
})
list.sort(compareClassLike)
return list
})
export const getGradeManagedClasses = cache(async (userId: string): Promise<AdminClassListItem[]> => {
const managedGradeIds = await db
.select({ id: grades.id })
.from(grades)
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
if (managedGradeIds.length === 0) return []
const gradeIds = managedGradeIds.map((g) => g.id)
const [rows, subjectRows] = await Promise.all([
(async () => {
try {
return await db
.select({
id: classes.id,
schoolName: classes.schoolName,
schoolId: classes.schoolId,
name: classes.name,
grade: classes.grade,
gradeId: classes.gradeId,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
studentCount: sql<number>`COALESCE(SUM(CASE WHEN ${classEnrollments.status} = 'active' THEN 1 ELSE 0 END), 0)`,
createdAt: classes.createdAt,
updatedAt: classes.updatedAt,
})
.from(classes)
.innerJoin(users, eq(users.id, classes.teacherId))
.leftJoin(classEnrollments, eq(classEnrollments.classId, classes.id))
.where(inArray(classes.gradeId, gradeIds))
.groupBy(
classes.id,
classes.schoolName,
classes.schoolId,
classes.name,
classes.grade,
classes.gradeId,
classes.homeroom,
classes.room,
classes.invitationCode,
users.id,
users.name,
users.email,
classes.createdAt,
classes.updatedAt
)
.orderBy(
asc(classes.schoolName),
asc(classes.grade),
asc(classes.name),
asc(classes.homeroom),
asc(classes.room)
)
} catch {
return []
}
})(),
db
.select({
classId: classSubjectTeachers.classId,
subject: subjects.name,
teacherId: users.id,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classSubjectTeachers)
.innerJoin(subjects, eq(subjects.id, classSubjectTeachers.subjectId))
.innerJoin(classes, eq(classes.id, classSubjectTeachers.classId))
.leftJoin(users, eq(users.id, classSubjectTeachers.teacherId))
.where(inArray(classes.gradeId, gradeIds))
.orderBy(asc(classSubjectTeachers.classId), asc(subjects.name)),
])
const subjectsByClassId = new Map<string, Map<ClassSubject, TeacherOption | null>>()
for (const r of subjectRows) {
const subject = r.subject as ClassSubject
if (!DEFAULT_CLASS_SUBJECTS.includes(subject)) continue
const teacher =
typeof r.teacherId === "string" && r.teacherId.length > 0
? { id: r.teacherId, name: r.teacherName ?? "Unnamed", email: r.teacherEmail ?? "" }
: null
const bySubject = subjectsByClassId.get(r.classId) ?? new Map<ClassSubject, TeacherOption | null>()
bySubject.set(subject, teacher)
subjectsByClassId.set(r.classId, bySubject)
}
const list = rows.map((r) => {
const bySubject = subjectsByClassId.get(r.id)
const subjectTeachers: ClassSubjectTeacherAssignment[] = DEFAULT_CLASS_SUBJECTS.map((subject) => ({
subject,
teacher: bySubject?.get(subject) ?? null,
}))
return {
id: r.id,
schoolName: r.schoolName,
schoolId: r.schoolId,
name: r.name,
grade: r.grade,
gradeId: r.gradeId,
homeroom: r.homeroom,
room: r.room,
invitationCode: r.invitationCode ?? null,
teacher: {
id: r.teacherId,
name: r.teacherName ?? "Unnamed",
email: r.teacherEmail,
},
subjectTeachers,
studentCount: Number(r.studentCount ?? 0),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}
})
list.sort(compareClassLike)
return list
})
export const getManagedGrades = cache(async (userId: string) => {
return await db
.select({
id: grades.id,
name: grades.name,
schoolId: grades.schoolId,
schoolName: schools.name,
})
.from(grades)
.innerJoin(schools, eq(schools.id, grades.schoolId))
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
.orderBy(asc(schools.name), asc(grades.name))
})
export async function createAdminClass(data: CreateTeacherClassInput & { teacherId: string }): Promise<string> {
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
const teacherId = data.teacherId.trim()
if (!name) throw new Error("Name is required")
if (!grade) throw new Error("Grade is required")
if (!teacherId) throw new Error("Teacher is required")
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, teacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
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 updateAdminClass(
classId: string,
data: UpdateTeacherClassInput & { teacherId?: string }
): Promise<void> {
const id = classId.trim()
if (!id) throw new Error("Missing class id")
const [existing] = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.id, id))
.limit(1)
if (!existing) 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 (typeof data.teacherId === "string") {
const nextTeacherId = data.teacherId.trim()
if (!nextTeacherId) throw new Error("Teacher is required")
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, nextTeacherId), eq(roles.name, "teacher")))
.limit(1)
if (!teacher) throw new Error("Teacher not found")
update.teacherId = nextTeacherId
}
if (Object.keys(update).length === 0) return
await db.update(classes).set(update).where(eq(classes.id, id))
}
export async function deleteAdminClass(classId: string): Promise<void> {
const id = classId.trim()
if (!id) throw new Error("Missing class id")
const [existing] = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.id, id))
.limit(1)
if (!existing) throw new Error("Class not found")
await db.delete(classes).where(eq(classes.id, id))
}

View File

@@ -0,0 +1,230 @@
import "server-only";
import { cache } from "react"
import { and, asc, eq, inArray, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
classSchedule,
} from "@/shared/db/schema"
import {
insertClassScheduleItem,
updateClassScheduleItemById,
deleteClassScheduleItemById,
} from "@/modules/scheduling/data-access"
import type {
ClassScheduleItem,
CreateClassScheduleItemInput,
StudentScheduleItem,
UpdateClassScheduleItemInput,
} from "./types"
import {
getAccessibleClassIdsForTeacher,
getSessionTeacherId,
getTeacherIdForMutations,
} from "./data-access"
export const getStudentSchedule = cache(async (studentId: string): Promise<StudentScheduleItem[]> => {
const id = studentId.trim()
if (!id) return []
const rows = await db
.select({
id: classSchedule.id,
classId: classSchedule.classId,
className: classes.name,
weekday: classSchedule.weekday,
startTime: classSchedule.startTime,
endTime: classSchedule.endTime,
course: classSchedule.course,
location: classSchedule.location,
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.innerJoin(classSchedule, eq(classSchedule.classId, classes.id))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
.orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))
return rows.map((r) => ({
id: r.id,
classId: r.classId,
className: r.className,
weekday: r.weekday as StudentScheduleItem["weekday"],
startTime: r.startTime,
endTime: r.endTime,
course: r.course,
location: r.location,
}))
})
export const getClassSchedule = cache(
async (params?: { classId?: string; teacherId?: string }): Promise<ClassScheduleItem[]> => {
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0) return []
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) conditions.push(eq(classSchedule.classId, classId))
const rows = await db
.select({
id: classSchedule.id,
classId: classSchedule.classId,
weekday: classSchedule.weekday,
startTime: classSchedule.startTime,
endTime: classSchedule.endTime,
course: classSchedule.course,
location: classSchedule.location,
})
.from(classSchedule)
.innerJoin(classes, eq(classes.id, classSchedule.classId))
.where(and(...conditions))
.orderBy(asc(classSchedule.weekday), asc(classSchedule.startTime))
return rows.map((r) => ({
id: r.id,
classId: r.classId,
weekday: r.weekday as ClassScheduleItem["weekday"],
startTime: r.startTime,
endTime: r.endTime,
course: r.course,
location: r.location,
}))
}
)
const isTimeHHMM = (v: string) => /^\d{2}:\d{2}$/.test(v)
export async function createClassScheduleItem(data: CreateClassScheduleItemInput): Promise<string> {
const teacherId = await getTeacherIdForMutations()
const classId = data.classId.trim()
const course = data.course.trim()
const startTime = data.startTime.trim()
const endTime = data.endTime.trim()
const location = data.location?.trim() || null
const weekday = data.weekday
if (!classId) throw new Error("Class is required")
if (!course) throw new Error("Course is required")
if (!isTimeHHMM(startTime) || !isTimeHHMM(endTime)) throw new Error("Invalid time format")
if (startTime >= endTime) throw new Error("Start time must be earlier than end time")
if (weekday < 1 || weekday > 7) throw new Error("Invalid weekday")
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")
// Delegate DB write to scheduling module (unified write entry point)
return insertClassScheduleItem({
classId,
weekday,
startTime,
endTime,
course,
location,
})
}
export async function updateClassScheduleItem(scheduleId: string, data: UpdateClassScheduleItemInput): Promise<void> {
const teacherId = await getTeacherIdForMutations()
const id = scheduleId.trim()
if (!id) throw new Error("Missing schedule id")
const [existing] = await db
.select({
id: classSchedule.id,
classId: classSchedule.classId,
startTime: classSchedule.startTime,
endTime: classSchedule.endTime,
})
.from(classSchedule)
.innerJoin(classes, eq(classes.id, classSchedule.classId))
.where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId)))
.limit(1)
if (!existing) throw new Error("Schedule item not found")
const update: Partial<typeof classSchedule.$inferSelect> = {}
if (typeof data.classId === "string") {
const nextClassId = data.classId.trim()
if (!nextClassId) throw new Error("Class is required")
const [ownedNext] = await db
.select({ id: classes.id })
.from(classes)
.where(and(eq(classes.id, nextClassId), eq(classes.teacherId, teacherId)))
.limit(1)
if (!ownedNext) throw new Error("Class not found")
update.classId = nextClassId
}
if (typeof data.weekday === "number") {
if (data.weekday < 1 || data.weekday > 7) throw new Error("Invalid weekday")
update.weekday = data.weekday
}
if (typeof data.course === "string") {
const course = data.course.trim()
if (!course) throw new Error("Course is required")
update.course = course
}
const nextStart = typeof data.startTime === "string" ? data.startTime.trim() : undefined
const nextEnd = typeof data.endTime === "string" ? data.endTime.trim() : undefined
if (nextStart !== undefined) {
if (!isTimeHHMM(nextStart)) throw new Error("Invalid time format")
update.startTime = nextStart
}
if (nextEnd !== undefined) {
if (!isTimeHHMM(nextEnd)) throw new Error("Invalid time format")
update.endTime = nextEnd
}
if (update.startTime !== undefined || update.endTime !== undefined) {
const mergedStart = update.startTime ?? existing.startTime
const mergedEnd = update.endTime ?? existing.endTime
if (typeof mergedStart === "string" && typeof mergedEnd === "string" && mergedStart >= mergedEnd) {
throw new Error("Start time must be earlier than end time")
}
}
if (data.location !== undefined) {
update.location = data.location?.trim() || null
}
if (Object.keys(update).length === 0) return
// Delegate DB write to scheduling module (unified write entry point)
await updateClassScheduleItemById(id, update)
}
export async function deleteClassScheduleItem(scheduleId: string): Promise<void> {
const teacherId = await getTeacherIdForMutations()
const id = scheduleId.trim()
if (!id) throw new Error("Missing schedule id")
const [owned] = await db
.select({ id: classSchedule.id })
.from(classSchedule)
.innerJoin(classes, eq(classes.id, classSchedule.classId))
.where(and(eq(classSchedule.id, id), eq(classes.teacherId, teacherId)))
.limit(1)
if (!owned) throw new Error("Schedule item not found")
// Delegate DB write to scheduling module (unified write entry point)
await deleteClassScheduleItemById(id)
}

View File

@@ -0,0 +1,604 @@
import "server-only";
import { cache } from "react"
import { and, asc, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
grades,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
schools,
subjects,
exams,
} from "@/shared/db/schema"
import type {
ClassHomeworkInsights,
ClassHomeworkAssignmentStats,
GradeHomeworkClassSummary,
GradeHomeworkInsights,
ScoreStats,
} from "./types"
import {
getAccessibleClassIdsForTeacher,
getSessionTeacherId,
getTeacherSubjectIdsForClass,
} from "./data-access"
const median = (sorted: number[]): number | null => {
if (sorted.length === 0) return null
const mid = Math.floor(sorted.length / 2)
if (sorted.length % 2 === 1) return sorted[mid] ?? null
const a = sorted[mid - 1]
const b = sorted[mid]
if (typeof a !== "number" || typeof b !== "number") return null
return (a + b) / 2
}
const toScoreStats = (scores: number[]): ScoreStats => {
if (scores.length === 0) return { count: 0, avg: null, median: null, min: null, max: null }
const sorted = [...scores].sort((a, b) => a - b)
const sum = sorted.reduce((acc, v) => acc + v, 0)
return {
count: sorted.length,
avg: sum / sorted.length,
median: median(sorted),
min: sorted[0] ?? null,
max: sorted[sorted.length - 1] ?? null,
}
}
export const getClassHomeworkInsights = cache(
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
const teacherId = params.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return null
const classId = params.classId.trim()
if (!classId) return null
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return null
const [classRow] = await db
.select({
id: classes.id,
name: classes.name,
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
invitationCode: classes.invitationCode,
teacherId: classes.teacherId,
})
.from(classes)
.where(and(eq(classes.id, classId), inArray(classes.id, accessibleIds)))
.limit(1)
if (!classRow) return null
const isHomeroomTeacher = classRow.teacherId === teacherId
const subjectIdFilter = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
const enrollments = await db
.select({
studentId: classEnrollments.studentId,
status: classEnrollments.status,
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.where(and(inArray(classes.id, accessibleIds), eq(classEnrollments.classId, classId)))
const activeStudentIds = enrollments.filter((e) => e.status === "active").map((e) => e.studentId)
const inactiveStudentIds = enrollments.filter((e) => e.status !== "active").map((e) => e.studentId)
const studentIds = enrollments.map((e) => e.studentId)
if (!isHomeroomTeacher && subjectIdFilter.length === 0) {
return {
class: {
id: classRow.id,
name: classRow.name,
grade: classRow.grade,
homeroom: classRow.homeroom,
room: classRow.room,
invitationCode: classRow.invitationCode ?? null,
},
studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}
}
if (studentIds.length === 0) {
return {
class: {
id: classRow.id,
name: classRow.name,
grade: classRow.grade,
homeroom: classRow.homeroom,
room: classRow.room,
invitationCode: classRow.invitationCode ?? null,
},
studentCounts: { total: 0, active: 0, inactive: 0 },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}
}
const assignmentIdRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
if (assignmentIds.length === 0) {
return {
class: {
id: classRow.id,
name: classRow.name,
grade: classRow.grade,
homeroom: classRow.homeroom,
room: classRow.room,
invitationCode: classRow.invitationCode ?? null,
},
studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
if (subjectIdFilter.length > 0) {
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
}
const assignments = await db
.select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
status: homeworkAssignments.status,
createdAt: homeworkAssignments.createdAt,
dueAt: homeworkAssignments.dueAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(...assignmentConditions))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) {
return {
class: {
id: classRow.id,
name: classRow.name,
grade: classRow.grade,
homeroom: classRow.homeroom,
room: classRow.room,
invitationCode: classRow.invitationCode ?? null,
},
studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}
}
const maxScoreRows = await db
.select({
assignmentId: homeworkAssignmentQuestions.assignmentId,
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const maxScoreByAssignmentId = new Map<string, number>()
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
const targetCountRows = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(
and(
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
inArray(homeworkAssignmentTargets.studentId, studentIds)
)
)
.groupBy(homeworkAssignmentTargets.assignmentId)
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
inArray(homeworkSubmissions.studentId, studentIds)
),
orderBy: [desc(homeworkSubmissions.createdAt)],
})
const latestByKey = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const key = `${s.assignmentId}:${s.studentId}`
if (!latestByKey.has(key)) latestByKey.set(key, s)
}
const allScored: number[] = []
const nowMs = Date.now()
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
let submittedCount = 0
let gradedCount = 0
const scores: number[] = []
const dueMs = a.dueAt ? a.dueAt.getTime() : null
for (const studentId of studentIds) {
const s = latestByKey.get(`${a.id}:${studentId}`)
if (!s) continue
const status = (s.status ?? "started") as string
if (status === "submitted" || status === "graded") submittedCount += 1
if (status === "graded" || typeof s.score === "number") gradedCount += 1
if (typeof s.score === "number") scores.push(s.score)
}
allScored.push(...scores)
return {
assignmentId: a.id,
title: a.title,
status: (a.status as string) ?? "draft",
subject: a.subjectName,
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
targetCount,
submittedCount,
gradedCount,
scoreStats: toScoreStats(scores),
}
})
const overallScores = toScoreStats(allScored)
const latest = stats[0] ?? null
return {
class: {
id: classRow.id,
name: classRow.name,
grade: classRow.grade,
homeroom: classRow.homeroom,
room: classRow.room,
invitationCode: classRow.invitationCode ?? null,
},
studentCounts: { total: studentIds.length, active: activeStudentIds.length, inactive: inactiveStudentIds.length },
assignments: stats,
latest,
overallScores,
}
}
)
const avg = (values: number[]): number | null => {
if (values.length === 0) return null
const sum = values.reduce((acc, v) => acc + v, 0)
return sum / values.length
}
export const getGradeHomeworkInsights = cache(
async (params: { gradeId: string; limit?: number }): Promise<GradeHomeworkInsights | null> => {
const gradeId = params.gradeId.trim()
if (!gradeId) return null
const [gradeRow] = await db
.select({
id: grades.id,
name: grades.name,
schoolId: schools.id,
schoolName: schools.name,
})
.from(grades)
.innerJoin(schools, eq(schools.id, grades.schoolId))
.where(eq(grades.id, gradeId))
.limit(1)
if (!gradeRow) return null
const classRows = await db
.select({
id: classes.id,
name: classes.name,
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
})
.from(classes)
.where(eq(classes.gradeId, gradeId))
.orderBy(asc(classes.name), asc(classes.homeroom), asc(classes.room))
const classIds = classRows.map((r) => r.id)
if (classIds.length === 0) {
return {
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
classCount: 0,
studentCounts: { total: 0, active: 0, inactive: 0 },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
classes: [],
}
}
const enrollmentRows = await db
.select({
classId: classEnrollments.classId,
studentId: classEnrollments.studentId,
status: classEnrollments.status,
})
.from(classEnrollments)
.where(inArray(classEnrollments.classId, classIds))
const studentActiveById = new Map<string, boolean>()
const studentsByClassId = new Map<string, { all: Set<string>; active: Set<string> }>()
for (const e of enrollmentRows) {
const prev = studentActiveById.get(e.studentId) ?? false
const next = prev || e.status === "active"
studentActiveById.set(e.studentId, next)
const bucket = studentsByClassId.get(e.classId) ?? { all: new Set<string>(), active: new Set<string>() }
bucket.all.add(e.studentId)
if (e.status === "active") bucket.active.add(e.studentId)
studentsByClassId.set(e.classId, bucket)
}
const studentIds = Array.from(studentActiveById.keys())
const activeCount = Array.from(studentActiveById.values()).filter(Boolean).length
const inactiveCount = studentIds.length - activeCount
if (studentIds.length === 0) {
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => ({
class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room },
studentCounts: { total: 0, active: 0, inactive: 0 },
latestAvg: null,
prevAvg: null,
deltaAvg: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}))
return {
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
classCount: classRows.length,
studentCounts: { total: 0, active: 0, inactive: 0 },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
classes: summaries,
}
}
const assignmentIdRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
if (assignmentIds.length === 0) {
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() }
return {
class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room },
studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size },
latestAvg: null,
prevAvg: null,
deltaAvg: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}
})
return {
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
classCount: classRows.length,
studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
classes: summaries,
}
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignments = await db.query.homeworkAssignments.findMany({
where: inArray(homeworkAssignments.id, assignmentIds),
orderBy: [desc(homeworkAssignments.createdAt)],
limit,
})
const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) {
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() }
return {
class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room },
studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size },
latestAvg: null,
prevAvg: null,
deltaAvg: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
}
})
return {
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
classCount: classRows.length,
studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount },
assignments: [],
latest: null,
overallScores: { count: 0, avg: null, median: null, min: null, max: null },
classes: summaries,
}
}
const maxScoreRows = await db
.select({
assignmentId: homeworkAssignmentQuestions.assignmentId,
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const maxScoreByAssignmentId = new Map<string, number>()
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
const targetCountRows = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(
and(
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
inArray(homeworkAssignmentTargets.studentId, studentIds)
)
)
.groupBy(homeworkAssignmentTargets.assignmentId)
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
inArray(homeworkSubmissions.studentId, studentIds)
),
orderBy: [desc(homeworkSubmissions.createdAt)],
})
const latestByKey = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const key = `${s.assignmentId}:${s.studentId}`
if (!latestByKey.has(key)) latestByKey.set(key, s)
}
const allScored: number[] = []
const nowMs = Date.now()
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
let submittedCount = 0
let gradedCount = 0
const scores: number[] = []
const dueMs = a.dueAt ? a.dueAt.getTime() : null
for (const studentId of studentIds) {
const s = latestByKey.get(`${a.id}:${studentId}`)
if (!s) continue
const status = (s.status ?? "started") as string
if (status === "submitted" || status === "graded") submittedCount += 1
if (status === "graded" || typeof s.score === "number") gradedCount += 1
if (typeof s.score === "number") scores.push(s.score)
}
allScored.push(...scores)
return {
assignmentId: a.id,
title: a.title,
status: (a.status as string) ?? "draft",
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
targetCount,
submittedCount,
gradedCount,
scoreStats: toScoreStats(scores),
}
})
const overallScores = toScoreStats(allScored)
const latest = stats[0] ?? null
const latestAssignmentId = stats[0]?.assignmentId ?? null
const prevAssignmentId = stats[1]?.assignmentId ?? null
const classSummaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() }
const classStudentIds = Array.from(bucket.all)
const latestScores: number[] = []
const prevScores: number[] = []
const overallClassScores: number[] = []
if (latestAssignmentId) {
for (const studentId of classStudentIds) {
const s = latestByKey.get(`${latestAssignmentId}:${studentId}`)
if (typeof s?.score === "number") latestScores.push(s.score)
}
}
if (prevAssignmentId) {
for (const studentId of classStudentIds) {
const s = latestByKey.get(`${prevAssignmentId}:${studentId}`)
if (typeof s?.score === "number") prevScores.push(s.score)
}
}
for (const assignmentId of usedAssignmentIds) {
for (const studentId of classStudentIds) {
const s = latestByKey.get(`${assignmentId}:${studentId}`)
if (typeof s?.score === "number") overallClassScores.push(s.score)
}
}
const latestAvg = avg(latestScores)
const prevAvg = avg(prevScores)
return {
class: { id: c.id, name: c.name, grade: c.grade, homeroom: c.homeroom, room: c.room },
studentCounts: { total: bucket.all.size, active: bucket.active.size, inactive: bucket.all.size - bucket.active.size },
latestAvg,
prevAvg,
deltaAvg: typeof latestAvg === "number" && typeof prevAvg === "number" ? latestAvg - prevAvg : null,
overallScores: toScoreStats(overallClassScores),
}
})
classSummaries.sort((a, b) => (b.latestAvg ?? -Infinity) - (a.latestAvg ?? -Infinity))
return {
grade: { id: gradeRow.id, name: gradeRow.name, school: { id: gradeRow.schoolId, name: gradeRow.schoolName } },
classCount: classRows.length,
studentCounts: { total: studentIds.length, active: activeCount, inactive: inactiveCount },
assignments: stats,
latest,
overallScores,
classes: classSummaries,
}
}
)
export type ClassesDashboardStats = {
classCount: number
}
export const getClassesDashboardStats = cache(async (): Promise<ClassesDashboardStats> => {
const [row] = await db.select({ value: count() }).from(classes)
return { classCount: Number(row?.value ?? 0) }
})

View File

@@ -0,0 +1,280 @@
import "server-only";
import { cache } from "react"
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
subjects,
exams,
users,
} from "@/shared/db/schema"
import type {
ClassStudent,
StudentEnrolledClass,
} from "./types"
import {
compareClassLike,
getAccessibleClassIdsForTeacher,
getSessionTeacherId,
getTeacherSubjectIdsForClass,
} from "./data-access"
export const getStudentsSubjectScores = cache(
async (studentIds: string[]): Promise<Map<string, Record<string, number | null>>> => {
if (studentIds.length === 0) return new Map()
// 1. Find assignments targeted at these students
const assignmentTargets = await db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = Array.from(new Set(assignmentTargets.map(t => t.assignmentId)))
if (assignmentIds.length === 0) return new Map()
// 2. Get assignment details including subject from linked exam
const assignments = await db
.select({
id: homeworkAssignments.id,
createdAt: homeworkAssignments.createdAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(
inArray(homeworkAssignments.id, assignmentIds),
eq(homeworkAssignments.status, "published")
))
.orderBy(desc(homeworkAssignments.createdAt))
// 3. Filter subjects (exclude PE, Music, Art)
const excludeSubjects = ["体育", "音乐", "美术"]
const subjectAssignments = new Map<string, string>() // subject -> assignmentId (latest)
for (const a of assignments) {
if (!a.subjectName) continue
if (excludeSubjects.includes(a.subjectName)) continue
if (!subjectAssignments.has(a.subjectName)) {
subjectAssignments.set(a.subjectName, a.id)
}
}
const targetAssignmentIds = Array.from(subjectAssignments.values())
if (targetAssignmentIds.length === 0) return new Map()
// 4. Get submissions for these assignments
const submissions = await db
.select({
studentId: homeworkSubmissions.studentId,
assignmentId: homeworkSubmissions.assignmentId,
score: homeworkSubmissions.score,
createdAt: homeworkSubmissions.createdAt,
})
.from(homeworkSubmissions)
.where(inArray(homeworkSubmissions.assignmentId, targetAssignmentIds))
.orderBy(desc(homeworkSubmissions.createdAt))
// 5. Map back to subject scores per student
const studentScores = new Map<string, Record<string, number | null>>()
// Create reverse map for assignment -> subject
const assignmentSubjectMap = new Map<string, string>()
for (const [subject, id] of subjectAssignments.entries()) {
assignmentSubjectMap.set(id, subject)
}
for (const s of submissions) {
const subject = assignmentSubjectMap.get(s.assignmentId)
if (!subject) continue
if (!studentScores.has(s.studentId)) {
studentScores.set(s.studentId, {})
}
const scores = studentScores.get(s.studentId)!
// Only set if not already set (since we ordered by desc createdAt, first one is latest)
if (scores[subject] === undefined) {
scores[subject] = s.score
}
}
return studentScores
}
)
export const getClassStudentSubjectScoresV2 = cache(
async (params: { classId: string; teacherId?: string }): Promise<Map<string, Record<string, number | null>>> => {
const teacherId = params.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return new Map()
const classId = params.classId.trim()
if (!classId) return new Map()
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0 || !accessibleIds.includes(classId)) return new Map()
const [classRow] = await db
.select({ id: classes.id, teacherId: classes.teacherId })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) return new Map()
const isHomeroomTeacher = classRow.teacherId === teacherId
const subjectIds = isHomeroomTeacher ? [] : await getTeacherSubjectIdsForClass(teacherId, classId)
if (!isHomeroomTeacher && subjectIds.length === 0) return new Map()
const enrollments = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(and(
eq(classEnrollments.classId, classId),
eq(classEnrollments.status, "active")
))
const studentIds = enrollments.map((e) => e.studentId)
const studentScores = await getStudentsSubjectScores(studentIds)
if (subjectIds.length === 0) return studentScores
// Map subjectIds to names for filtering
const subjectRows = await db
.select({ id: subjects.id, name: subjects.name })
.from(subjects)
.where(inArray(subjects.id, subjectIds))
const allowed = new Set(subjectRows.map((s) => s.name))
const filtered = new Map<string, Record<string, number | null>>()
for (const [studentId, scores] of studentScores.entries()) {
const nextScores: Record<string, number | null> = {}
for (const [subject, score] of Object.entries(scores)) {
if (allowed.has(subject)) nextScores[subject] = score
}
filtered.set(studentId, nextScores)
}
return filtered
}
)
export const getStudentClasses = cache(async (studentId: string): Promise<StudentEnrolledClass[]> => {
const id = studentId.trim()
if (!id) return []
const rows = await (async () => {
try {
return await db
.select({
id: classes.id,
schoolName: classes.schoolName,
name: classes.name,
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
} catch {
return await db
.select({
id: classes.id,
schoolName: sql<string | null>`NULL`.as("schoolName"),
name: classes.name,
grade: classes.grade,
homeroom: classes.homeroom,
room: classes.room,
teacherName: users.name,
teacherEmail: users.email,
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.leftJoin(users, eq(users.id, classes.teacherId))
.where(and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")))
.orderBy(asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
}
})()
const list = rows.map((r) => ({
id: r.id,
schoolName: r.schoolName,
name: r.name,
grade: r.grade,
homeroom: r.homeroom,
room: r.room,
teacherName: r.teacherName,
teacherEmail: r.teacherEmail,
}))
list.sort(compareClassLike)
return list
})
export const getClassStudents = cache(
async (params?: { classId?: string; q?: string; status?: string; teacherId?: string }): Promise<ClassStudent[]> => {
const teacherId = params?.teacherId ?? (await getSessionTeacherId())
if (!teacherId) return []
const classId = params?.classId?.trim()
const q = params?.q?.trim().toLowerCase()
const status = params?.status?.trim().toLowerCase()
const accessibleIds = await getAccessibleClassIdsForTeacher(teacherId)
if (accessibleIds.length === 0) return []
const conditions: SQL[] = [inArray(classes.id, accessibleIds)]
if (classId) {
conditions.push(eq(classes.id, classId))
}
if (status === "active" || status === "inactive") {
conditions.push(eq(classEnrollments.status, status))
}
if (q && q.length > 0) {
const needle = `%${q}%`
conditions.push(
sql`(LOWER(COALESCE(${users.name}, '')) LIKE ${needle} OR LOWER(${users.email}) LIKE ${needle})`
)
}
const rows = await db
.select({
id: users.id,
name: users.name,
email: users.email,
image: users.image,
gender: users.gender,
classId: classes.id,
className: classes.name,
status: classEnrollments.status,
joinedAt: classEnrollments.createdAt,
})
.from(classEnrollments)
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
.innerJoin(users, eq(users.id, classEnrollments.studentId))
.where(and(...conditions))
.orderBy(asc(users.name), asc(users.email))
return rows.map((r) => ({
id: r.id,
name: r.name ?? "Unnamed",
email: r.email,
image: r.image,
gender: r.gender,
classId: r.classId,
className: r.className,
status: r.status,
joinedAt: r.joinedAt,
}))
}
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,205 +1,47 @@
import "server-only"
import { cache } from "react"
import { count, desc, eq, gt, inArray, and } from "drizzle-orm"
import { db } from "@/shared/db"
import {
chapters,
classes,
exams,
homeworkAssignments,
homeworkSubmissions,
questions,
roles,
sessions,
textbooks,
users,
usersToRoles,
} from "@/shared/db/schema"
import type { AdminDashboardData } from "./types"
import { getClassesDashboardStats } from "@/modules/classes/data-access"
import { getExamsDashboardStats } from "@/modules/exams/data-access"
import { getHomeworkDashboardStats } from "@/modules/homework/stats-service"
import { getQuestionsDashboardStats } from "@/modules/questions/data-access"
import { getTextbooksDashboardStats } from "@/modules/textbooks/data-access"
import { getUsersDashboardStats } from "@/modules/users/data-access"
import type { DataScope } from "@/shared/types/permissions"
import type { AdminDashboardData } from "./types"
export const getAdminDashboardData = cache(async (scope?: DataScope): Promise<AdminDashboardData> => {
const now = new Date()
// Build scope-based conditions for exams
const examConditions = []
const homeworkConditions = []
const submissionConditions = []
if (scope && scope.type !== "all") {
if (scope.type === "owned") {
examConditions.push(eq(exams.creatorId, scope.userId))
homeworkConditions.push(eq(homeworkAssignments.creatorId, scope.userId))
const ownedAssignmentIds = db
.select({ id: homeworkAssignments.id })
.from(homeworkAssignments)
.where(eq(homeworkAssignments.creatorId, scope.userId))
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
examConditions.push(inArray(exams.gradeId, scope.gradeIds))
const gradeExamIds = db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, scope.gradeIds))
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
const gradeAssignmentIds = db
.select({ id: homeworkAssignments.id })
.from(homeworkAssignments)
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0) {
examConditions.push(inArray(exams.gradeId, gradeIds))
const gradeExamIds = db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, gradeIds))
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
const gradeAssignmentIds = db
.select({ id: homeworkAssignments.id })
.from(homeworkAssignments)
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
}
}
}
const [
activeSessionsRow,
userCountRow,
userRoleCountRows,
classCountRow,
textbookCountRow,
chapterCountRow,
questionCountRow,
examCountRow,
homeworkAssignmentCountRow,
homeworkAssignmentPublishedCountRow,
homeworkSubmissionCountRow,
homeworkSubmissionToGradeCountRow,
recentUserRows,
usersStats,
classesStats,
textbooksStats,
questionsStats,
examsStats,
homeworkStats,
] = await Promise.all([
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
db.select({ value: count() }).from(users),
db
.select({ role: roles.name, value: count() })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.groupBy(roles.name),
db.select({ value: count() }).from(classes),
db.select({ value: count() }).from(textbooks),
db.select({ value: count() }).from(chapters),
db.select({ value: count() }).from(questions),
db.select({ value: count() }).from(exams).where(examConditions.length ? and(...examConditions) : undefined),
db.select({ value: count() }).from(homeworkAssignments).where(homeworkConditions.length ? and(...homeworkConditions) : undefined),
db.select({ value: count() }).from(homeworkAssignments).where(
homeworkConditions.length
? and(eq(homeworkAssignments.status, "published"), ...homeworkConditions)
: eq(homeworkAssignments.status, "published")
),
db.select({ value: count() }).from(homeworkSubmissions).where(submissionConditions.length ? and(...submissionConditions) : undefined),
db.select({ value: count() }).from(homeworkSubmissions).where(
submissionConditions.length
? and(eq(homeworkSubmissions.status, "submitted"), ...submissionConditions)
: eq(homeworkSubmissions.status, "submitted")
),
db
.select({
id: users.id,
name: users.name,
email: users.email,
createdAt: users.createdAt,
})
.from(users)
.orderBy(desc(users.createdAt))
.limit(8),
getUsersDashboardStats(),
getClassesDashboardStats(),
getTextbooksDashboardStats(),
getQuestionsDashboardStats(),
getExamsDashboardStats(scope),
getHomeworkDashboardStats(scope),
])
const activeSessionsCount = Number(activeSessionsRow[0]?.value ?? 0)
const userCount = Number(userCountRow[0]?.value ?? 0)
const classCount = Number(classCountRow[0]?.value ?? 0)
const textbookCount = Number(textbookCountRow[0]?.value ?? 0)
const chapterCount = Number(chapterCountRow[0]?.value ?? 0)
const questionCount = Number(questionCountRow[0]?.value ?? 0)
const examCount = Number(examCountRow[0]?.value ?? 0)
const homeworkAssignmentCount = Number(homeworkAssignmentCountRow[0]?.value ?? 0)
const homeworkAssignmentPublishedCount = Number(homeworkAssignmentPublishedCountRow[0]?.value ?? 0)
const homeworkSubmissionCount = Number(homeworkSubmissionCountRow[0]?.value ?? 0)
const homeworkSubmissionToGradeCount = Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0)
const userRoleCounts = userRoleCountRows
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
.sort((a, b) => b.count - a.count)
const normalizeRole = (value: string) => {
const role = value.trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
return ""
}
const recentUserIds = recentUserRows.map((u) => u.id)
const recentRoleRows = recentUserIds.length
? await db
.select({
userId: usersToRoles.userId,
roleName: roles.name,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(usersToRoles.userId, recentUserIds))
: []
const rolesByUserId = new Map<string, string[]>()
for (const row of recentRoleRows) {
const list = rolesByUserId.get(row.userId) ?? []
list.push(row.roleName)
rolesByUserId.set(row.userId, list)
}
const resolvePrimaryRole = (roleNames: string[]) => {
const mapped = roleNames.map(normalizeRole).filter(Boolean)
if (mapped.includes("admin")) return "admin"
if (mapped.includes("teacher")) return "teacher"
if (mapped.includes("parent")) return "parent"
if (mapped.includes("student")) return "student"
return "student"
}
const recentUsers = recentUserRows.map((u) => {
const roleNames = rolesByUserId.get(u.id) ?? []
return {
id: u.id,
name: u.name,
email: u.email,
role: resolvePrimaryRole(roleNames),
createdAt: u.createdAt.toISOString(),
}
})
return {
activeSessionsCount,
userCount,
userRoleCounts,
classCount,
textbookCount,
chapterCount,
questionCount,
examCount,
homeworkAssignmentCount,
homeworkAssignmentPublishedCount,
homeworkSubmissionCount,
homeworkSubmissionToGradeCount,
recentUsers,
activeSessionsCount: usersStats.activeSessionsCount,
userCount: usersStats.userCount,
userRoleCounts: usersStats.userRoleCounts,
classCount: classesStats.classCount,
textbookCount: textbooksStats.textbookCount,
chapterCount: textbooksStats.chapterCount,
questionCount: questionsStats.questionCount,
examCount: examsStats.examCount,
homeworkAssignmentCount: homeworkStats.homeworkAssignmentCount,
homeworkAssignmentPublishedCount: homeworkStats.homeworkAssignmentPublishedCount,
homeworkSubmissionCount: homeworkStats.homeworkSubmissionCount,
homeworkSubmissionToGradeCount: homeworkStats.homeworkSubmissionToGradeCount,
recentUsers: usersStats.recentUsers,
}
})

View File

@@ -1,6 +1,6 @@
import { db } from "@/shared/db"
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
import { eq, desc, like, and, or, inArray } from "drizzle-orm"
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
import { cache } from "react"
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
@@ -337,3 +337,37 @@ export const persistAiGeneratedExamDraft = async (input: {
}
})
}
export type ExamsDashboardStats = {
examCount: number
}
export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<ExamsDashboardStats> => {
const conditions = []
if (scope && scope.type !== "all") {
if (scope.type === "owned") {
conditions.push(eq(exams.creatorId, scope.userId))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, scope.gradeIds))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds))
}
}
}
const [row] = await db
.select({ value: count() })
.from(exams)
.where(conditions.length ? and(...conditions) : undefined)
return { examCount: Number(row?.value ?? 0) }
})

View File

@@ -26,86 +26,20 @@ import type {
HomeworkAssignmentStatus,
HomeworkSubmissionDetails,
HomeworkSubmissionListItem,
HomeworkAssignmentAnalytics,
HomeworkAssignmentQuestionAnalytics,
StudentHomeworkAssignmentListItem,
StudentHomeworkProgressStatus,
StudentHomeworkTakeData,
StudentDashboardGradeProps,
StudentHomeworkScoreAnalytics,
StudentRanking,
TeacherGradeTrendItem,
} from "./types"
import type { DataScope } from "@/shared/types/permissions"
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
const recentAssignments = await db.query.homeworkAssignments.findMany({
where: and(
eq(homeworkAssignments.creatorId, teacherId),
or(eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "archived"))
),
orderBy: [desc(homeworkAssignments.createdAt)],
limit: limit,
})
export const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
if (recentAssignments.length === 0) return []
const assignmentIds = recentAssignments.map((a) => a.id)
const [maxScoreMap, targetCountRows, submissionStats] = await Promise.all([
getAssignmentMaxScoreById(assignmentIds),
db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
count: count(homeworkAssignmentTargets.studentId),
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
count: count(homeworkSubmissions.id),
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
)
)
.groupBy(homeworkSubmissions.assignmentId),
])
const targetCountMap = new Map<string, number>()
for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count)
const statsMap = new Map<string, { avg: number; count: number }>()
for (const r of submissionStats) statsMap.set(r.assignmentId, { avg: Number(r.avgScore), count: Number(r.count) })
return recentAssignments.map((a) => {
const stats = statsMap.get(a.id) ?? { avg: 0, count: 0 }
return {
id: a.id,
title: a.title,
averageScore: stats.avg,
maxScore: maxScoreMap.get(a.id) ?? 0,
submissionCount: stats.count,
totalStudents: targetCountMap.get(a.id) ?? 0,
createdAt: a.createdAt.toISOString(),
}
}).reverse() // Reverse to show trend from left (older) to right (newer)
})
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
export const toQuestionContent = (v: unknown): HomeworkQuestionContent | null => {
if (!isRecord(v)) return null
return v as HomeworkQuestionContent
}
const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
export const getAssignmentMaxScoreById = async (assignmentIds: string[]): Promise<Map<string, number>> => {
const ids = assignmentIds.filter((v) => v.trim().length > 0)
if (ids.length === 0) return new Map()
@@ -473,152 +407,6 @@ export const getHomeworkAssignmentById = cache(async (id: string, scope?: DataSc
}
})
export const getHomeworkAssignmentAnalytics = cache(
async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
with: {
sourceExam: true,
},
})
if (!assignment) return null
const [targetsRow] = await db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
const [submissionsRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
const [submittedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
eq(homeworkSubmissions.assignmentId, assignmentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
const [gradedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: { question: true },
orderBy: (q, { asc }) => [asc(q.order)],
})
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
for (const aq of assignmentQuestions) {
statsByQuestionId.set(aq.questionId, {
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
errorCount: 0,
errorRate: 0,
})
}
const gradedSubmissionsAll = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
with: {
answers: true,
student: true,
},
})
const latestByStudentId = new Map<string, (typeof gradedSubmissionsAll)[number]>()
for (const s of gradedSubmissionsAll) {
if (!latestByStudentId.has(s.studentId)) latestByStudentId.set(s.studentId, s)
}
const gradedSubmissions = Array.from(latestByStudentId.values())
const scoreBySubmissionQuestion = new Map<string, number>()
const answerBySubmissionQuestion = new Map<string, unknown>()
for (const sub of gradedSubmissions) {
for (const ans of sub.answers) {
const key = `${sub.id}|${ans.questionId}`
if (scoreBySubmissionQuestion.has(key)) continue
scoreBySubmissionQuestion.set(key, ans.score ?? 0)
const raw = ans.answerContent
if (isRecord(raw) && "answer" in raw) {
answerBySubmissionQuestion.set(key, raw.answer)
} else {
answerBySubmissionQuestion.set(key, raw)
}
}
}
const denom = gradedSubmissions.length
if (denom > 0) {
for (const q of statsByQuestionId.values()) {
if (q.maxScore <= 0) continue
let errors = 0
const wrongAnswers: Array<{ studentId: string; studentName: string; answerContent: unknown }> = []
for (const sub of gradedSubmissions) {
const key = `${sub.id}|${q.questionId}`
const score = scoreBySubmissionQuestion.get(key) ?? 0
if (score < q.maxScore) {
errors += 1
wrongAnswers.push({
studentId: sub.studentId,
studentName: sub.student.name || "Unknown",
answerContent: answerBySubmissionQuestion.get(key),
})
}
}
q.errorCount = errors
q.errorRate = errors / denom
q.wrongAnswers = wrongAnswers.slice(0, 500)
}
}
const questions: HomeworkAssignmentQuestionAnalytics[] = Array.from(statsByQuestionId.values())
.sort((a, b) => a.order - b.order)
const analytics: HomeworkAssignmentAnalytics = {
assignment: {
id: assignment.id,
title: assignment.title,
description: assignment.description,
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title,
structure: assignment.structure as unknown,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
targetCount: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
submittedCount: submittedRow?.c ?? 0,
gradedCount: gradedRow?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
},
gradedSampleCount: gradedSubmissions.length,
questions,
}
return analytics
}
)
export const getHomeworkSubmissionDetails = cache(async (submissionId: string): Promise<HomeworkSubmissionDetails | null> => {
const submission = await db.query.homeworkSubmissions.findFirst({
where: eq(homeworkSubmissions.id, submissionId),
@@ -882,157 +670,12 @@ export const getStudentHomeworkTakeData = cache(async (assignmentId: string, stu
}
})
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
const id = studentId.trim()
if (!id) return { trend: [], recent: [], ranking: null }
const targetAssignmentIdsRows = await db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, id))
const targetAssignmentIds = Array.from(new Set(targetAssignmentIdsRows.map((r) => r.assignmentId)))
if (targetAssignmentIds.length === 0) return { trend: [], recent: [], ranking: null }
const gradedSubmissions = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.studentId, id),
inArray(homeworkSubmissions.assignmentId, targetAssignmentIds),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
limit: 200,
})
const latestByAssignmentId = new Map<string, (typeof gradedSubmissions)[number]>()
for (const s of gradedSubmissions) {
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
}
const unique = Array.from(latestByAssignmentId.values()).sort((a, b) => {
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
return aTime - bTime
})
const trendSubmissions = unique.slice(-10)
const recentSubmissions = [...trendSubmissions].sort((a, b) => {
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
return bTime - aTime
})
const assignmentIds = Array.from(new Set(trendSubmissions.map((s) => s.assignmentId)))
const assignments = await db.query.homeworkAssignments.findMany({
where: inArray(homeworkAssignments.id, assignmentIds),
})
const titleByAssignmentId = new Map(assignments.map((a) => [a.id, a.title] as const))
const maxScoreByAssignmentId = await getAssignmentMaxScoreById(assignmentIds)
const toAnalytics = (s: (typeof trendSubmissions)[number]): StudentHomeworkScoreAnalytics => {
const maxScore = maxScoreByAssignmentId.get(s.assignmentId) ?? 0
const score = s.score ?? 0
const percentage = maxScore > 0 ? (score / maxScore) * 100 : 0
return {
assignmentId: s.assignmentId,
assignmentTitle: titleByAssignmentId.get(s.assignmentId) ?? "Untitled",
score,
maxScore,
percentage,
submittedAt: (s.submittedAt ?? s.updatedAt).toISOString(),
}
}
const trend = trendSubmissions.map(toAnalytics)
const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
const enrollment = await db.query.classEnrollments.findFirst({
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")),
orderBy: (e, { asc }) => [asc(e.createdAt)],
})
if (!enrollment) return { trend, recent, ranking: null }
const classStudents = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, enrollment.classId), eq(classEnrollments.status, "active")))
const classStudentIds = Array.from(new Set(classStudents.map((r) => r.studentId)))
const classSize = classStudentIds.length
if (classSize === 0) return { trend, recent, ranking: null }
const classAssignmentIdsRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
const classAssignmentIds = Array.from(new Set(classAssignmentIdsRows.map((r) => r.assignmentId)))
if (classAssignmentIds.length === 0) return { trend, recent, ranking: null }
const classMaxScoreByAssignmentId = await getAssignmentMaxScoreById(classAssignmentIds)
const classGradedSubmissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.studentId, classStudentIds),
inArray(homeworkSubmissions.assignmentId, classAssignmentIds),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
limit: 5000,
})
const latestByStudentAssignment = new Map<string, (typeof classGradedSubmissions)[number]>()
for (const s of classGradedSubmissions) {
const key = `${s.studentId}|${s.assignmentId}`
if (!latestByStudentAssignment.has(key)) latestByStudentAssignment.set(key, s)
}
const totalsByStudentId = new Map<string, { score: number; maxScore: number }>()
for (const sub of latestByStudentAssignment.values()) {
const maxScore = classMaxScoreByAssignmentId.get(sub.assignmentId) ?? 0
const score = sub.score ?? 0
const prev = totalsByStudentId.get(sub.studentId) ?? { score: 0, maxScore: 0 }
totalsByStudentId.set(sub.studentId, {
score: prev.score + score,
maxScore: prev.maxScore + maxScore,
})
}
const classUsers = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, classStudentIds))
const nameByStudentId = new Map(classUsers.map((u) => [u.id, u.name ?? "Student"] as const))
const myName = nameByStudentId.get(id) ?? "Student"
const ranked = classStudentIds
.map((studentId) => {
const totals = totalsByStudentId.get(studentId) ?? { score: 0, maxScore: 0 }
const percentage = totals.maxScore > 0 ? (totals.score / totals.maxScore) * 100 : 0
return { studentId, percentage, totals }
})
.sort((a, b) => {
if (b.percentage !== a.percentage) return b.percentage - a.percentage
return a.studentId.localeCompare(b.studentId)
})
const myIndex = ranked.findIndex((r) => r.studentId === id)
if (myIndex < 0) return { trend, recent, ranking: null }
const myTotals = ranked[myIndex]?.totals ?? { score: 0, maxScore: 0 }
const myPercentage = myTotals.maxScore > 0 ? (myTotals.score / myTotals.maxScore) * 100 : 0
const ranking: StudentRanking = {
studentId: id,
studentName: myName,
rank: myIndex + 1,
classSize,
totalScore: myTotals.score,
totalMaxScore: myTotals.maxScore,
percentage: myPercentage,
}
return { trend, recent, ranking }
})
// Re-export stats functions for backward compatibility
// New code should import directly from "./stats-service"
export {
getTeacherGradeTrends,
getHomeworkAssignmentAnalytics,
getStudentDashboardGrades,
getHomeworkDashboardStats,
} from "./stats-service"
export type { HomeworkDashboardStats } from "./stats-service"

View File

@@ -0,0 +1,483 @@
import "server-only"
import { cache } from "react"
import { and, count, desc, eq, inArray, or, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classEnrollments,
classes,
exams,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
users,
} from "@/shared/db/schema"
import type {
HomeworkAssignmentAnalytics,
HomeworkAssignmentQuestionAnalytics,
HomeworkAssignmentStatus,
StudentDashboardGradeProps,
StudentHomeworkScoreAnalytics,
StudentRanking,
TeacherGradeTrendItem,
} from "./types"
import type { DataScope } from "@/shared/types/permissions"
import { getAssignmentMaxScoreById, isRecord, toQuestionContent } from "./data-access"
/**
* Get grade trend data for a teacher's recent assignments.
* Used by the teacher dashboard to visualize class performance over time.
*/
export const getTeacherGradeTrends = cache(async (teacherId: string, limit: number = 5): Promise<TeacherGradeTrendItem[]> => {
const recentAssignments = await db.query.homeworkAssignments.findMany({
where: and(
eq(homeworkAssignments.creatorId, teacherId),
or(eq(homeworkAssignments.status, "published"), eq(homeworkAssignments.status, "archived"))
),
orderBy: [desc(homeworkAssignments.createdAt)],
limit: limit,
})
if (recentAssignments.length === 0) return []
const assignmentIds = recentAssignments.map((a) => a.id)
const [maxScoreMap, targetCountRows, submissionStats] = await Promise.all([
getAssignmentMaxScoreById(assignmentIds),
db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
count: count(homeworkAssignmentTargets.studentId),
})
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.assignmentId, assignmentIds))
.groupBy(homeworkAssignmentTargets.assignmentId),
db
.select({
assignmentId: homeworkSubmissions.assignmentId,
avgScore: sql<number>`AVG(${homeworkSubmissions.score})`,
count: count(homeworkSubmissions.id),
})
.from(homeworkSubmissions)
.where(
and(
inArray(homeworkSubmissions.assignmentId, assignmentIds),
eq(homeworkSubmissions.status, "graded")
)
)
.groupBy(homeworkSubmissions.assignmentId),
])
const targetCountMap = new Map<string, number>()
for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count)
const statsMap = new Map<string, { avg: number; count: number }>()
for (const r of submissionStats) statsMap.set(r.assignmentId, { avg: Number(r.avgScore), count: Number(r.count) })
return recentAssignments.map((a) => {
const stats = statsMap.get(a.id) ?? { avg: 0, count: 0 }
return {
id: a.id,
title: a.title,
averageScore: stats.avg,
maxScore: maxScoreMap.get(a.id) ?? 0,
submissionCount: stats.count,
totalStudents: targetCountMap.get(a.id) ?? 0,
createdAt: a.createdAt.toISOString(),
}
}).reverse() // Reverse to show trend from left (older) to right (newer)
})
/**
* Get detailed analytics for a specific homework assignment.
* Includes per-question error rates and wrong answer samples.
*/
export const getHomeworkAssignmentAnalytics = cache(
async (assignmentId: string): Promise<HomeworkAssignmentAnalytics | null> => {
const assignment = await db.query.homeworkAssignments.findFirst({
where: eq(homeworkAssignments.id, assignmentId),
with: {
sourceExam: true,
},
})
if (!assignment) return null
const [targetsRow] = await db
.select({ c: count() })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.assignmentId, assignmentId))
const [submissionsRow] = await db
.select({ c: count() })
.from(homeworkSubmissions)
.where(eq(homeworkSubmissions.assignmentId, assignmentId))
const [submittedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(
and(
eq(homeworkSubmissions.assignmentId, assignmentId),
inArray(homeworkSubmissions.status, ["submitted", "graded"])
)
)
const [gradedRow] = await db
.select({ c: sql<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
.from(homeworkSubmissions)
.where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.status, "graded")))
const assignmentQuestions = await db.query.homeworkAssignmentQuestions.findMany({
where: eq(homeworkAssignmentQuestions.assignmentId, assignmentId),
with: { question: true },
orderBy: (q, { asc }) => [asc(q.order)],
})
const statsByQuestionId = new Map<string, HomeworkAssignmentQuestionAnalytics>()
for (const aq of assignmentQuestions) {
statsByQuestionId.set(aq.questionId, {
questionId: aq.questionId,
questionType: aq.question.type,
questionContent: toQuestionContent(aq.question.content),
maxScore: aq.score ?? 0,
order: aq.order ?? 0,
errorCount: 0,
errorRate: 0,
})
}
const gradedSubmissionsAll = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.assignmentId, assignmentId),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
with: {
answers: true,
student: true,
},
})
const latestByStudentId = new Map<string, (typeof gradedSubmissionsAll)[number]>()
for (const s of gradedSubmissionsAll) {
if (!latestByStudentId.has(s.studentId)) latestByStudentId.set(s.studentId, s)
}
const gradedSubmissions = Array.from(latestByStudentId.values())
const scoreBySubmissionQuestion = new Map<string, number>()
const answerBySubmissionQuestion = new Map<string, unknown>()
for (const sub of gradedSubmissions) {
for (const ans of sub.answers) {
const key = `${sub.id}|${ans.questionId}`
if (scoreBySubmissionQuestion.has(key)) continue
scoreBySubmissionQuestion.set(key, ans.score ?? 0)
const raw = ans.answerContent
if (isRecord(raw) && "answer" in raw) {
answerBySubmissionQuestion.set(key, raw.answer)
} else {
answerBySubmissionQuestion.set(key, raw)
}
}
}
const denom = gradedSubmissions.length
if (denom > 0) {
for (const q of statsByQuestionId.values()) {
if (q.maxScore <= 0) continue
let errors = 0
const wrongAnswers: Array<{ studentId: string; studentName: string; answerContent: unknown }> = []
for (const sub of gradedSubmissions) {
const key = `${sub.id}|${q.questionId}`
const score = scoreBySubmissionQuestion.get(key) ?? 0
if (score < q.maxScore) {
errors += 1
wrongAnswers.push({
studentId: sub.studentId,
studentName: sub.student.name || "Unknown",
answerContent: answerBySubmissionQuestion.get(key),
})
}
}
q.errorCount = errors
q.errorRate = errors / denom
q.wrongAnswers = wrongAnswers.slice(0, 500)
}
}
const questions: HomeworkAssignmentQuestionAnalytics[] = Array.from(statsByQuestionId.values())
.sort((a, b) => a.order - b.order)
const analytics: HomeworkAssignmentAnalytics = {
assignment: {
id: assignment.id,
title: assignment.title,
description: assignment.description,
status: (assignment.status as HomeworkAssignmentStatus) ?? "draft",
sourceExamId: assignment.sourceExamId,
sourceExamTitle: assignment.sourceExam.title,
structure: assignment.structure as unknown,
availableAt: assignment.availableAt ? assignment.availableAt.toISOString() : null,
dueAt: assignment.dueAt ? assignment.dueAt.toISOString() : null,
allowLate: assignment.allowLate,
lateDueAt: assignment.lateDueAt ? assignment.lateDueAt.toISOString() : null,
maxAttempts: assignment.maxAttempts,
targetCount: targetsRow?.c ?? 0,
submissionCount: submissionsRow?.c ?? 0,
submittedCount: submittedRow?.c ?? 0,
gradedCount: gradedRow?.c ?? 0,
createdAt: assignment.createdAt.toISOString(),
updatedAt: assignment.updatedAt.toISOString(),
},
gradedSampleCount: gradedSubmissions.length,
questions,
}
return analytics
}
)
/**
* Get student dashboard grade data including trend, recent scores, and class ranking.
* The ranking calculation queries all classmates' graded submissions and computes
* relative position by total percentage score.
*/
export const getStudentDashboardGrades = cache(async (studentId: string): Promise<StudentDashboardGradeProps> => {
const id = studentId.trim()
if (!id) return { trend: [], recent: [], ranking: null }
const targetAssignmentIdsRows = await db
.select({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(eq(homeworkAssignmentTargets.studentId, id))
const targetAssignmentIds = Array.from(new Set(targetAssignmentIdsRows.map((r) => r.assignmentId)))
if (targetAssignmentIds.length === 0) return { trend: [], recent: [], ranking: null }
const gradedSubmissions = await db.query.homeworkSubmissions.findMany({
where: and(
eq(homeworkSubmissions.studentId, id),
inArray(homeworkSubmissions.assignmentId, targetAssignmentIds),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
limit: 200,
})
const latestByAssignmentId = new Map<string, (typeof gradedSubmissions)[number]>()
for (const s of gradedSubmissions) {
if (!latestByAssignmentId.has(s.assignmentId)) latestByAssignmentId.set(s.assignmentId, s)
}
const unique = Array.from(latestByAssignmentId.values()).sort((a, b) => {
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
return aTime - bTime
})
const trendSubmissions = unique.slice(-10)
const recentSubmissions = [...trendSubmissions].sort((a, b) => {
const aTime = (a.submittedAt ?? a.updatedAt).getTime()
const bTime = (b.submittedAt ?? b.updatedAt).getTime()
return bTime - aTime
})
const assignmentIds = Array.from(new Set(trendSubmissions.map((s) => s.assignmentId)))
const assignments = await db.query.homeworkAssignments.findMany({
where: inArray(homeworkAssignments.id, assignmentIds),
})
const titleByAssignmentId = new Map(assignments.map((a) => [a.id, a.title] as const))
const maxScoreByAssignmentId = await getAssignmentMaxScoreById(assignmentIds)
const toAnalytics = (s: (typeof trendSubmissions)[number]): StudentHomeworkScoreAnalytics => {
const maxScore = maxScoreByAssignmentId.get(s.assignmentId) ?? 0
const score = s.score ?? 0
const percentage = maxScore > 0 ? (score / maxScore) * 100 : 0
return {
assignmentId: s.assignmentId,
assignmentTitle: titleByAssignmentId.get(s.assignmentId) ?? "Untitled",
score,
maxScore,
percentage,
submittedAt: (s.submittedAt ?? s.updatedAt).toISOString(),
}
}
const trend = trendSubmissions.map(toAnalytics)
const recent = recentSubmissions.map(toAnalytics).slice(0, 5)
const enrollment = await db.query.classEnrollments.findFirst({
where: and(eq(classEnrollments.studentId, id), eq(classEnrollments.status, "active")),
orderBy: (e, { asc }) => [asc(e.createdAt)],
})
if (!enrollment) return { trend, recent, ranking: null }
const classStudents = await db
.select({ studentId: classEnrollments.studentId })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, enrollment.classId), eq(classEnrollments.status, "active")))
const classStudentIds = Array.from(new Set(classStudents.map((r) => r.studentId)))
const classSize = classStudentIds.length
if (classSize === 0) return { trend, recent, ranking: null }
const classAssignmentIdsRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, classStudentIds))
const classAssignmentIds = Array.from(new Set(classAssignmentIdsRows.map((r) => r.assignmentId)))
if (classAssignmentIds.length === 0) return { trend, recent, ranking: null }
const classMaxScoreByAssignmentId = await getAssignmentMaxScoreById(classAssignmentIds)
const classGradedSubmissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.studentId, classStudentIds),
inArray(homeworkSubmissions.assignmentId, classAssignmentIds),
eq(homeworkSubmissions.status, "graded")
),
orderBy: (s, { desc }) => [desc(s.updatedAt)],
limit: 5000,
})
const latestByStudentAssignment = new Map<string, (typeof classGradedSubmissions)[number]>()
for (const s of classGradedSubmissions) {
const key = `${s.studentId}|${s.assignmentId}`
if (!latestByStudentAssignment.has(key)) latestByStudentAssignment.set(key, s)
}
const totalsByStudentId = new Map<string, { score: number; maxScore: number }>()
for (const sub of latestByStudentAssignment.values()) {
const maxScore = classMaxScoreByAssignmentId.get(sub.assignmentId) ?? 0
const score = sub.score ?? 0
const prev = totalsByStudentId.get(sub.studentId) ?? { score: 0, maxScore: 0 }
totalsByStudentId.set(sub.studentId, {
score: prev.score + score,
maxScore: prev.maxScore + maxScore,
})
}
const classUsers = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, classStudentIds))
const nameByStudentId = new Map(classUsers.map((u) => [u.id, u.name ?? "Student"] as const))
const myName = nameByStudentId.get(id) ?? "Student"
const ranked = classStudentIds
.map((studentId) => {
const totals = totalsByStudentId.get(studentId) ?? { score: 0, maxScore: 0 }
const percentage = totals.maxScore > 0 ? (totals.score / totals.maxScore) * 100 : 0
return { studentId, percentage, totals }
})
.sort((a, b) => {
if (b.percentage !== a.percentage) return b.percentage - a.percentage
return a.studentId.localeCompare(b.studentId)
})
const myIndex = ranked.findIndex((r) => r.studentId === id)
if (myIndex < 0) return { trend, recent, ranking: null }
const myTotals = ranked[myIndex]?.totals ?? { score: 0, maxScore: 0 }
const myPercentage = myTotals.maxScore > 0 ? (myTotals.score / myTotals.maxScore) * 100 : 0
const ranking: StudentRanking = {
studentId: id,
studentName: myName,
rank: myIndex + 1,
classSize,
totalScore: myTotals.score,
totalMaxScore: myTotals.maxScore,
percentage: myPercentage,
}
return { trend, recent, ranking }
})
export type HomeworkDashboardStats = {
homeworkAssignmentCount: number
homeworkAssignmentPublishedCount: number
homeworkSubmissionCount: number
homeworkSubmissionToGradeCount: number
}
export const getHomeworkDashboardStats = cache(async (scope?: DataScope): Promise<HomeworkDashboardStats> => {
const homeworkConditions = []
const submissionConditions = []
if (scope && scope.type !== "all") {
if (scope.type === "owned") {
homeworkConditions.push(eq(homeworkAssignments.creatorId, scope.userId))
const ownedAssignmentIds = db
.select({ id: homeworkAssignments.id })
.from(homeworkAssignments)
.where(eq(homeworkAssignments.creatorId, scope.userId))
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, ownedAssignmentIds))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
const gradeExamIds = db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, scope.gradeIds))
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
const gradeAssignmentIds = db
.select({ id: homeworkAssignments.id })
.from(homeworkAssignments)
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const teacherGradeIds = await db
.selectDistinct({ gradeId: classes.gradeId })
.from(classes)
.where(inArray(classes.id, scope.classIds))
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
if (gradeIds.length > 0) {
const gradeExamIds = db
.select({ id: exams.id })
.from(exams)
.where(inArray(exams.gradeId, gradeIds))
homeworkConditions.push(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
const gradeAssignmentIds = db
.select({ id: homeworkAssignments.id })
.from(homeworkAssignments)
.where(inArray(homeworkAssignments.sourceExamId, gradeExamIds))
submissionConditions.push(inArray(homeworkSubmissions.assignmentId, gradeAssignmentIds))
}
}
}
const [
homeworkAssignmentCountRow,
homeworkAssignmentPublishedCountRow,
homeworkSubmissionCountRow,
homeworkSubmissionToGradeCountRow,
] = await Promise.all([
db.select({ value: count() }).from(homeworkAssignments).where(homeworkConditions.length ? and(...homeworkConditions) : undefined),
db.select({ value: count() }).from(homeworkAssignments).where(
homeworkConditions.length
? and(eq(homeworkAssignments.status, "published"), ...homeworkConditions)
: eq(homeworkAssignments.status, "published")
),
db.select({ value: count() }).from(homeworkSubmissions).where(submissionConditions.length ? and(...submissionConditions) : undefined),
db.select({ value: count() }).from(homeworkSubmissions).where(
submissionConditions.length
? and(eq(homeworkSubmissions.status, "submitted"), ...submissionConditions)
: eq(homeworkSubmissions.status, "submitted")
),
])
return {
homeworkAssignmentCount: Number(homeworkAssignmentCountRow[0]?.value ?? 0),
homeworkAssignmentPublishedCount: Number(homeworkAssignmentPublishedCountRow[0]?.value ?? 0),
homeworkSubmissionCount: Number(homeworkSubmissionCountRow[0]?.value ?? 0),
homeworkSubmissionToGradeCount: Number(homeworkSubmissionToGradeCountRow[0]?.value ?? 0),
}
})

View File

@@ -127,3 +127,12 @@ export const getQuestions = cache(async ({
},
};
});
export type QuestionsDashboardStats = {
questionCount: number
}
export const getQuestionsDashboardStats = cache(async (): Promise<QuestionsDashboardStats> => {
const [row] = await db.select({ value: count() }).from(questions)
return { questionCount: Number(row?.value ?? 0) }
})

View File

@@ -1,7 +1,7 @@
import "server-only"
import { cache } from "react"
import { and, asc, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm"
import { and, asc, count, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm"
import { createId } from "@paralleldrive/cuid2"
import { db } from "@/shared/db"
@@ -417,12 +417,28 @@ export async function reorderChapters(chapterId: string, newIndex: number, paren
if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) {
await tx
.update(chapters)
.set({
.set({
order: i,
parentId: ch.id === chapterId ? parentId : ch.parentId
parentId: ch.id === chapterId ? parentId : ch.parentId
})
.where(eq(chapters.id, ch.id))
}
}
})
}
export type TextbooksDashboardStats = {
textbookCount: number
chapterCount: number
}
export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashboardStats> => {
const [textbookCountRow, chapterCountRow] = await Promise.all([
db.select({ value: count() }).from(textbooks),
db.select({ value: count() }).from(chapters),
])
return {
textbookCount: Number(textbookCountRow[0]?.value ?? 0),
chapterCount: Number(chapterCountRow[0]?.value ?? 0),
}
})

View File

@@ -1,10 +1,10 @@
import "server-only"
import { cache } from "react"
import { eq } from "drizzle-orm"
import { count, desc, eq, gt, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import { roles, users, usersToRoles } from "@/shared/db/schema"
import { roles, sessions, users, usersToRoles } from "@/shared/db/schema"
export type UserProfile = {
id: string
@@ -69,3 +69,84 @@ export const getUserProfile = cache(async (userId: string): Promise<UserProfile
updatedAt: user.updatedAt,
}
})
export type UsersDashboardStats = {
userCount: number
activeSessionsCount: number
userRoleCounts: Array<{ role: string; count: number }>
recentUsers: Array<{
id: string
name: string | null
email: string
role: string | null
createdAt: string
}>
}
export const getUsersDashboardStats = cache(async (): Promise<UsersDashboardStats> => {
const now = new Date()
const [userCountRow, activeSessionsRow, userRoleCountRows, recentUserRows] = await Promise.all([
db.select({ value: count() }).from(users),
db.select({ value: count() }).from(sessions).where(gt(sessions.expires, now)),
db
.select({ role: roles.name, value: count() })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.groupBy(roles.name),
db
.select({
id: users.id,
name: users.name,
email: users.email,
createdAt: users.createdAt,
})
.from(users)
.orderBy(desc(users.createdAt))
.limit(8),
])
const userCount = Number(userCountRow[0]?.value ?? 0)
const activeSessionsCount = Number(activeSessionsRow[0]?.value ?? 0)
const userRoleCounts = userRoleCountRows
.map((r) => ({ role: r.role ?? "unknown", count: Number(r.value ?? 0) }))
.sort((a, b) => b.count - a.count)
const recentUserIds = recentUserRows.map((u) => u.id)
const recentRoleRows = recentUserIds.length
? await db
.select({
userId: usersToRoles.userId,
roleName: roles.name,
})
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(inArray(usersToRoles.userId, recentUserIds))
: []
const rolesByUserId = new Map<string, string[]>()
for (const row of recentRoleRows) {
const list = rolesByUserId.get(row.userId) ?? []
list.push(row.roleName)
rolesByUserId.set(row.userId, list)
}
const recentUsers = recentUserRows.map((u) => {
const roleNames = rolesByUserId.get(u.id) ?? []
return {
id: u.id,
name: u.name,
email: u.email,
role: resolvePrimaryRole(roleNames),
createdAt: u.createdAt.toISOString(),
}
})
return {
userCount,
activeSessionsCount,
userRoleCounts,
recentUsers,
}
})