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:
@@ -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 }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user