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.
725 lines
21 KiB
TypeScript
725 lines
21 KiB
TypeScript
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
|
||
}
|