- Update attendance components and data-access for record management - Update audit log views, filters, and data-access - Update auth login and register forms - Update classes actions, components, and data-access (admin, schedule, stats) - Update course-plans actions, form, list, progress, and schema - Update exams actions, AI pipeline, preview components, and hooks - Update files components (icon, list, preview, upload) and data-access - Update homework assignment form, review view, auto-save hook, and stats-service - Update layout sidebar, header, and navigation config - Update proctoring actions, anti-cheat monitor, and data-access - Update questions actions, components (dialog, actions, columns, filters), and data-access - Update scheduling actions, auto-scheduler, components, and schema - Update textbooks constants and text-selection hook - Update users class-registration, import-dialog, data-access, and user-service
467 lines
18 KiB
TypeScript
467 lines
18 KiB
TypeScript
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<TeacherGradeTrendItem[]> => {
|
|
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<number>`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<string, number>()
|
|
for (const r of targetCountRows) targetCountMap.set(r.assignmentId, r.count)
|
|
|
|
const statsMap = new Map<string, { avg: number; count: number }>()
|
|
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<HomeworkAssignmentAnalytics | null> => {
|
|
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<number>`COUNT(DISTINCT ${homeworkSubmissions.studentId})` })
|
|
.from(homeworkSubmissions)
|
|
.where(
|
|
and(
|
|
eq(homeworkSubmissions.assignmentId, assignmentId),
|
|
inArray(homeworkSubmissions.status, ["submitted", "graded"])
|
|
)
|
|
),
|
|
db
|
|
.select({ c: sql<number>`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<string, HomeworkAssignmentQuestionAnalytics>()
|
|
|
|
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<string, (typeof gradedSubmissionsAll)[number]>()
|
|
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<string, number>()
|
|
const answerBySubmissionQuestion = new Map<string, unknown>()
|
|
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<StudentDashboardGradeProps> => {
|
|
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<string, (typeof gradedSubmissions)[number]>()
|
|
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<string, (typeof classGradedSubmissions)[number]>()
|
|
for (const s of classGradedSubmissions) {
|
|
const key = `${s.studentId}|${s.assignmentId}`
|
|
if (!latestByStudentAssignment.has(key)) latestByStudentAssignment.set(key, s)
|
|
}
|
|
|
|
const totalsByStudentId = new Map<string, { score: number; maxScore: number }>()
|
|
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<HomeworkDashboardStats> => {
|
|
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),
|
|
}
|
|
})
|