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 => typeof v === "object" && v !== null const parseExamMeta = (description: string | null): Record => { if (!description) return {} try { const parsed: unknown = JSON.parse(description) return isRecord(parsed) ? parsed : {} } catch { return {} } } const getString = (obj: Record, key: string): string | undefined => { const v = obj[key] return typeof v === "string" ? v : undefined } const getNumber = (obj: Record, key: string): number | undefined => { const v = obj[key] return typeof v === "number" ? v : undefined } const getStringArray = (obj: Record, 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 => { 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() 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 => { 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 => { 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 => { 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 = {} 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 => { 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 => { 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> => { 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> => { 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 => { 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 => { 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> => { 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() for (const r of rows) map.set(r.id, r.subjectId) return map } /** * 获取考试标题。 * 供 proctoring 等模块跨模块调用使用。 */ export const getExamTitleById = async (examId: string): Promise => { 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 => { 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 => { 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 => { const rows = await db .select({ id: examSubmissions.id, studentId: examSubmissions.studentId, status: examSubmissions.status, }) .from(examSubmissions) .where(eq(examSubmissions.examId, examId)) return rows }