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