feat: exam actions and data safety fixes

This commit is contained in:
SpecialX
2025-12-30 17:48:22 +08:00
parent e7c902e8e1
commit f7ff018490
27 changed files with 896 additions and 194 deletions

View File

@@ -1,9 +1,9 @@
import { db } from "@/shared/db"
import { exams, examQuestions, examSubmissions, submissionAnswers, users } from "@/shared/db/schema"
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
import { eq, desc, like, and, or } from "drizzle-orm"
import { cache } from "react"
import type { ExamStatus } from "./types"
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
export type GetExamsParams = {
q?: string
@@ -13,6 +13,40 @@ export type GetExamsParams = {
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 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) => {
const conditions = []
@@ -23,7 +57,7 @@ export const getExams = cache(async (params: GetExamsParams) => {
}
if (params.status && params.status !== "all") {
conditions.push(eq(exams.status, params.status as any))
conditions.push(eq(exams.status, params.status))
}
// Note: Difficulty is stored in JSON description field in current schema,
@@ -37,25 +71,23 @@ export const getExams = cache(async (params: GetExamsParams) => {
})
// Transform and Filter (especially for JSON fields)
let result = data.map((exam) => {
let meta: any = {}
try {
meta = JSON.parse(exam.description || "{}")
} catch { }
let result: Exam[] = data.map((exam) => {
const meta = parseExamMeta(exam.description || null)
return {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
subject: meta.subject || "General",
grade: meta.grade || "General",
difficulty: meta.difficulty || 1,
totalScore: meta.totalScore || 100,
durationMin: meta.durationMin || 60,
questionCount: meta.questionCount || 0,
subject: getString(meta, "subject") || "General",
grade: 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(),
tags: meta.tags || [],
updatedAt: exam.updatedAt?.toISOString(),
tags: getStringArray(meta, "tags") || [],
}
})
@@ -82,30 +114,27 @@ export const getExamById = cache(async (id: string) => {
if (!exam) return null
let meta: any = {}
try {
meta = JSON.parse(exam.description || "{}")
} catch { }
const meta = parseExamMeta(exam.description || null)
return {
id: exam.id,
title: exam.title,
status: (exam.status as ExamStatus) || "draft",
subject: meta.subject || "General",
grade: meta.grade || "General",
difficulty: meta.difficulty || 1,
totalScore: meta.totalScore || 100,
durationMin: meta.durationMin || 60,
subject: getString(meta, "subject") || "General",
grade: 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(),
tags: meta.tags || [],
structure: exam.structure as any, // Return structure
questions: exam.questions.map(eq => ({
id: eq.questionId,
score: eq.score,
order: eq.order,
// ... include question details if needed
}))
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,
})),
}
})
@@ -154,13 +183,20 @@ export const getSubmissionDetails = cache(async (submissionId: string) => {
orderBy: [desc(examQuestions.order)],
})
type QuestionContent = { text?: string } & Record<string, unknown>
const toQuestionContent = (v: unknown): QuestionContent | null => {
if (!isRecord(v)) return null
return v as QuestionContent
}
// Map answers with question details
const answersWithDetails = answers.map(ans => {
const eqRel = examQ.find(q => q.questionId === ans.questionId)
return {
id: ans.id,
questionId: ans.questionId,
questionContent: ans.question.content,
questionContent: toQuestionContent(ans.question.content),
questionType: ans.question.type,
maxScore: eqRel?.score || 0,
studentAnswer: ans.answerContent,