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.
This commit is contained in:
SpecialX
2026-06-19 05:13:09 +08:00
parent 063baffe4c
commit 49291fcc31
114 changed files with 12548 additions and 3395 deletions

View File

@@ -1,13 +1,15 @@
import "server-only"
import { cache } from "react"
import { and, asc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { gradeRecords } from "@/shared/db/schema"
import {
classes,
gradeRecords,
subjects,
} from "@/shared/db/schema"
getClassesByGradeId,
getClassNameById,
} from "@/modules/classes/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import type { DataScope } from "@/shared/types/permissions"
import type {
@@ -54,63 +56,67 @@ export interface GradeTrendParams {
currentUserId?: string
}
export async function getGradeTrend(
params: GradeTrendParams
): Promise<GradeTrendResult | null> {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
export const getGradeTrend = cache(
async (params: GradeTrendParams): Promise<GradeTrendResult | null> => {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
record: gradeRecords,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
if (rows.length === 0) return null
const points: GradeTrendPoint[] = rows.map((r) => {
const score = toNumber(r.record.score)
const fullScore = toNumber(r.record.fullScore)
return {
date: r.record.createdAt.toISOString(),
title: r.record.title,
score,
fullScore,
normalizedScore: normalize(score, fullScore),
type: r.record.type,
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
})
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
const className = rows[0].className ?? "Class"
const subjectName = rows[0].subjectName ?? "All Subjects"
const studentLabel = params.studentId
? `Student ${params.studentId.slice(-4)}`
: "Class Average"
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
return {
label: params.subjectId
? `${className} · ${subjectName} · ${studentLabel}`
: `${className} · ${studentLabel}`,
points,
averageScore: Math.round(avg * 100) / 100,
const rows = await db
.select({
record: gradeRecords,
})
.from(gradeRecords)
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
if (rows.length === 0) return null
// Fetch display names via cross-module interfaces
const className = await getClassNameById(params.classId)
let subjectName = "All Subjects"
if (params.subjectId) {
const subjectOptions = await getSubjectOptions()
const subject = subjectOptions.find((s) => s.id === params.subjectId)
subjectName = subject?.name ?? "Unknown"
}
const points: GradeTrendPoint[] = rows.map((r) => {
const score = toNumber(r.record.score)
const fullScore = toNumber(r.record.fullScore)
return {
date: r.record.createdAt.toISOString(),
title: r.record.title,
score,
fullScore,
normalizedScore: normalize(score, fullScore),
type: r.record.type,
}
})
const avg = points.reduce((acc, p) => acc + p.normalizedScore, 0) / points.length
const finalClassName = className ?? "Class"
const studentLabel = params.studentId
? `Student ${params.studentId.slice(-4)}`
: "Class Average"
return {
label: params.subjectId
? `${finalClassName} · ${subjectName} · ${studentLabel}`
: `${finalClassName} · ${studentLabel}`,
points,
averageScore: Math.round(avg * 100) / 100,
}
}
}
)
export interface ClassComparisonParams {
gradeId: string
@@ -119,37 +125,32 @@ export interface ClassComparisonParams {
scope: DataScope
}
export async function getClassComparison(
params: ClassComparisonParams
): Promise<ClassComparisonItem[]> {
const classRows = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.gradeId, params.gradeId))
export const getClassComparison = cache(
async (params: ClassComparisonParams): Promise<ClassComparisonItem[]> => {
const classRows = await getClassesByGradeId(params.gradeId)
if (classRows.length === 0) return []
if (classRows.length === 0) return []
const scope = params.scope
const allowedClassIds =
scope.type === "class_taught"
? classRows.filter((c) => scope.classIds.includes(c.id)).map((c) => c.id)
: classRows.map((c) => c.id)
const scope = params.scope
const scopeClassIdSet =
scope.type === "class_taught" ? new Set(scope.classIds) : null
const allowedClassRows = scopeClassIdSet
? classRows.filter((c) => scopeClassIdSet.has(c.id))
: classRows
if (allowedClassIds.length === 0) return []
if (allowedClassRows.length === 0) return []
const result: ClassComparisonItem[] = []
for (const cls of classRows) {
if (!allowedClassIds.includes(cls.id)) continue
const allowedClassIds = allowedClassRows.map((c) => c.id)
const conditions = [
eq(gradeRecords.classId, cls.id),
inArray(gradeRecords.classId, allowedClassIds),
eq(gradeRecords.subjectId, params.subjectId),
]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
const rows = await db
const allRows = await db
.select({
classId: gradeRecords.classId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
studentId: gradeRecords.studentId,
@@ -157,35 +158,64 @@ export async function getClassComparison(
.from(gradeRecords)
.where(and(...conditions))
if (rows.length === 0) {
result.push({
classId: cls.id, className: cls.name, averageScore: 0, medianScore: 0,
passRate: 0, excellentRate: 0, count: 0, studentCount: 0,
})
continue
const byClass = new Map<string, typeof allRows>()
for (const r of allRows) {
const existing = byClass.get(r.classId)
if (existing) {
existing.push(r)
} else {
byClass.set(r.classId, [r])
}
}
const normalized = rows.map((r) => normalize(toNumber(r.score), toNumber(r.fullScore)))
const sorted = [...normalized].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
const result: ClassComparisonItem[] = allowedClassRows.map((cls) => {
const rows = byClass.get(cls.id) ?? []
if (rows.length === 0) {
return {
classId: cls.id,
className: cls.name,
averageScore: 0,
medianScore: 0,
passRate: 0,
excellentRate: 0,
count: 0,
studentCount: 0,
}
}
result.push({
classId: cls.id,
className: cls.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((normalized.filter((s) => s >= 60).length / normalized.length) * 10000) / 100,
excellentRate: Math.round((normalized.filter((s) => s >= 85).length / normalized.length) * 10000) / 100,
count: normalized.length,
studentCount: uniqueStudents,
const normalized = rows.map((r) =>
normalize(toNumber(r.score), toNumber(r.fullScore))
)
const sorted = [...normalized].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median =
sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = normalized.reduce((a, b) => a + b, 0) / normalized.length
const uniqueStudents = new Set(rows.map((r) => r.studentId)).size
const { passCount, excellentCount } = normalized.reduce(
(acc, s) => ({
passCount: acc.passCount + (s >= 60 ? 1 : 0),
excellentCount: acc.excellentCount + (s >= 85 ? 1 : 0),
}),
{ passCount: 0, excellentCount: 0 }
)
return {
classId: cls.id,
className: cls.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((passCount / normalized.length) * 10000) / 100,
excellentRate: Math.round((excellentCount / normalized.length) * 10000) / 100,
count: normalized.length,
studentCount: uniqueStudents,
}
})
}
return result
}
return result
}
)
export interface SubjectComparisonParams {
classId: string
@@ -193,56 +223,71 @@ export interface SubjectComparisonParams {
scope: DataScope
}
export async function getSubjectComparison(
params: SubjectComparisonParams
): Promise<SubjectComparisonItem[]> {
const scopeFilter = buildScopeClassFilter(params.scope)
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (scopeFilter) conditions.push(scopeFilter)
export const getSubjectComparison = cache(
async (params: SubjectComparisonParams): Promise<SubjectComparisonItem[]> => {
const scopeFilter = buildScopeClassFilter(params.scope)
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({
subjectId: gradeRecords.subjectId,
subjectName: subjects.name,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(and(...conditions))
const rows = await db
.select({
subjectId: gradeRecords.subjectId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
const bySubject = new Map<string, { name: string; scores: number[] }>()
if (rows.length === 0) return []
for (const r of rows) {
const sid = r.subjectId
if (!sid) continue
const entry = bySubject.get(sid) ?? { name: r.subjectName ?? "Unknown", scores: [] }
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
bySubject.set(sid, entry)
// Fetch subject names via cross-module interface
const subjectIds = Array.from(new Set(rows.map((r) => r.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
const subjectOptions = await getSubjectOptions()
const subjectNameById = new Map<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
const bySubject = new Map<string, { name: string; scores: number[] }>()
for (const r of rows) {
const sid = r.subjectId
if (!sid) continue
const entry = bySubject.get(sid) ?? { name: subjectNameById.get(sid) ?? "Unknown", scores: [] }
entry.scores.push(normalize(toNumber(r.score), toNumber(r.fullScore)))
bySubject.set(sid, entry)
}
const result: SubjectComparisonItem[] = []
for (const [subjectId, entry] of bySubject.entries()) {
if (entry.scores.length === 0) continue
const sorted = [...entry.scores].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median =
sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
const { passCount, excellentCount } = entry.scores.reduce(
(acc, s) => ({
passCount: acc.passCount + (s >= 60 ? 1 : 0),
excellentCount: acc.excellentCount + (s >= 85 ? 1 : 0),
}),
{ passCount: 0, excellentCount: 0 }
)
result.push({
subjectId,
subjectName: entry.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((passCount / entry.scores.length) * 10000) / 100,
excellentRate: Math.round((excellentCount / entry.scores.length) * 10000) / 100,
count: entry.scores.length,
})
}
return result.sort((a, b) => b.averageScore - a.averageScore)
}
const result: SubjectComparisonItem[] = []
for (const [subjectId, entry] of bySubject.entries()) {
if (entry.scores.length === 0) continue
const sorted = [...entry.scores].sort((a, b) => a - b)
const mid = Math.floor(sorted.length / 2)
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const avg = entry.scores.reduce((a, b) => a + b, 0) / entry.scores.length
result.push({
subjectId,
subjectName: entry.name,
averageScore: Math.round(avg * 100) / 100,
medianScore: Math.round(median * 100) / 100,
passRate: Math.round((entry.scores.filter((s) => s >= 60).length / entry.scores.length) * 10000) / 100,
excellentRate: Math.round((entry.scores.filter((s) => s >= 85).length / entry.scores.length) * 10000) / 100,
count: entry.scores.length,
})
}
return result.sort((a, b) => b.averageScore - a.averageScore)
}
)
export interface GradeDistributionParams {
classId: string
@@ -252,42 +297,42 @@ export interface GradeDistributionParams {
currentUserId?: string
}
export async function getGradeDistribution(
params: GradeDistributionParams
): Promise<GradeDistributionResult> {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
export const getGradeDistribution = cache(
async (params: GradeDistributionParams): Promise<GradeDistributionResult> => {
const conditions = [eq(gradeRecords.classId, params.classId)]
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore })
.from(gradeRecords)
.where(and(...conditions))
const buckets: GradeDistributionBucket[] = [
{ label: "90-100", min: 90, max: 100, count: 0 },
{ label: "80-89", min: 80, max: 89, count: 0 },
{ label: "70-79", min: 70, max: 79, count: 0 },
{ label: "60-69", min: 60, max: 69, count: 0 },
{ label: "<60", min: 0, max: 59, count: 0 },
]
for (const r of rows) {
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
const rounded = Math.round(normalized)
if (rounded >= 90) buckets[0].count++
else if (rounded >= 80) buckets[1].count++
else if (rounded >= 70) buckets[2].count++
else if (rounded >= 60) buckets[3].count++
else buckets[4].count++
}
return { buckets, totalCount: rows.length }
}
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const rows = await db
.select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore })
.from(gradeRecords)
.where(and(...conditions))
const buckets: GradeDistributionBucket[] = [
{ label: "90-100", min: 90, max: 100, count: 0 },
{ label: "80-89", min: 80, max: 89, count: 0 },
{ label: "70-79", min: 70, max: 79, count: 0 },
{ label: "60-69", min: 60, max: 69, count: 0 },
{ label: "<60", min: 0, max: 59, count: 0 },
]
for (const r of rows) {
const normalized = normalize(toNumber(r.score), toNumber(r.fullScore))
const rounded = Math.round(normalized)
if (rounded >= 90) buckets[0].count++
else if (rounded >= 80) buckets[1].count++
else if (rounded >= 70) buckets[2].count++
else if (rounded >= 60) buckets[3].count++
else buckets[4].count++
}
return { buckets, totalCount: rows.length }
}
)

View File

@@ -1,13 +1,12 @@
import "server-only"
import { cache } from "react"
import { and, asc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classEnrollments,
gradeRecords,
users,
} from "@/shared/db/schema"
import { gradeRecords } from "@/shared/db/schema"
import { getStudentActiveClassId } from "@/modules/classes/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import type {
RankingTrendPoint,
@@ -29,93 +28,92 @@ const normalize = (score: number, fullScore: number): number => {
* Each point represents one assessment (grouped by title), with the
* student's normalized score, rank, and total participants.
*/
export async function getRankingTrend(
studentId: string,
subjectId?: string,
semester?: "1" | "2"
): Promise<RankingTrendResult | null> {
const [student] = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(eq(users.id, studentId))
.limit(1)
if (!student) return null
export const getRankingTrend = cache(
async (
studentId: string,
subjectId?: string,
semester?: "1" | "2"
): Promise<RankingTrendResult | null> => {
const studentNameMap = await getUserNamesByIds([studentId])
const studentInfo = studentNameMap.get(studentId)
if (!studentInfo) return null
const studentName = studentInfo.name ?? "Unknown"
const [enrollment] = await db
.select({ classId: classEnrollments.classId })
.from(classEnrollments)
.where(
and(
eq(classEnrollments.studentId, studentId),
eq(classEnrollments.status, "active")
)
)
.limit(1)
const classId = await getStudentActiveClassId(studentId)
if (!classId) {
return {
studentId,
studentName,
points: [],
}
}
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (semester) conditions.push(eq(gradeRecords.semester, semester))
const rows = await db
.select({
title: gradeRecords.title,
createdAt: gradeRecords.createdAt,
studentId: gradeRecords.studentId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
const byTitle = new Map<
string,
{
date: Date
entries: Array<{ studentId: string; normalized: number }>
}
>()
for (const r of rows) {
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
entry.entries.push({
studentId: r.studentId,
normalized: normalize(toNumber(r.score), toNumber(r.fullScore)),
})
byTitle.set(r.title, entry)
}
const points: RankingTrendPoint[] = []
for (const [title, entry] of byTitle.entries()) {
if (entry.entries.length === 0) continue
const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized)
// Single traversal: find rank and student entry together
let rank = 0
let studentEntry: { studentId: string; normalized: number } | null = null
for (let i = 0; i < sorted.length; i += 1) {
const e = sorted[i]
if (e.studentId === studentId) {
rank = i + 1
studentEntry = e
break
}
}
if (rank <= 0 || !studentEntry) continue
points.push({
title,
date: entry.date.toISOString(),
score: studentEntry.normalized,
rank,
totalStudents: sorted.length,
})
}
points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
if (!enrollment) {
return {
studentId,
studentName: student.name ?? "Unknown",
points: [],
studentName,
points,
}
}
const conditions = [eq(gradeRecords.classId, enrollment.classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (semester) conditions.push(eq(gradeRecords.semester, semester))
const rows = await db
.select({
title: gradeRecords.title,
createdAt: gradeRecords.createdAt,
studentId: gradeRecords.studentId,
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
.orderBy(asc(gradeRecords.createdAt))
const byTitle = new Map<
string,
{
date: Date
entries: Array<{ studentId: string; normalized: number }>
}
>()
for (const r of rows) {
const entry = byTitle.get(r.title) ?? { date: r.createdAt, entries: [] }
entry.entries.push({
studentId: r.studentId,
normalized: normalize(toNumber(r.score), toNumber(r.fullScore)),
})
byTitle.set(r.title, entry)
}
const points: RankingTrendPoint[] = []
for (const [title, entry] of byTitle.entries()) {
if (entry.entries.length === 0) continue
const sorted = [...entry.entries].sort((a, b) => b.normalized - a.normalized)
const rank = sorted.findIndex((e) => e.studentId === studentId) + 1
if (rank <= 0) continue
const studentEntry = sorted.find((e) => e.studentId === studentId)
if (!studentEntry) continue
points.push({
title,
date: entry.date.toISOString(),
score: studentEntry.normalized,
rank,
totalStudents: sorted.length,
})
}
points.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
return {
studentId,
studentName: student.name ?? "Unknown",
points,
}
}
)

View File

@@ -1,15 +1,19 @@
import "server-only"
import { and, asc, count, desc, eq, inArray, sql } from "drizzle-orm"
import { cache } from "react"
import { createId } from "@paralleldrive/cuid2"
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { gradeRecords } from "@/shared/db/schema"
import {
classes,
classEnrollments,
gradeRecords,
subjects,
users,
} from "@/shared/db/schema"
getActiveStudentIdsByClassId,
getClassExists,
getClassNameById,
getClassNamesByIds,
} from "@/modules/classes/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import type { DataScope } from "@/shared/types/permissions"
import type {
@@ -70,82 +74,83 @@ const buildScopeClassFilter = (scope: DataScope) => {
return sql`1=0`
}
export async function getGradeRecords(
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
): Promise<GradeRecordListItem[]> {
const conditions = []
export const getGradeRecords = cache(
async (
params: GradeQueryParams & { scope: DataScope; currentUserId?: string }
): Promise<GradeRecordListItem[]> => {
const conditions = []
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
const scopeFilter = buildScopeClassFilter(params.scope)
if (scopeFilter) conditions.push(scopeFilter)
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
if (params.classId) conditions.push(eq(gradeRecords.classId, params.classId))
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
if (params.type) conditions.push(eq(gradeRecords.type, params.type))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
const rows = await db
.select({
record: gradeRecords,
studentName: users.name,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(users, eq(users.id, gradeRecords.studentId))
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(gradeRecords.createdAt))
const recorderIds = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
const recorderMap = new Map<string, string>()
if (recorderIds.length > 0) {
const recorders = await db
.select({ id: users.id, name: users.name })
.from(users)
.where(inArray(users.id, recorderIds))
for (const r of recorders) {
recorderMap.set(r.id, r.name ?? "Unknown")
if (params.scope.type === "class_members" && params.currentUserId) {
conditions.push(eq(gradeRecords.studentId, params.currentUserId))
}
if (params.classId) conditions.push(eq(gradeRecords.classId, params.classId))
if (params.subjectId) conditions.push(eq(gradeRecords.subjectId, params.subjectId))
if (params.studentId) conditions.push(eq(gradeRecords.studentId, params.studentId))
if (params.type) conditions.push(eq(gradeRecords.type, params.type))
if (params.semester) conditions.push(eq(gradeRecords.semester, params.semester))
if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId))
const rows = await db
.select({
record: gradeRecords,
})
.from(gradeRecords)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(gradeRecords.createdAt))
if (rows.length === 0) return []
// Batch fetch display names via cross-module interfaces
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
const classIds = Array.from(new Set(rows.map((r) => r.record.classId).filter((v): v is string => typeof v === "string" && v.length > 0)))
const subjectIds = Array.from(new Set(rows.map((r) => r.record.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
const recorderIds = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
const [studentNameMap, classNameMap, subjectOptions, recorderNameMap] = await Promise.all([
getUserNamesByIds(studentIds),
getClassNamesByIds(classIds),
getSubjectOptions(),
getUserNamesByIds(recorderIds),
])
const subjectNameById = new Map<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
return rows.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: studentNameMap.get(r.record.studentId)?.name ?? "Unknown",
classId: r.record.classId,
className: r.record.classId ? classNameMap.get(r.record.classId) ?? "Unknown" : "Unknown",
subjectId: r.record.subjectId,
subjectName: r.record.subjectId ? subjectNameById.get(r.record.subjectId) ?? "Unknown" : "Unknown",
examId: r.record.examId ?? null,
title: r.record.title,
score: toNumber(r.record.score),
fullScore: toNumber(r.record.fullScore),
type: r.record.type,
semester: r.record.semester,
recordedBy: r.record.recordedBy,
recorderName: recorderNameMap.get(r.record.recordedBy)?.name ?? "Unknown",
remark: r.record.remark ?? null,
createdAt: r.record.createdAt.toISOString(),
}))
}
)
return rows.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: r.studentName ?? "Unknown",
classId: r.record.classId,
className: r.className ?? "Unknown",
subjectId: r.record.subjectId,
subjectName: r.subjectName ?? "Unknown",
examId: r.record.examId ?? null,
title: r.record.title,
score: toNumber(r.record.score),
fullScore: toNumber(r.record.fullScore),
type: r.record.type,
semester: r.record.semester,
recordedBy: r.record.recordedBy,
recorderName: recorderMap.get(r.record.recordedBy) ?? "Unknown",
remark: r.record.remark ?? null,
createdAt: r.record.createdAt.toISOString(),
}))
}
export async function getGradeRecordById(id: string): Promise<GradeRecord | null> {
export const getGradeRecordById = cache(async (id: string): Promise<GradeRecord | null> => {
const [row] = await db.select().from(gradeRecords).where(eq(gradeRecords.id, id)).limit(1)
return row ? serializeRecord(row) : null
}
})
export async function createGradeRecord(
data: CreateGradeRecordInput,
recordedBy: string
): Promise<string> {
const { createId } = await import("@paralleldrive/cuid2")
const id = createId()
await db.insert(gradeRecords).values({
id,
@@ -169,7 +174,6 @@ export async function batchCreateGradeRecords(
data: BatchCreateGradeRecordInput,
recordedBy: string
): Promise<number> {
const { createId } = await import("@paralleldrive/cuid2")
const rows = data.records.map((r) => ({
id: createId(),
studentId: r.studentId,
@@ -211,94 +215,106 @@ export async function deleteGradeRecord(id: string): Promise<void> {
await db.delete(gradeRecords).where(eq(gradeRecords.id, id))
}
export async function getClassGradeStats(
classId: string,
subjectId?: string,
examId?: string
): Promise<GradeStats | null> {
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId))
export const getClassGradeStats = cache(
async (
classId: string,
subjectId?: string,
examId?: string
): Promise<GradeStats | null> => {
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId))
const rows = await db
.select({
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
const rows = await db
.select({
score: gradeRecords.score,
fullScore: gradeRecords.fullScore,
})
.from(gradeRecords)
.where(and(...conditions))
if (rows.length === 0) return null
if (rows.length === 0) return null
const scores = rows.map((r) => toNumber(r.score))
const fullScores = rows.map((r) => toNumber(r.fullScore))
const countN = scores.length
const sum = scores.reduce((a, b) => a + b, 0)
const average = sum / countN
const sorted = [...scores].sort((a, b) => a - b)
const mid = Math.floor(countN / 2)
const median = countN % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const max = sorted[countN - 1]
const min = sorted[0]
const variance = scores.reduce((acc, s) => acc + Math.pow(s - average, 2), 0) / countN
const stdDev = Math.sqrt(variance)
const scores = rows.map((r) => toNumber(r.score))
const fullScores = rows.map((r) => toNumber(r.fullScore))
const countN = scores.length
const sum = scores.reduce((a, b) => a + b, 0)
const average = sum / countN
const sorted = [...scores].sort((a, b) => a - b)
const mid = Math.floor(countN / 2)
const median = countN % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
const max = sorted[countN - 1]
const min = sorted[0]
const variance = scores.reduce((acc, s) => acc + Math.pow(s - average, 2), 0) / countN
const stdDev = Math.sqrt(variance)
let passCount = 0
let excellentCount = 0
for (let i = 0; i < countN; i++) {
const ratio = scores[i] / fullScores[i]
if (ratio >= 0.6) passCount++
if (ratio >= 0.85) excellentCount++
let passCount = 0
let excellentCount = 0
for (let i = 0; i < countN; i++) {
if (fullScores[i] <= 0) continue
const ratio = scores[i] / fullScores[i]
if (ratio >= 0.6) passCount++
if (ratio >= 0.85) excellentCount++
}
return {
average: Math.round(average * 100) / 100,
median: Math.round(median * 100) / 100,
max,
min,
stdDev: Math.round(stdDev * 100) / 100,
passRate: Math.round((passCount / countN) * 10000) / 100,
excellentRate: Math.round((excellentCount / countN) * 10000) / 100,
count: countN,
}
}
return {
average: Math.round(average * 100) / 100,
median: Math.round(median * 100) / 100,
max,
min,
stdDev: Math.round(stdDev * 100) / 100,
passRate: Math.round((passCount / countN) * 10000) / 100,
excellentRate: Math.round((excellentCount / countN) * 10000) / 100,
count: countN,
}
}
)
export async function getStudentGradeSummary(
studentId: string
): Promise<StudentGradeSummary | null> {
const [student] = await db.select({ name: users.name }).from(users).where(eq(users.id, studentId)).limit(1)
if (!student) return null
const studentNameMap = await getUserNamesByIds([studentId])
const studentName = studentNameMap.get(studentId)?.name ?? null
if (!studentName && !studentNameMap.has(studentId)) return null
const records = await db
.select({
record: gradeRecords,
className: classes.name,
subjectName: subjects.name,
})
.from(gradeRecords)
.leftJoin(classes, eq(classes.id, gradeRecords.classId))
.leftJoin(subjects, eq(subjects.id, gradeRecords.subjectId))
.where(eq(gradeRecords.studentId, studentId))
.orderBy(desc(gradeRecords.createdAt))
if (records.length === 0) {
return {
studentId,
studentName: student.name ?? "Unknown",
studentName: studentName ?? "Unknown",
records: [],
averageScore: 0,
rank: 0,
}
}
// Batch fetch display names via cross-module interfaces
const classIds = Array.from(new Set(records.map((r) => r.record.classId).filter((v): v is string => typeof v === "string" && v.length > 0)))
const subjectIds = Array.from(new Set(records.map((r) => r.record.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
const [classNameMap, subjectOptions] = await Promise.all([
getClassNamesByIds(classIds),
getSubjectOptions(),
])
const subjectNameById = new Map<string, string>()
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
const listItems: GradeRecordListItem[] = records.map((r) => ({
id: r.record.id,
studentId: r.record.studentId,
studentName: student.name ?? "Unknown",
studentName: studentName ?? "Unknown",
classId: r.record.classId,
className: r.className ?? "Unknown",
className: r.record.classId ? classNameMap.get(r.record.classId) ?? "Unknown" : "Unknown",
subjectId: r.record.subjectId,
subjectName: r.subjectName ?? "Unknown",
subjectName: r.record.subjectId ? subjectNameById.get(r.record.subjectId) ?? "Unknown" : "Unknown",
examId: r.record.examId ?? null,
title: r.record.title,
score: toNumber(r.record.score),
@@ -315,63 +331,67 @@ export async function getStudentGradeSummary(
return {
studentId,
studentName: student.name ?? "Unknown",
studentName: studentName ?? "Unknown",
records: listItems,
averageScore: Math.round(avg * 100) / 100,
rank: 0,
}
}
export async function getClassRanking(
classId: string,
subjectId?: string,
examId?: string
): Promise<ClassRankingItem[]> {
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId))
export const getClassRanking = cache(
async (
classId: string,
subjectId?: string,
examId?: string
): Promise<ClassRankingItem[]> => {
const conditions = [eq(gradeRecords.classId, classId)]
if (subjectId) conditions.push(eq(gradeRecords.subjectId, subjectId))
if (examId) conditions.push(eq(gradeRecords.examId, examId))
const rows = await db
.select({
studentId: gradeRecords.studentId,
studentName: users.name,
avgScore: sql<number>`AVG(${gradeRecords.score})`,
recordCount: count(gradeRecords.id),
})
.from(gradeRecords)
.leftJoin(users, eq(users.id, gradeRecords.studentId))
.where(and(...conditions))
.groupBy(gradeRecords.studentId, users.name)
.orderBy(desc(sql`AVG(${gradeRecords.score})`))
const rows = await db
.select({
studentId: gradeRecords.studentId,
avgScore: sql<number>`AVG(${gradeRecords.score})`,
recordCount: count(gradeRecords.id),
})
.from(gradeRecords)
.where(and(...conditions))
.groupBy(gradeRecords.studentId)
.orderBy(desc(sql`AVG(${gradeRecords.score})`))
return rows.map((r, idx) => ({
studentId: r.studentId,
studentName: r.studentName ?? "Unknown",
averageScore: Math.round(toNumber(r.avgScore) * 100) / 100,
rank: idx + 1,
recordCount: toNumber(r.recordCount),
}))
}
if (rows.length === 0) return []
const studentIds = Array.from(new Set(rows.map((r) => r.studentId)))
const studentNameMap = await getUserNamesByIds(studentIds)
return rows.map((r, idx) => ({
studentId: r.studentId,
studentName: studentNameMap.get(r.studentId)?.name ?? "Unknown",
averageScore: Math.round(toNumber(r.avgScore) * 100) / 100,
rank: idx + 1,
recordCount: toNumber(r.recordCount),
}))
}
)
export async function getClassStudentsForEntry(classId: string): Promise<
Array<{ id: string; name: string; email: string }>
> {
const rows = await db
.select({
id: users.id,
name: users.name,
email: users.email,
})
.from(classEnrollments)
.innerJoin(users, eq(users.id, classEnrollments.studentId))
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
.orderBy(asc(users.name))
const studentIds = await getActiveStudentIdsByClassId(classId)
if (studentIds.length === 0) return []
return rows.map((r) => ({
id: r.id,
name: r.name ?? "Unknown",
email: r.email,
}))
const studentNameMap = await getUserNamesByIds(studentIds)
return studentIds
.map((id) => {
const info = studentNameMap.get(id)
return {
id,
name: info?.name ?? "Unknown",
email: info?.email ?? "",
}
})
.sort((a, b) => a.name.localeCompare(b.name))
}
export async function getClassGradeStatsWithMeta(
@@ -379,18 +399,15 @@ export async function getClassGradeStatsWithMeta(
subjectId?: string,
examId?: string
): Promise<ClassGradeStats | null> {
const [classRow] = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.id, classId))
.limit(1)
if (!classRow) return null
const classExists = await getClassExists(classId)
if (!classExists) return null
const className = await getClassNameById(classId)
const stats = await getClassGradeStats(classId, subjectId, examId)
if (!stats) {
return {
classId,
className: classRow.name,
className: className ?? "Unknown",
stats: {
average: 0,
median: 0,
@@ -405,15 +422,12 @@ export async function getClassGradeStatsWithMeta(
}
}
const [studentCountRow] = await db
.select({ c: count() })
.from(classEnrollments)
.where(and(eq(classEnrollments.classId, classId), eq(classEnrollments.status, "active")))
const activeStudentIds = await getActiveStudentIdsByClassId(classId)
return {
classId,
className: classRow.name,
className: className ?? "Unknown",
stats,
studentCount: toNumber(studentCountRow?.c ?? 0),
studentCount: activeStudentIds.length,
}
}

View File

@@ -1,14 +1,8 @@
import "server-only"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
gradeRecords,
subjects,
users,
} from "@/shared/db/schema"
import { getClassNameById } from "@/modules/classes/data-access"
import { getSubjectOptions } from "@/modules/school/data-access"
import { getUserNamesByIds } from "@/modules/users/data-access"
import type { DataScope } from "@/shared/types/permissions"
import { exportToExcel } from "@/shared/lib/excel"
@@ -113,45 +107,43 @@ export async function exportClassGradeReportToExcel(params: {
classId: string
scope: DataScope
}): Promise<Buffer> {
const [classRow] = await db
.select({ id: classes.id, name: classes.name })
.from(classes)
.where(eq(classes.id, params.classId))
.limit(1)
const className = classRow?.name ?? "Unknown"
const className = (await getClassNameById(params.classId)) ?? "Unknown"
// Get all subjects that have grade records for this class
const subjectRows = await db
.select({
id: subjects.id,
name: subjects.name,
})
.from(subjects)
.innerJoin(gradeRecords, eq(gradeRecords.subjectId, subjects.id))
.where(eq(gradeRecords.classId, params.classId))
.groupBy(subjects.id, subjects.name)
// Get all students with grades in this class
const studentRows = await db
.select({
id: users.id,
name: users.name,
})
.from(users)
.innerJoin(gradeRecords, eq(gradeRecords.studentId, users.id))
.where(eq(gradeRecords.classId, params.classId))
.groupBy(users.id, users.name)
.orderBy(users.name)
// Build a map: studentId -> subjectId -> average score
// Get all grade records for this class (already includes student/subject names via cross-module interfaces)
const allRecords = await getGradeRecords({
scope: params.scope,
classId: params.classId,
})
// Extract unique subjects and students from the records
const subjectIds = Array.from(new Set(allRecords.map((r) => r.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
const studentIds = Array.from(new Set(allRecords.map((r) => r.studentId)))
const [subjectOptions, studentNameMap] = await Promise.all([
getSubjectOptions(),
getUserNamesByIds(studentIds),
])
const subjectRows = subjectIds
.map((id) => {
const subject = subjectOptions.find((s) => s.id === id)
return subject ? { id: subject.id, name: subject.name } : null
})
.filter((s): s is { id: string; name: string } => s !== null)
const studentRows = studentIds
.map((id) => {
const info = studentNameMap.get(id)
return { id, name: info?.name ?? "Unknown" }
})
.sort((a, b) => a.name.localeCompare(b.name))
// Build a map: studentId -> subjectId -> average score
const scoreMap = new Map<string, Map<string, number[]>>()
for (const r of allRecords) {
if (!scoreMap.has(r.studentId)) scoreMap.set(r.studentId, new Map())
const subjMap = scoreMap.get(r.studentId)!
const subjMap = scoreMap.get(r.studentId)
if (!subjMap) continue
const arr = subjMap.get(r.subjectId) ?? []
arr.push(r.score)
subjMap.set(r.subjectId, arr)
@@ -175,7 +167,7 @@ export async function exportClassGradeReportToExcel(params: {
const rowsData = studentRows.map((student) => {
const subjMap = scoreMap.get(student.id) ?? new Map<string, number[]>()
const row: Record<string, unknown> = {
studentName: student.name ?? "Unknown",
studentName: student.name,
}
let total = 0
let count = 0