219 lines
6.6 KiB
TypeScript
219 lines
6.6 KiB
TypeScript
import { db } from "@/shared/db"
|
|
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
|
|
import { eq, desc, like, and, or } from "drizzle-orm"
|
|
import { cache } from "react"
|
|
|
|
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
|
|
|
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 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 = []
|
|
|
|
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))
|
|
}
|
|
|
|
// 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)],
|
|
})
|
|
|
|
// 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: (exam.status as ExamStatus) || "draft",
|
|
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(),
|
|
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) => {
|
|
const exam = await db.query.exams.findFirst({
|
|
where: eq(exams.id, id),
|
|
with: {
|
|
questions: {
|
|
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
|
with: {
|
|
question: true
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
if (!exam) return null
|
|
|
|
const meta = parseExamMeta(exam.description || null)
|
|
|
|
return {
|
|
id: exam.id,
|
|
title: exam.title,
|
|
status: (exam.status as ExamStatus) || "draft",
|
|
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(),
|
|
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 getExamSubmissions = cache(async () => {
|
|
const data = await db.query.examSubmissions.findMany({
|
|
orderBy: [desc(examSubmissions.submittedAt)],
|
|
with: {
|
|
exam: true,
|
|
student: true
|
|
}
|
|
})
|
|
|
|
return data.map(sub => ({
|
|
id: sub.id,
|
|
examId: sub.examId,
|
|
examTitle: sub.exam.title,
|
|
studentName: sub.student.name || "Unknown",
|
|
submittedAt: sub.submittedAt ? sub.submittedAt.toISOString() : new Date().toISOString(),
|
|
score: sub.score || undefined,
|
|
status: sub.status as "pending" | "graded",
|
|
}))
|
|
})
|
|
|
|
export const getSubmissionDetails = cache(async (submissionId: string) => {
|
|
const submission = await db.query.examSubmissions.findFirst({
|
|
where: eq(examSubmissions.id, submissionId),
|
|
with: {
|
|
student: true,
|
|
exam: true,
|
|
}
|
|
})
|
|
|
|
if (!submission) return null
|
|
|
|
// Fetch answers
|
|
const answers = await db.query.submissionAnswers.findMany({
|
|
where: eq(submissionAnswers.submissionId, submissionId),
|
|
with: {
|
|
question: true
|
|
}
|
|
})
|
|
|
|
// Fetch exam questions structure (to know max score and order)
|
|
const examQ = await db.query.examQuestions.findMany({
|
|
where: eq(examQuestions.examId, submission.examId),
|
|
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: toQuestionContent(ans.question.content),
|
|
questionType: ans.question.type,
|
|
maxScore: eqRel?.score || 0,
|
|
studentAnswer: ans.answerContent,
|
|
score: ans.score,
|
|
feedback: ans.feedback,
|
|
order: eqRel?.order || 0
|
|
}
|
|
}).sort((a, b) => a.order - b.order)
|
|
|
|
return {
|
|
id: submission.id,
|
|
studentName: submission.student.name || "Unknown",
|
|
examTitle: submission.exam.title,
|
|
submittedAt: submission.submittedAt ? submission.submittedAt.toISOString() : null,
|
|
status: submission.status,
|
|
totalScore: submission.score,
|
|
answers: answersWithDetails
|
|
}
|
|
})
|