Files
NextEdu/src/modules/exams/data-access.ts
SpecialX 49291fcc31 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.
2026-06-19 05:13:34 +08:00

725 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { db } from "@/shared/db"
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"
import type { DataScope } from "@/shared/types/permissions"
export type GetExamsParams = {
q?: string
status?: string
difficulty?: string
page?: number
pageSize?: number
}
const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null
const parseExamMeta = (description: string | null): Record<string, unknown> => {
if (!description) return {}
try {
const parsed: unknown = JSON.parse(description)
return isRecord(parsed) ? parsed : {}
} catch {
return {}
}
}
const getString = (obj: Record<string, unknown>, key: string): string | undefined => {
const v = obj[key]
return typeof v === "string" ? v : undefined
}
const getNumber = (obj: Record<string, unknown>, key: string): number | undefined => {
const v = obj[key]
return typeof v === "number" ? v : undefined
}
const getStringArray = (obj: Record<string, unknown>, key: string): string[] | undefined => {
const v = obj[key]
if (!Array.isArray(v)) return undefined
const items = v.filter((x): x is string => typeof x === "string")
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
}
export const getExams = cache(async (params: GetExamsParams & { scope: DataScope }) => {
const conditions = []
if (params.q) {
const search = `%${params.q}%`
conditions.push(or(like(exams.title, search), like(exams.description, search)))
}
if (params.status && params.status !== "all") {
conditions.push(eq(exams.status, params.status))
}
// Data scope filtering
if (params.scope.type === "owned") {
conditions.push(eq(exams.creatorId, params.scope.userId))
}
if (params.scope.type === "class_taught" && params.scope.classIds.length > 0) {
// Teacher can see exams for grades their classes belong to
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))
}
}
if (params.scope.type === "grade_managed" && params.scope.gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, params.scope.gradeIds))
}
// "all" type: no filtering
// "class_members": student sees published exams for their grade (would need student's gradeId)
// Note: Difficulty is stored in JSON description field in current schema,
// so we might need to filter in memory or adjust schema.
// For now, let's fetch and filter in memory if difficulty is needed,
// or just ignore strict DB filtering for JSON fields to keep it simple.
const data = await db.query.exams.findMany({
where: conditions.length ? and(...conditions) : undefined,
orderBy: [desc(exams.createdAt)],
with: {
subject: true,
gradeEntity: true,
}
})
// Transform and Filter (especially for JSON fields)
let result: Exam[] = data.map((exam) => {
const meta = parseExamMeta(exam.description || null)
return {
id: exam.id,
title: exam.title,
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")),
totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(meta, "durationMin") || 60,
questionCount: getNumber(meta, "questionCount") || 0,
scheduledAt: exam.startTime?.toISOString(),
createdAt: exam.createdAt.toISOString(),
updatedAt: exam.updatedAt?.toISOString(),
tags: getStringArray(meta, "tags") || [],
}
})
if (params.difficulty && params.difficulty !== "all") {
const d = parseInt(params.difficulty)
result = result.filter((e) => e.difficulty === d)
}
return result
})
export const getExamById = cache(async (id: string, scope?: DataScope) => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, id),
with: {
subject: true,
gradeEntity: true,
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
with: {
question: true
}
}
}
})
if (!exam) return null
// Data scope verification for single-item fetch
if (scope && scope.type !== "all") {
if (scope.type === "owned" && exam.creatorId !== scope.userId) {
return null
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0 && !scope.gradeIds.includes(exam.gradeId ?? "")) {
return null
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length > 0 && !gradeIds.includes(exam.gradeId ?? "")) {
return null
}
}
}
const meta = parseExamMeta(exam.description || null)
return {
id: exam.id,
title: exam.title,
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")),
totalScore: getNumber(meta, "totalScore") || 100,
durationMin: getNumber(meta, "durationMin") || 60,
scheduledAt: exam.startTime?.toISOString(),
createdAt: exam.createdAt.toISOString(),
updatedAt: exam.updatedAt?.toISOString(),
tags: getStringArray(meta, "tags") || [],
structure: exam.structure as unknown,
questions: exam.questions.map((eqRel) => ({
id: eqRel.questionId,
score: eqRel.score ?? 0,
order: eqRel.order ?? 0,
})),
}
})
export const omitScheduledAtFromDescription = (description: string | null): string => {
if (!description) return "{}"
try {
const parsed: unknown = JSON.parse(description)
if (isRecord(parsed)) {
const rest = { ...parsed }
delete rest.scheduledAt
return JSON.stringify(rest)
}
return description
} catch {
return description || "{}"
}
}
export const resolveSubjectGradeNames = async (input: {
subjectId?: string
gradeId?: string
}) => {
const [subjectRecord, gradeRecord] = await Promise.all([
input.subjectId
? db.query.subjects.findFirst({
where: eq(subjects.id, input.subjectId),
})
: Promise.resolve(null),
input.gradeId
? db.query.grades.findFirst({
where: eq(grades.id, input.gradeId),
})
: Promise.resolve(null),
])
return {
subjectName: subjectRecord?.name,
gradeName: gradeRecord?.name,
}
}
export const buildExamDescription = (input: {
subject: string
grade: string
difficulty: number
totalScore: number
durationMin: number
scheduledAt?: string
questionCount?: number
}) => JSON.stringify({
subject: input.subject,
grade: input.grade,
difficulty: input.difficulty,
totalScore: input.totalScore,
durationMin: input.durationMin,
scheduledAt: input.scheduledAt,
questionCount: input.questionCount,
})
export const persistExamDraft = async (input: {
examId: string
title: string
creatorId: string
subjectId: string
gradeId: string
scheduledAt?: string
description: string
}) => {
await db.insert(exams).values({
id: input.examId,
title: input.title,
description: input.description,
creatorId: input.creatorId,
subjectId: input.subjectId,
gradeId: input.gradeId,
startTime: input.scheduledAt ? new Date(input.scheduledAt) : null,
status: "draft",
})
}
const buildOrderedQuestionsFromStructure = (
structure: AiGeneratedStructureNode[],
generated: AiGeneratedQuestion[]
) => {
const questionById = new Map(generated.map((q) => [q.id, q] as const))
const orderedQuestions: Array<{ id: string; score: number }> = []
const collectOrder = (nodes: AiGeneratedStructureNode[]) => {
for (const node of nodes) {
if (node.type === "question" && typeof node.questionId === "string" && node.questionId) {
const score = typeof node.score === "number" ? node.score : questionById.get(node.questionId)?.score ?? 0
orderedQuestions.push({ id: node.questionId, score })
continue
}
if (node.type === "group" && Array.isArray(node.children) && node.children.length > 0) {
collectOrder(node.children)
}
}
}
collectOrder(structure)
if (orderedQuestions.length === 0) {
return generated.map((q) => ({ id: q.id, score: q.score ?? 0 }))
}
return orderedQuestions
}
export const persistAiGeneratedExamDraft = async (input: {
examId: string
title: string
creatorId: string
subjectId: string
gradeId: string
scheduledAt?: string
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,
title: input.title,
description: input.description,
creatorId: input.creatorId,
subjectId: input.subjectId,
gradeId: input.gradeId,
startTime: input.scheduledAt ? new Date(input.scheduledAt) : null,
status: "draft",
structure: input.structure,
})
if (remappedOrderedQuestions.length > 0) {
await tx.insert(examQuestions).values(
remappedOrderedQuestions.map((q, idx) => ({
examId: input.examId,
questionId: q.id,
score: q.score ?? 0,
order: idx,
}))
)
}
})
}
export type ExamsDashboardStats = {
examCount: number
}
export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<ExamsDashboardStats> => {
const conditions = []
if (scope && scope.type !== "all") {
if (scope.type === "owned") {
conditions.push(eq(exams.creatorId, scope.userId))
}
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, scope.gradeIds))
}
if (scope.type === "class_taught" && scope.classIds.length > 0) {
const classGradeMap = await getClassGradeIdsByClassIds(scope.classIds)
const gradeIds = Array.from(new Set(classGradeMap.values()))
if (gradeIds.length > 0) {
conditions.push(inArray(exams.gradeId, gradeIds))
}
}
}
const [row] = await db
.select({ value: count() })
.from(exams)
.where(conditions.length ? and(...conditions) : undefined)
return { examCount: Number(row?.value ?? 0) }
})
/**
* Get exam creator ID for ownership check.
* Returns null if exam not found.
*/
export const getExamCreatorId = async (examId: string): Promise<string | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
columns: { creatorId: true },
})
return exam?.creatorId ?? null
}
/**
* Update an exam, optionally replacing its questions.
* Preserves original behavior: questions replacement is not transactional with exam field update.
*/
export const updateExamWithQuestions = async (
examId: string,
data: {
questions?: Array<{ id: string; score: number }>
structure?: unknown
status?: ExamStatus
}
): Promise<void> => {
if (data.questions) {
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
if (data.questions.length > 0) {
await db.insert(examQuestions).values(
data.questions.map((q, idx) => ({
examId,
questionId: q.id,
score: q.score ?? 0,
order: idx,
}))
)
}
}
const updateData: Partial<typeof exams.$inferInsert> = {}
if (data.status) updateData.status = data.status
if (data.structure !== undefined) updateData.structure = data.structure
if (Object.keys(updateData).length > 0) {
await db.update(exams).set(updateData).where(eq(exams.id, examId))
}
}
/**
* Delete an exam by ID.
*/
export const deleteExamById = async (examId: string): Promise<void> => {
await db.delete(exams).where(eq(exams.id, examId))
}
/**
* Duplicate an exam (including its questions) in a transaction.
* Returns the new exam ID, or null if the source exam is not found.
*/
export const duplicateExam = async (
sourceExamId: string,
newCreatorId: string
): Promise<string | null> => {
const source = await db.query.exams.findFirst({
where: eq(exams.id, sourceExamId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
},
},
})
if (!source) return null
const newExamId = createId()
await db.transaction(async (tx) => {
await tx.insert(exams).values({
id: newExamId,
title: `${source.title} (Copy)`,
description: omitScheduledAtFromDescription(source.description),
creatorId: newCreatorId,
startTime: null,
endTime: null,
status: "draft",
structure: source.structure,
})
if (source.questions.length > 0) {
await tx.insert(examQuestions).values(
source.questions.map((q) => ({
examId: newExamId,
questionId: q.questionId,
score: q.score ?? 0,
order: q.order ?? 0,
}))
)
}
})
return newExamId
}
/**
* Get exam preview data (structure + questions).
* Returns null if exam not found.
*/
export const getExamPreview = async (
examId: string
): Promise<{ structure: unknown; questions: Array<{ id: string }> } | null> => {
const exam = await db.query.exams.findFirst({
where: eq(exams.id, examId),
with: {
questions: {
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
with: {
question: true,
},
},
},
})
if (!exam) return null
const questions = exam.questions.map((eqRel) => eqRel.question)
return {
structure: exam.structure,
questions,
}
}
/**
* Get all subjects for exam forms.
*/
export const getExamSubjects = async (): Promise<Array<{ id: string; name: string }>> => {
const allSubjects = await db.query.subjects.findMany({
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
})
return allSubjects.map((s) => ({ id: s.id, name: s.name }))
}
/**
* Get all grades for exam forms.
*/
export const getExamGrades = async (): Promise<Array<{ id: string; name: string }>> => {
const allGrades = await db.query.grades.findMany({
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
})
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
}