refactor: fix all P0/P1/P2 bugs and architecture issues

Bug fixes (from bugs/ directory):

- Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions

- Fix shared/lib <-> auth circular dependency via new session.ts module

- Fix divide-by-zero guard in grades data-access

- Fix audit export data truncation (paginated fetch for full datasets)

- Fix missing transactions in homework grading and elective lottery

- Fix missing revalidatePath in course-plans actions

- Fix frontend permission checks using requirePermission instead of requireAuth

- Fix dashboard role routing using session.user.roles

- Fix student auth pattern (migrate getDemoStudentUser to users module)

- Fix ActionState return type handling in components

Code quality fixes:

- Remove 60+ as type assertions (replace with type guards)

- Remove non-null assertions (use optional chaining or explicit checks)

- Convert dynamic imports to static imports (grades, diagnostic)

- Add React.cache() wrapping for read functions

- Parallelize independent queries with Promise.all

- Add explicit return types to 30+ arrow functions

- Replace any with unknown + type guards

- Fix import type for type-only imports

- Add Zod validation schemas for classes and diagnostic modules

- Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction)

- Add console.error to silent catch blocks

- Fix permission naming consistency (exam:proctor_read -> exam:proctor:read)

Architecture doc sync:

- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Update management-modules-audit.md for P0-7 cross-module fix

Moved deleted proctoring event route to deletes/ folder.
This commit is contained in:
SpecialX
2026-06-19 05:13:09 +08:00
parent 063baffe4c
commit 49291fcc31
114 changed files with 12548 additions and 3395 deletions

View File

@@ -1,21 +1,23 @@
import "server-only";
import { cache } from "react"
import { and, asc, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
import { and, asc, count, eq, inArray } from "drizzle-orm"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
grades,
homeworkAssignmentQuestions,
homeworkAssignmentTargets,
homeworkAssignments,
homeworkSubmissions,
schools,
subjects,
exams,
} from "@/shared/db/schema"
import {
getAssignmentIdsForStudents,
getAssignmentMaxScoreById,
getAssignmentTargetCounts,
getHomeworkAssignmentsByIds,
getHomeworkAssignmentsWithSubject,
getHomeworkSubmissionsForStudents,
} from "@/modules/homework/data-access-classes"
import type {
ClassHomeworkInsights,
ClassHomeworkAssignmentStats,
@@ -23,6 +25,7 @@ import type {
GradeHomeworkInsights,
ScoreStats,
} from "./types"
import type { HomeworkSubmissionRecord } from "@/modules/homework/data-access-classes"
import {
getAccessibleClassIdsForTeacher,
getSessionTeacherId,
@@ -52,6 +55,74 @@ const toScoreStats = (scores: number[]): ScoreStats => {
}
}
const buildLatestSubmissionByKey = (
submissions: HomeworkSubmissionRecord[]
): Map<string, HomeworkSubmissionRecord> => {
const map = new Map<string, HomeworkSubmissionRecord>()
for (const s of submissions) {
const key = `${s.assignmentId}:${s.studentId}`
if (!map.has(key)) map.set(key, s)
}
return map
}
const computeAssignmentStats = (params: {
assignments: Array<{
id: string
title: string
status: string | null
createdAt: Date
dueAt: Date | null
subjectName?: string | null
}>
studentIds: string[]
latestByKey: Map<string, HomeworkSubmissionRecord>
maxScoreByAssignmentId: Map<string, number>
targetCountByAssignmentId: Map<string, number>
}): { stats: ClassHomeworkAssignmentStats[]; allScored: number[] } => {
const { assignments, studentIds, latestByKey, maxScoreByAssignmentId, targetCountByAssignmentId } = params
const allScored: number[] = []
const nowMs = Date.now()
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
let submittedCount = 0
let gradedCount = 0
const scores: number[] = []
const dueMs = a.dueAt ? a.dueAt.getTime() : null
for (const studentId of studentIds) {
const s = latestByKey.get(`${a.id}:${studentId}`)
if (!s) continue
const status = s.status ?? "started"
if (status === "submitted" || status === "graded") submittedCount += 1
if (status === "graded" || typeof s.score === "number") gradedCount += 1
if (typeof s.score === "number") scores.push(s.score)
}
allScored.push(...scores)
return {
assignmentId: a.id,
title: a.title,
status: a.status ?? "draft",
subject: a.subjectName ?? null,
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
targetCount,
submittedCount,
gradedCount,
scoreStats: toScoreStats(scores),
}
})
return { stats, allScored }
}
export const getClassHomeworkInsights = cache(
async (params: { classId: string; teacherId?: string; limit?: number }): Promise<ClassHomeworkInsights | null> => {
const teacherId = params.teacherId ?? (await getSessionTeacherId())
@@ -127,12 +198,7 @@ export const getClassHomeworkInsights = cache(
}
}
const assignmentIdRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
const assignmentIds = await getAssignmentIdsForStudents(studentIds)
if (assignmentIds.length === 0) {
return {
class: {
@@ -151,26 +217,11 @@ export const getClassHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignmentConditions: SQL[] = [inArray(homeworkAssignments.id, assignmentIds)]
if (subjectIdFilter.length > 0) {
assignmentConditions.push(inArray(exams.subjectId, subjectIdFilter))
}
const assignments = await db
.select({
id: homeworkAssignments.id,
title: homeworkAssignments.title,
status: homeworkAssignments.status,
createdAt: homeworkAssignments.createdAt,
dueAt: homeworkAssignments.dueAt,
subjectId: exams.subjectId,
subjectName: subjects.name
})
.from(homeworkAssignments)
.innerJoin(exams, eq(homeworkAssignments.sourceExamId, exams.id))
.leftJoin(subjects, eq(exams.subjectId, subjects.id))
.where(and(...assignmentConditions))
.orderBy(desc(homeworkAssignments.createdAt))
.limit(limit)
const assignments = await getHomeworkAssignmentsWithSubject({
assignmentIds,
subjectIdFilter: subjectIdFilter.length > 0 ? subjectIdFilter : undefined,
limit,
})
const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) {
@@ -190,86 +241,19 @@ export const getClassHomeworkInsights = cache(
}
}
const maxScoreRows = await db
.select({
assignmentId: homeworkAssignmentQuestions.assignmentId,
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
getAssignmentMaxScoreById(usedAssignmentIds),
getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
])
const maxScoreByAssignmentId = new Map<string, number>()
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
const targetCountRows = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(
and(
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
inArray(homeworkAssignmentTargets.studentId, studentIds)
)
)
.groupBy(homeworkAssignmentTargets.assignmentId)
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
inArray(homeworkSubmissions.studentId, studentIds)
),
orderBy: [desc(homeworkSubmissions.createdAt)],
})
const latestByKey = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const key = `${s.assignmentId}:${s.studentId}`
if (!latestByKey.has(key)) latestByKey.set(key, s)
}
const allScored: number[] = []
const nowMs = Date.now()
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
let submittedCount = 0
let gradedCount = 0
const scores: number[] = []
const dueMs = a.dueAt ? a.dueAt.getTime() : null
for (const studentId of studentIds) {
const s = latestByKey.get(`${a.id}:${studentId}`)
if (!s) continue
const status = (s.status ?? "started") as string
if (status === "submitted" || status === "graded") submittedCount += 1
if (status === "graded" || typeof s.score === "number") gradedCount += 1
if (typeof s.score === "number") scores.push(s.score)
}
allScored.push(...scores)
return {
assignmentId: a.id,
title: a.title,
status: (a.status as string) ?? "draft",
subject: a.subjectName,
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
targetCount,
submittedCount,
gradedCount,
scoreStats: toScoreStats(scores),
}
const latestByKey = buildLatestSubmissionByKey(submissions)
const { stats, allScored } = computeAssignmentStats({
assignments,
studentIds,
latestByKey,
maxScoreByAssignmentId,
targetCountByAssignmentId,
})
const overallScores = toScoreStats(allScored)
@@ -390,12 +374,7 @@ export const getGradeHomeworkInsights = cache(
}
}
const assignmentIdRows = await db
.selectDistinct({ assignmentId: homeworkAssignmentTargets.assignmentId })
.from(homeworkAssignmentTargets)
.where(inArray(homeworkAssignmentTargets.studentId, studentIds))
const assignmentIds = assignmentIdRows.map((r) => r.assignmentId)
const assignmentIds = await getAssignmentIdsForStudents(studentIds)
if (assignmentIds.length === 0) {
const summaries: GradeHomeworkClassSummary[] = classRows.map((c) => {
const bucket = studentsByClassId.get(c.id) ?? { all: new Set<string>(), active: new Set<string>() }
@@ -421,11 +400,7 @@ export const getGradeHomeworkInsights = cache(
}
const limit = typeof params.limit === "number" && params.limit > 0 ? params.limit : 50
const assignments = await db.query.homeworkAssignments.findMany({
where: inArray(homeworkAssignments.id, assignmentIds),
orderBy: [desc(homeworkAssignments.createdAt)],
limit,
})
const assignments = await getHomeworkAssignmentsByIds({ assignmentIds, limit })
const usedAssignmentIds = assignments.map((a) => a.id)
if (usedAssignmentIds.length === 0) {
@@ -452,85 +427,19 @@ export const getGradeHomeworkInsights = cache(
}
}
const maxScoreRows = await db
.select({
assignmentId: homeworkAssignmentQuestions.assignmentId,
maxScore: sql<number>`COALESCE(SUM(${homeworkAssignmentQuestions.score}), 0)`,
})
.from(homeworkAssignmentQuestions)
.where(inArray(homeworkAssignmentQuestions.assignmentId, usedAssignmentIds))
.groupBy(homeworkAssignmentQuestions.assignmentId)
const [maxScoreByAssignmentId, targetCountByAssignmentId, submissions] = await Promise.all([
getAssignmentMaxScoreById(usedAssignmentIds),
getAssignmentTargetCounts({ assignmentIds: usedAssignmentIds, studentIds }),
getHomeworkSubmissionsForStudents({ assignmentIds: usedAssignmentIds, studentIds }),
])
const maxScoreByAssignmentId = new Map<string, number>()
for (const r of maxScoreRows) maxScoreByAssignmentId.set(r.assignmentId, Number(r.maxScore ?? 0))
const targetCountRows = await db
.select({
assignmentId: homeworkAssignmentTargets.assignmentId,
targetCount: sql<number>`COUNT(*)`,
})
.from(homeworkAssignmentTargets)
.where(
and(
inArray(homeworkAssignmentTargets.assignmentId, usedAssignmentIds),
inArray(homeworkAssignmentTargets.studentId, studentIds)
)
)
.groupBy(homeworkAssignmentTargets.assignmentId)
const targetCountByAssignmentId = new Map<string, number>()
for (const r of targetCountRows) targetCountByAssignmentId.set(r.assignmentId, Number(r.targetCount ?? 0))
const submissions = await db.query.homeworkSubmissions.findMany({
where: and(
inArray(homeworkSubmissions.assignmentId, usedAssignmentIds),
inArray(homeworkSubmissions.studentId, studentIds)
),
orderBy: [desc(homeworkSubmissions.createdAt)],
})
const latestByKey = new Map<string, (typeof submissions)[number]>()
for (const s of submissions) {
const key = `${s.assignmentId}:${s.studentId}`
if (!latestByKey.has(key)) latestByKey.set(key, s)
}
const allScored: number[] = []
const nowMs = Date.now()
const stats: ClassHomeworkAssignmentStats[] = assignments.map((a) => {
const targetCount = targetCountByAssignmentId.get(a.id) ?? 0
let submittedCount = 0
let gradedCount = 0
const scores: number[] = []
const dueMs = a.dueAt ? a.dueAt.getTime() : null
for (const studentId of studentIds) {
const s = latestByKey.get(`${a.id}:${studentId}`)
if (!s) continue
const status = (s.status ?? "started") as string
if (status === "submitted" || status === "graded") submittedCount += 1
if (status === "graded" || typeof s.score === "number") gradedCount += 1
if (typeof s.score === "number") scores.push(s.score)
}
allScored.push(...scores)
return {
assignmentId: a.id,
title: a.title,
status: (a.status as string) ?? "draft",
createdAt: a.createdAt.toISOString(),
dueAt: a.dueAt ? a.dueAt.toISOString() : null,
isActive: dueMs === null || dueMs >= nowMs,
isOverdue: typeof dueMs === "number" && dueMs < nowMs,
maxScore: maxScoreByAssignmentId.get(a.id) ?? 0,
targetCount,
submittedCount,
gradedCount,
scoreStats: toScoreStats(scores),
}
const latestByKey = buildLatestSubmissionByKey(submissions)
const { stats, allScored } = computeAssignmentStats({
assignments,
studentIds,
latestByKey,
maxScoreByAssignmentId,
targetCountByAssignmentId,
})
const overallScores = toScoreStats(allScored)