Files
NextEdu/src/modules/classes/data-access-stats.ts
SpecialX 49291fcc31 refactor: fix all P0/P1/P2 bugs and architecture issues
Bug fixes (from bugs/ directory):

- Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions

- Fix shared/lib <-> auth circular dependency via new session.ts module

- Fix divide-by-zero guard in grades data-access

- Fix audit export data truncation (paginated fetch for full datasets)

- Fix missing transactions in homework grading and elective lottery

- Fix missing revalidatePath in course-plans actions

- Fix frontend permission checks using requirePermission instead of requireAuth

- Fix dashboard role routing using session.user.roles

- Fix student auth pattern (migrate getDemoStudentUser to users module)

- Fix ActionState return type handling in components

Code quality fixes:

- Remove 60+ as type assertions (replace with type guards)

- Remove non-null assertions (use optional chaining or explicit checks)

- Convert dynamic imports to static imports (grades, diagnostic)

- Add React.cache() wrapping for read functions

- Parallelize independent queries with Promise.all

- Add explicit return types to 30+ arrow functions

- Replace any with unknown + type guards

- Fix import type for type-only imports

- Add Zod validation schemas for classes and diagnostic modules

- Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction)

- Add console.error to silent catch blocks

- Fix permission naming consistency (exam:proctor_read -> exam:proctor:read)

Architecture doc sync:

- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Update management-modules-audit.md for P0-7 cross-module fix

Moved deleted proctoring event route to deletes/ folder.
2026-06-19 05:13:34 +08:00

514 lines
18 KiB
TypeScript

import "server-only";
import { cache } from "react"
import { and, asc, count, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
grades,
schools,
} from "@/shared/db/schema"
import {
getAssignmentIdsForStudents,
getAssignmentMaxScoreById,
getAssignmentTargetCounts,
getHomeworkAssignmentsByIds,
getHomeworkAssignmentsWithSubject,
getHomeworkSubmissionsForStudents,
} from "@/modules/homework/data-access-classes"
import type {
ClassHomeworkInsights,
ClassHomeworkAssignmentStats,
GradeHomeworkClassSummary,
GradeHomeworkInsights,
ScoreStats,
} from "./types"
import type { HomeworkSubmissionRecord } from "@/modules/homework/data-access-classes"
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,
}
}
const buildLatestSubmissionByKey = (
submissions: HomeworkSubmissionRecord[]
): Map<string, HomeworkSubmissionRecord> => {
const map = new Map<string, HomeworkSubmissionRecord>()
for (const s of submissions) {
const key = `${s.assignmentId}:${s.studentId}`
if (!map.has(key)) map.set(key, s)
}
return map
}
const computeAssignmentStats = (params: {
assignments: Array<{
id: string
title: string
status: string | null
createdAt: Date
dueAt: Date | null
subjectName?: string | null
}>
studentIds: string[]
latestByKey: Map<string, HomeworkSubmissionRecord>
maxScoreByAssignmentId: Map<string, number>
targetCountByAssignmentId: Map<string, number>
}): { stats: ClassHomeworkAssignmentStats[]; allScored: number[] } => {
const { assignments, studentIds, latestByKey, maxScoreByAssignmentId, targetCountByAssignmentId } = params
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"
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 ?? "draft",
subject: a.subjectName ?? null,
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),
}
})
return { stats, allScored }
}
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 assignmentIds = await getAssignmentIdsForStudents(studentIds)
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 assignments = await getHomeworkAssignmentsWithSubject({
assignmentIds,
subjectIdFilter: subjectIdFilter.length > 0 ? subjectIdFilter : undefined,
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 [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
getAssignmentMaxScoreById(usedAssignmentIds),
getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
])
const latestByKey = buildLatestSubmissionByKey(submissions)
const { stats, allScored } = computeAssignmentStats({
assignments,
studentIds,
latestByKey,
maxScoreByAssignmentId,
targetCountByAssignmentId,
})
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 assignmentIds = await getAssignmentIdsForStudents(studentIds)
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 getHomeworkAssignmentsByIds({ assignmentIds, 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 [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
getAssignmentMaxScoreById(usedAssignmentIds),
getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
])
const latestByKey = buildLatestSubmissionByKey(submissions)
const { stats, allScored } = computeAssignmentStats({
assignments,
studentIds,
latestByKey,
maxScoreByAssignmentId,
targetCountByAssignmentId,
})
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) }
})