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,8 +1,10 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers, subjects, grades } from "@/shared/db/schema"
|
||||
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { createQuestionWithRelations } from "@/modules/questions/data-access"
|
||||
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
|
||||
|
||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||
@@ -45,6 +47,12 @@ const getStringArray = (obj: Record<string, unknown>, key: string): string[] | u
|
||||
return items.length === v.length ? items : undefined
|
||||
}
|
||||
|
||||
const isExamStatus = (v: unknown): v is ExamStatus =>
|
||||
v === "draft" || v === "published" || v === "archived"
|
||||
|
||||
const toExamStatus = (v: string | null | undefined): ExamStatus =>
|
||||
isExamStatus(v) ? v : "draft"
|
||||
|
||||
const toExamDifficulty = (n: number | undefined): ExamDifficulty => {
|
||||
if (n === 1 || n === 2 || n === 3 || n === 4 || n === 5) return n
|
||||
return 1
|
||||
@@ -69,11 +77,8 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
}
|
||||
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
|
||||
// Teacher can see exams for grades their classes belong to
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, params.scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(params.scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
}
|
||||
@@ -105,7 +110,7 @@ export const getExams = cache(async (params: GetExamsParams & { scope: DataScope
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
status: toExamStatus(exam.status),
|
||||
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
@@ -153,11 +158,8 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
return null
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map(g => g.gradeId).filter(Boolean) as string[]
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) {
|
||||
return null
|
||||
}
|
||||
@@ -169,7 +171,7 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
status: (exam.status as ExamStatus) || "draft",
|
||||
status: toExamStatus(exam.status),
|
||||
subject: exam.subject?.name ?? getString(meta, "subject") ?? "General",
|
||||
grade: exam.gradeEntity?.name ?? getString(meta, "grade") ?? "General",
|
||||
difficulty: toExamDifficulty(getNumber(meta, "difficulty")),
|
||||
@@ -191,9 +193,9 @@ export const getExamById = cache(async (id: string, scope?: DataScope) => {
|
||||
export const omitScheduledAtFromDescription = (description: string | null): string => {
|
||||
if (!description) return "{}"
|
||||
try {
|
||||
const meta = JSON.parse(description)
|
||||
if (typeof meta === "object" && meta !== null) {
|
||||
const rest = { ...(meta as Record<string, unknown>) }
|
||||
const parsed: unknown = JSON.parse(description)
|
||||
if (isRecord(parsed)) {
|
||||
const rest = { ...parsed }
|
||||
delete rest.scheduledAt
|
||||
return JSON.stringify(rest)
|
||||
}
|
||||
@@ -299,8 +301,31 @@ export const persistAiGeneratedExamDraft = async (input: {
|
||||
description: string
|
||||
structure: AiGeneratedStructureNode[]
|
||||
generated: AiGeneratedQuestion[]
|
||||
}) => {
|
||||
}): Promise<void> => {
|
||||
const orderedQuestions = buildOrderedQuestionsFromStructure(input.structure, input.generated)
|
||||
|
||||
// P0-1 fix: create questions via questions module data-access instead of direct table insert.
|
||||
// createQuestionWithRelations generates new IDs, so we remap structure references accordingly.
|
||||
const questionIdMapping = new Map<string, string>()
|
||||
for (const q of input.generated) {
|
||||
const newQuestionId = await createQuestionWithRelations(
|
||||
{
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
},
|
||||
input.creatorId
|
||||
)
|
||||
questionIdMapping.set(q.id, newQuestionId)
|
||||
}
|
||||
|
||||
const remappedOrderedQuestions = orderedQuestions
|
||||
.map((q) => {
|
||||
const mappedId = questionIdMapping.get(q.id)
|
||||
return mappedId ? { id: mappedId, score: q.score } : null
|
||||
})
|
||||
.filter((q): q is { id: string; score: number } => q !== null)
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(exams).values({
|
||||
id: input.examId,
|
||||
@@ -314,21 +339,9 @@ export const persistAiGeneratedExamDraft = async (input: {
|
||||
structure: input.structure,
|
||||
})
|
||||
|
||||
if (input.generated.length > 0) {
|
||||
await tx.insert(questions).values(
|
||||
input.generated.map((q) => ({
|
||||
id: q.id,
|
||||
content: q.content,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
authorId: input.creatorId,
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
if (orderedQuestions.length > 0) {
|
||||
if (remappedOrderedQuestions.length > 0) {
|
||||
await tx.insert(examQuestions).values(
|
||||
orderedQuestions.map((q, idx) => ({
|
||||
remappedOrderedQuestions.map((q, idx) => ({
|
||||
examId: input.examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
@@ -354,11 +367,8 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
|
||||
conditions.push(inArray(exams.gradeId, scope.gradeIds))
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const teacherGradeIds = await db
|
||||
.selectDistinct({ gradeId: classes.gradeId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, scope.classIds))
|
||||
const gradeIds = teacherGradeIds.map((g) => g.gradeId).filter(Boolean) as string[]
|
||||
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
|
||||
const gradeIds = Array.from(new Set(classGradeMap.values()))
|
||||
if (gradeIds.length > 0) {
|
||||
conditions.push(inArray(exams.gradeId, gradeIds))
|
||||
}
|
||||
@@ -522,3 +532,193 @@ export const getExamGrades = async (): Promise<Array<{ id: string; name: string
|
||||
})
|
||||
return allGrades.map((g) => ({ id: g.id, name: g.name }))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-module query interfaces (供其他模块调用,避免直查 exams/examSubmissions 表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取指定年级 ID 列表对应的所有考试 ID。
|
||||
* 供 homework/grades 等模块跨模块调用使用。
|
||||
*/
|
||||
export const getExamIdsByGradeIds = async (gradeIds: string[]): Promise<string[]> => {
|
||||
if (gradeIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({ id: exams.id })
|
||||
.from(exams)
|
||||
.where(inArray(exams.gradeId, gradeIds))
|
||||
return rows.map((r) => r.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考试的基本信息(含题目列表),供 homework 模块创建作业时使用。
|
||||
* 返回的数据包含 examId、title、subjectId、structure 和题目列表。
|
||||
*/
|
||||
export type ExamWithQuestionsForHomework = {
|
||||
id: string
|
||||
title: string
|
||||
subjectId: string | null
|
||||
structure: unknown
|
||||
questions: Array<{ questionId: string; score: number | null; order: number | null }>
|
||||
}
|
||||
|
||||
export const getExamWithQuestionsForHomework = async (
|
||||
examId: string
|
||||
): Promise<ExamWithQuestionsForHomework | null> => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!exam) return null
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
subjectId: exam.subjectId,
|
||||
structure: exam.structure,
|
||||
questions: exam.questions.map((q) => ({
|
||||
questionId: q.questionId,
|
||||
score: q.score ?? null,
|
||||
order: q.order ?? null,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个考试的 subjectId 映射(examId -> subjectId)。
|
||||
* 供 homework 模块查询作业对应科目时使用。
|
||||
*/
|
||||
export const getExamSubjectIdMap = async (examIds: string[]): Promise<Map<string, string | null>> => {
|
||||
if (examIds.length === 0) return new Map()
|
||||
const rows = await db
|
||||
.select({ id: exams.id, subjectId: exams.subjectId })
|
||||
.from(exams)
|
||||
.where(inArray(exams.id, examIds))
|
||||
const map = new Map<string, string | null>()
|
||||
for (const r of rows) map.set(r.id, r.subjectId)
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考试标题。
|
||||
* 供 proctoring 等模块跨模块调用使用。
|
||||
*/
|
||||
export const getExamTitleById = async (examId: string): Promise<string | null> => {
|
||||
const [row] = await db
|
||||
.select({ title: exams.title })
|
||||
.from(exams)
|
||||
.where(eq(exams.id, examId))
|
||||
.limit(1)
|
||||
return row?.title ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考试的基本信息(含监考模式相关字段),供 proctoring 模块使用。
|
||||
*/
|
||||
export type ExamForProctoring = {
|
||||
id: string
|
||||
title: string
|
||||
examMode: string | null
|
||||
durationMinutes: number | null
|
||||
shuffleQuestions: boolean | null
|
||||
allowLateStart: boolean | null
|
||||
lateStartGraceMinutes: number | null
|
||||
antiCheatEnabled: boolean | null
|
||||
}
|
||||
|
||||
export const getExamForProctoringCrossModule = async (examId: string): Promise<ExamForProctoring | null> => {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
})
|
||||
if (!exam) return null
|
||||
return {
|
||||
id: exam.id,
|
||||
title: exam.title,
|
||||
examMode: exam.examMode,
|
||||
durationMinutes: exam.durationMinutes ?? null,
|
||||
shuffleQuestions: exam.shuffleQuestions ?? false,
|
||||
allowLateStart: exam.allowLateStart ?? false,
|
||||
lateStartGraceMinutes: exam.lateStartGraceMinutes ?? 0,
|
||||
antiCheatEnabled: exam.antiCheatEnabled ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验提交记录归属(监考事件上报前的安全校验)。
|
||||
* 供 proctoring 模块跨模块调用使用。
|
||||
*/
|
||||
export const getExamSubmissionForProctoringCrossModule = async (
|
||||
submissionId: string,
|
||||
studentId: string
|
||||
): Promise<{ id: string; examId: string; studentId: string } | null> => {
|
||||
const submission = await db.query.examSubmissions.findFirst({
|
||||
where: and(
|
||||
eq(examSubmissions.id, submissionId),
|
||||
eq(examSubmissions.studentId, studentId),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
examId: true,
|
||||
studentId: true,
|
||||
},
|
||||
})
|
||||
return submission ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取考试提交记录及其答题数据,供 diagnostic 模块更新知识点掌握度使用。
|
||||
*/
|
||||
export type ExamSubmissionWithAnswers = {
|
||||
studentId: string
|
||||
answers: Array<{ questionId: string; score: number | null }>
|
||||
}
|
||||
|
||||
export const getExamSubmissionWithAnswers = async (
|
||||
submissionId: string
|
||||
): Promise<ExamSubmissionWithAnswers | null> => {
|
||||
const [submission] = await db
|
||||
.select({ studentId: examSubmissions.studentId })
|
||||
.from(examSubmissions)
|
||||
.where(eq(examSubmissions.id, submissionId))
|
||||
.limit(1)
|
||||
if (!submission) return null
|
||||
|
||||
const answers = await db
|
||||
.select({
|
||||
questionId: submissionAnswers.questionId,
|
||||
score: submissionAnswers.score,
|
||||
})
|
||||
.from(submissionAnswers)
|
||||
.where(eq(submissionAnswers.submissionId, submissionId))
|
||||
|
||||
return {
|
||||
studentId: submission.studentId,
|
||||
answers,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一场考试的所有提交记录(含学生 ID 和状态),供 proctoring 模块使用。
|
||||
*/
|
||||
export type ExamSubmissionForProctoringSummary = {
|
||||
id: string
|
||||
studentId: string
|
||||
status: string | null
|
||||
}
|
||||
|
||||
export const getExamSubmissionsForExam = async (
|
||||
examId: string
|
||||
): Promise<ExamSubmissionForProctoringSummary[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: examSubmissions.id,
|
||||
studentId: examSubmissions.studentId,
|
||||
status: examSubmissions.status,
|
||||
})
|
||||
.from(examSubmissions)
|
||||
.where(eq(examSubmissions.examId, examId))
|
||||
return rows
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user