refactor: P1-2 actions 层 DB 操作下沉到 data-access (exams/homework/questions/announcements)
This commit is contained in:
@@ -6,10 +6,19 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { z } from "zod"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions } from "@/shared/db/schema"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { buildExamDescription, omitScheduledAtFromDescription, persistAiGeneratedExamDraft, persistExamDraft, resolveSubjectGradeNames } from "./data-access"
|
||||
import {
|
||||
buildExamDescription,
|
||||
deleteExamById,
|
||||
duplicateExam,
|
||||
getExamCreatorId,
|
||||
getExamGrades,
|
||||
getExamPreview,
|
||||
getExamSubjects,
|
||||
persistAiGeneratedExamDraft,
|
||||
persistExamDraft,
|
||||
resolveSubjectGradeNames,
|
||||
updateExamWithQuestions,
|
||||
} from "./data-access"
|
||||
import {
|
||||
AiGeneratedStructureSchema,
|
||||
AiInsertQuestionSchema,
|
||||
@@ -568,39 +577,18 @@ export async function updateExamAction(
|
||||
|
||||
// Ownership check: non-admin users can only update their own exams
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
columns: { creatorId: true },
|
||||
})
|
||||
if (!exam || exam.creatorId !== ctx.userId) {
|
||||
const creatorId = await getExamCreatorId(examId)
|
||||
if (!creatorId || creatorId !== ctx.userId) {
|
||||
return failState<string>("You can only update exams you created")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (questions) {
|
||||
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
|
||||
if (questions.length > 0) {
|
||||
await db.insert(examQuestions).values(
|
||||
questions.map((q, idx) => ({
|
||||
examId,
|
||||
questionId: q.id,
|
||||
score: q.score ?? 0,
|
||||
order: idx,
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update object
|
||||
const updateData: Partial<typeof exams.$inferInsert> = {}
|
||||
if (status) updateData.status = status
|
||||
if (structure !== undefined) updateData.structure = structure
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db.update(exams).set(updateData).where(eq(exams.id, examId))
|
||||
}
|
||||
|
||||
await updateExamWithQuestions(examId, {
|
||||
questions: questions ?? undefined,
|
||||
structure,
|
||||
status,
|
||||
})
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to update exam")
|
||||
}
|
||||
@@ -642,17 +630,14 @@ export async function deleteExamAction(
|
||||
|
||||
// Ownership check: non-admin users can only delete their own exams
|
||||
if (ctx.dataScope.type !== "all") {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
columns: { creatorId: true },
|
||||
})
|
||||
if (!exam || exam.creatorId !== ctx.userId) {
|
||||
const creatorId = await getExamCreatorId(examId)
|
||||
if (!creatorId || creatorId !== ctx.userId) {
|
||||
return failState<string>("You can only delete exams you created")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
await deleteExamById(examId)
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to delete exam")
|
||||
}
|
||||
@@ -692,45 +677,13 @@ export async function duplicateExamAction(
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
const source = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!source) {
|
||||
return failState<string>("Exam not found")
|
||||
}
|
||||
|
||||
const newExamId = createId()
|
||||
|
||||
let newExamId: string
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(exams).values({
|
||||
id: newExamId,
|
||||
title: `${source.title} (Copy)`,
|
||||
description: omitScheduledAtFromDescription(source.description),
|
||||
creatorId: ctx.userId,
|
||||
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,
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
const duplicatedId = await duplicateExam(examId, ctx.userId)
|
||||
if (!duplicatedId) {
|
||||
return failState<string>("Exam not found")
|
||||
}
|
||||
newExamId = duplicatedId
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to duplicate exam")
|
||||
}
|
||||
@@ -753,25 +706,14 @@ export async function getExamPreviewAction(
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
|
||||
try {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
with: {
|
||||
question: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const exam = await getExamPreview(examId)
|
||||
|
||||
if (!exam) {
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
|
||||
}
|
||||
const questions = exam.questions.map((eq) => eq.question)
|
||||
return successState({
|
||||
structure: exam.structure,
|
||||
questions,
|
||||
questions: exam.questions,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -790,11 +732,8 @@ export async function getSubjectsAction(): Promise<ActionState<{ id: string; nam
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
|
||||
try {
|
||||
const allSubjects = await db.query.subjects.findMany({
|
||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
|
||||
return successState(allSubjects.map((s) => ({ id: s.id, name: s.name })))
|
||||
const allSubjects = await getExamSubjects()
|
||||
return successState(allSubjects)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch subjects:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
||||
@@ -812,11 +751,8 @@ export async function getGradesAction(): Promise<ActionState<{ id: string; name:
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
|
||||
try {
|
||||
const allGrades = await db.query.grades.findMany({
|
||||
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
||||
})
|
||||
|
||||
return successState(allGrades.map((g) => ({ id: g.id, name: g.name })))
|
||||
const allGrades = await getExamGrades()
|
||||
return successState(allGrades)
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch grades:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, questions, subjects, grades, classes } 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 type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||
@@ -371,3 +372,153 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
|
||||
|
||||
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 }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user