import "server-only" import { and, asc, eq, inArray, sql } from "drizzle-orm" import { db } from "@/shared/db" import { classes, gradeRecords, subjects, } from "@/shared/db/schema" import type { DataScope } from "@/shared/types/permissions" import type { ClassComparisonItem, GradeDistributionBucket, GradeDistributionResult, GradeTrendPoint, GradeTrendResult, SubjectComparisonItem, } from "./types" const toNumber = (v: unknown): number => { const n = typeof v === "number" ? v : Number(v) return Number.isFinite(n) ? n : 0 } const normalize = (score: number, fullScore: number): number => { if (fullScore <= 0) return 0 return Math.round((score / fullScore) * 10000) / 100 } const buildScopeClassFilter = (scope: DataScope) => { if (scope.type === "all") return null if (scope.type === "class_taught") { return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0` } if (scope.type === "grade_managed") return sql`1=0` if (scope.type === "class_members") return null if (scope.type === "children") { return scope.childrenIds.length > 0 ? inArray(gradeRecords.studentId, scope.childrenIds) : sql`1=0` } if (scope.type === "owned") return eq(gradeRecords.studentId, scope.userId) return sql`1=0` } export interface GradeTrendParams { classId: string subjectId?: string studentId?: string semester?: "1" | "2" scope: DataScope currentUserId?: string } export async function getGradeTrend( params: GradeTrendParams ): Promise { 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, } }) 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" return { label: params.subjectId ? `${className} · ${subjectName} · ${studentLabel}` : `${className} · ${studentLabel}`, points, averageScore: Math.round(avg * 100) / 100, } } export interface ClassComparisonParams { gradeId: string subjectId: string examId?: string scope: DataScope } export async function getClassComparison( params: ClassComparisonParams ): Promise { const classRows = await db .select({ id: classes.id, name: classes.name }) .from(classes) .where(eq(classes.gradeId, params.gradeId)) 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) if (allowedClassIds.length === 0) return [] const result: ClassComparisonItem[] = [] for (const cls of classRows) { if (!allowedClassIds.includes(cls.id)) continue const conditions = [ eq(gradeRecords.classId, cls.id), eq(gradeRecords.subjectId, params.subjectId), ] if (params.examId) conditions.push(eq(gradeRecords.examId, params.examId)) const rows = await db .select({ score: gradeRecords.score, fullScore: gradeRecords.fullScore, studentId: gradeRecords.studentId, }) .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 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 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, }) } return result } export interface SubjectComparisonParams { classId: string examId?: string scope: DataScope } export async function getSubjectComparison( params: SubjectComparisonParams ): Promise { 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 bySubject = new Map() 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) } 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 subjectId?: string examId?: string scope: DataScope currentUserId?: string } export async function getGradeDistribution( params: GradeDistributionParams ): Promise { 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)) } 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 } }