refactor: RBAC权限系统重构 + UI组件拆分 + 测试修复 + 架构文档
Some checks failed
CI / build-deploy (push) Has been cancelled
Some checks failed
CI / build-deploy (push) Has been cancelled
- RBAC: 新增30个权限点、DataScope行级权限、requirePermission守卫,所有57+ Server Action接入权限校验 - UI拆分: exam-form(1623行→11文件)、textbook-reader(744行→7文件),均降至300行以内 - 测试: 新增5个单元测试文件(19用例),修复4个集成测试文件(38用例全部通过) - 架构文档: 新增架构影响地图(004/005)、标准功能清单(006)、差距审计报告(007) - 项目规则: 架构图优先规则,改码必同步图 - 安全: rehype-sanitize净化、AES加密API Key、权限路由守卫 - 无障碍: skip-link、aria-label、prefers-reduced-motion - 性能: next/font优化、next/image、代码分割
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { ActionState } from "@/shared/types/action-state"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { z } from "zod"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { db } from "@/shared/db"
|
||||
@@ -253,53 +255,61 @@ export async function createExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
|
||||
const parsed = ExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, { useFirstMessage: false })
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
const context = await prepareExamCreateContext({
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: input.scheduledAt,
|
||||
})
|
||||
const description = context.buildDescription()
|
||||
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
await persistExamDraft({
|
||||
examId: context.examId,
|
||||
title: input.title,
|
||||
creatorId: user?.id ?? "user_teacher_math",
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description,
|
||||
const ctx = await requirePermission(Permissions.EXAM_CREATE)
|
||||
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
|
||||
const parsed = ExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, { useFirstMessage: false })
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
const context = await prepareExamCreateContext({
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: input.scheduledAt,
|
||||
})
|
||||
const description = context.buildDescription()
|
||||
|
||||
try {
|
||||
await persistExamDraft({
|
||||
examId: context.examId,
|
||||
title: input.title,
|
||||
creatorId: ctx.userId,
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(context.examId, "Exam created successfully.")
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(context.examId, "Exam created successfully.")
|
||||
}
|
||||
|
||||
const AiExamCreateSchema = ExamCreateSchema.extend({
|
||||
@@ -324,167 +334,193 @@ export async function createAiExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
|
||||
const rawStructure = formData.get("structureJson") as string | null
|
||||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||||
|
||||
const parsed = AiExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
|
||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||
? aiQuestionCountRaw
|
||||
: undefined,
|
||||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||||
? aiProviderIdRaw
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error)
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
if (!rawAiQuestions && !input.aiSourceText) {
|
||||
return failState<string>("Please analyze and preview before creating")
|
||||
}
|
||||
const context = await prepareExamCreateContext({
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: input.scheduledAt,
|
||||
})
|
||||
|
||||
const user = await getCurrentUser()
|
||||
const aiDraftResult = await loadAiDraftQuestionsAndStructure({
|
||||
rawAiQuestions,
|
||||
rawStructure,
|
||||
title: input.title,
|
||||
subject: context.subjectName,
|
||||
grade: context.gradeName,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
aiSourceText: input.aiSourceText,
|
||||
aiQuestionCount: input.aiQuestionCount,
|
||||
aiProviderId: input.aiProviderId,
|
||||
})
|
||||
if (!aiDraftResult.ok) {
|
||||
return failState<string>(aiDraftResult.message)
|
||||
}
|
||||
const { generated, structure } = aiDraftResult
|
||||
|
||||
const questionCount = generated.length
|
||||
const description = context.buildDescription({ questionCount })
|
||||
|
||||
try {
|
||||
await persistAiGeneratedExamDraft({
|
||||
examId: context.examId,
|
||||
title: input.title,
|
||||
creatorId: user?.id ?? "user_teacher_math",
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description,
|
||||
structure,
|
||||
generated,
|
||||
const ctx = await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||||
|
||||
const rawQuestions = formData.get("questionsJson") as string | null
|
||||
const rawAiQuestions = formData.get("aiQuestionsJson") as string | null
|
||||
const rawStructure = formData.get("structureJson") as string | null
|
||||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||||
|
||||
const parsed = AiExamCreateSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
scheduledAt: getStringValue(formData, "scheduledAt") ?? null,
|
||||
questions: rawQuestions ? JSON.parse(rawQuestions) : [],
|
||||
aiSourceText: typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : undefined,
|
||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||
? aiQuestionCountRaw
|
||||
: undefined,
|
||||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||||
? aiProviderIdRaw
|
||||
: undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error)
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
if (!rawAiQuestions && !input.aiSourceText) {
|
||||
return failState<string>("Please analyze and preview before creating")
|
||||
}
|
||||
const context = await prepareExamCreateContext({
|
||||
subject: input.subject,
|
||||
grade: input.grade,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
scheduledAt: input.scheduledAt,
|
||||
})
|
||||
|
||||
const aiDraftResult = await loadAiDraftQuestionsAndStructure({
|
||||
rawAiQuestions,
|
||||
rawStructure,
|
||||
title: input.title,
|
||||
subject: context.subjectName,
|
||||
grade: context.gradeName,
|
||||
difficulty: input.difficulty,
|
||||
totalScore: input.totalScore,
|
||||
durationMin: input.durationMin,
|
||||
aiSourceText: input.aiSourceText,
|
||||
aiQuestionCount: input.aiQuestionCount,
|
||||
aiProviderId: input.aiProviderId,
|
||||
})
|
||||
if (!aiDraftResult.ok) {
|
||||
return failState<string>(aiDraftResult.message)
|
||||
}
|
||||
const { generated, structure } = aiDraftResult
|
||||
|
||||
const questionCount = generated.length
|
||||
const description = context.buildDescription({ questionCount })
|
||||
|
||||
try {
|
||||
await persistAiGeneratedExamDraft({
|
||||
examId: context.examId,
|
||||
title: input.title,
|
||||
creatorId: ctx.userId,
|
||||
subjectId: input.subject,
|
||||
gradeId: input.grade,
|
||||
scheduledAt: context.scheduled,
|
||||
description,
|
||||
structure,
|
||||
generated,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(context.examId, "Exam created successfully.")
|
||||
} catch (error) {
|
||||
console.error("Failed to create exam:", error)
|
||||
return failState<string>("Database error: Failed to create exam")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(context.examId, "Exam created successfully.")
|
||||
}
|
||||
|
||||
export async function previewAiExamAction(
|
||||
prevState: ActionState<AiPreviewData> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<AiPreviewData>> {
|
||||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||||
try {
|
||||
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||||
|
||||
const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : ""
|
||||
if (!sourceText) {
|
||||
return failState<AiPreviewData>("Please paste the full exam text first", {
|
||||
aiSourceText: ["Please paste the full exam text first"],
|
||||
const aiSourceTextRaw = formData.get("aiSourceText")
|
||||
const aiQuestionCountRaw = formData.get("aiQuestionCount")
|
||||
const aiProviderIdRaw = formData.get("aiProviderId")
|
||||
|
||||
const sourceText = typeof aiSourceTextRaw === "string" ? aiSourceTextRaw.trim() : ""
|
||||
if (!sourceText) {
|
||||
return failState<AiPreviewData>("Please paste the full exam text first", {
|
||||
aiSourceText: ["Please paste the full exam text first"],
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = AiExamPreviewSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
aiSourceText: sourceText,
|
||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||
? aiQuestionCountRaw
|
||||
: undefined,
|
||||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||||
? aiProviderIdRaw
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = AiExamPreviewSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
difficulty: getStringValue(formData, "difficulty"),
|
||||
totalScore: getStringValue(formData, "totalScore"),
|
||||
durationMin: getStringValue(formData, "durationMin"),
|
||||
aiSourceText: sourceText,
|
||||
aiQuestionCount: typeof aiQuestionCountRaw === "string" && aiQuestionCountRaw.trim().length > 0
|
||||
? aiQuestionCountRaw
|
||||
: undefined,
|
||||
aiProviderId: typeof aiProviderIdRaw === "string" && aiProviderIdRaw.trim().length > 0
|
||||
? aiProviderIdRaw
|
||||
: undefined,
|
||||
})
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<AiPreviewData>(parsed.error)
|
||||
}
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<AiPreviewData>(parsed.error)
|
||||
const input = parsed.data
|
||||
const previewRequest = await prepareAiPreviewRequest(input)
|
||||
const aiDraft = await generateAiPreviewData(previewRequest)
|
||||
if (!aiDraft.ok) {
|
||||
return failState<AiPreviewData>(aiDraft.message)
|
||||
}
|
||||
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<AiPreviewData>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const input = parsed.data
|
||||
const previewRequest = await prepareAiPreviewRequest(input)
|
||||
const aiDraft = await generateAiPreviewData(previewRequest)
|
||||
if (!aiDraft.ok) {
|
||||
return failState<AiPreviewData>(aiDraft.message)
|
||||
}
|
||||
return successState({ ...aiDraft.data, rawOutput: aiDraft.rawOutput })
|
||||
}
|
||||
|
||||
export async function regenerateAiQuestionAction(
|
||||
prevState: ActionState<AiRewriteQuestionData> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<AiRewriteQuestionData>> {
|
||||
const parsedInput = parseRegenerateAiQuestionInput(formData)
|
||||
if (!parsedInput.ok) {
|
||||
return parsedInput.state
|
||||
}
|
||||
const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput
|
||||
|
||||
const originalDifficulty = originalQuestion.difficulty ?? 3
|
||||
const originalScore = originalQuestion.score ?? 0
|
||||
|
||||
try {
|
||||
const result = await regenerateAiQuestionByInstruction({
|
||||
instruction,
|
||||
originalQuestion,
|
||||
sourceText,
|
||||
aiProviderId,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return failState<AiRewriteQuestionData>(result.message)
|
||||
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
||||
|
||||
const parsedInput = parseRegenerateAiQuestionInput(formData)
|
||||
if (!parsedInput.ok) {
|
||||
return parsedInput.state
|
||||
}
|
||||
return successState({
|
||||
type: result.data.type,
|
||||
difficulty: result.data.difficulty ?? originalDifficulty,
|
||||
score: result.data.score ?? originalScore,
|
||||
content: result.data.content,
|
||||
})
|
||||
} catch {
|
||||
return failState<AiRewriteQuestionData>("AI question format invalid")
|
||||
const { instruction, aiProviderId, sourceText, originalQuestion } = parsedInput
|
||||
|
||||
const originalDifficulty = originalQuestion.difficulty ?? 3
|
||||
const originalScore = originalQuestion.score ?? 0
|
||||
|
||||
try {
|
||||
const result = await regenerateAiQuestionByInstruction({
|
||||
instruction,
|
||||
originalQuestion,
|
||||
sourceText,
|
||||
aiProviderId,
|
||||
})
|
||||
if (!result.ok) {
|
||||
return failState<AiRewriteQuestionData>(result.message)
|
||||
}
|
||||
return successState({
|
||||
type: result.data.type,
|
||||
difficulty: result.data.difficulty ?? originalDifficulty,
|
||||
score: result.data.score ?? originalScore,
|
||||
content: result.data.content,
|
||||
})
|
||||
} catch {
|
||||
return failState<AiRewriteQuestionData>("AI question format invalid")
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<AiRewriteQuestionData>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,58 +542,78 @@ export async function updateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const rawQuestions = formData.get("questionsJson")
|
||||
const rawStructure = formData.get("structureJson")
|
||||
const hasQuestions = typeof rawQuestions === "string"
|
||||
const hasStructure = typeof rawStructure === "string"
|
||||
|
||||
const parsed = ExamUpdateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
|
||||
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
|
||||
status: formData.get("status") ?? undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid update data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId, questions, structure, status } = parsed.data
|
||||
|
||||
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,
|
||||
}))
|
||||
)
|
||||
const ctx = await requirePermission(Permissions.EXAM_UPDATE)
|
||||
|
||||
const rawQuestions = formData.get("questionsJson")
|
||||
const rawStructure = formData.get("structureJson")
|
||||
const hasQuestions = typeof rawQuestions === "string"
|
||||
const hasStructure = typeof rawStructure === "string"
|
||||
|
||||
const parsed = ExamUpdateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
questions: hasQuestions ? JSON.parse(rawQuestions) : undefined,
|
||||
structure: hasStructure ? JSON.parse(rawStructure) : undefined,
|
||||
status: formData.get("status") ?? undefined,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid update data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId, questions, structure, status } = parsed.data
|
||||
|
||||
// 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) {
|
||||
return failState<string>("You can only update exams you created")
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to update exam")
|
||||
}
|
||||
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to update exam")
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(examId, "Exam updated")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(examId, "Exam updated")
|
||||
}
|
||||
|
||||
const ExamDeleteSchema = z.object({
|
||||
@@ -568,28 +624,48 @@ export async function deleteExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const parsed = ExamDeleteSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid delete data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
try {
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to delete exam")
|
||||
const ctx = await requirePermission(Permissions.EXAM_DELETE)
|
||||
|
||||
const parsed = ExamDeleteSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid delete data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
const { examId } = parsed.data
|
||||
|
||||
// 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) {
|
||||
return failState<string>("You can only delete exams you created")
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(exams).where(eq(exams.id, examId))
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to delete exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(examId, "Exam deleted")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(examId, "Exam deleted")
|
||||
}
|
||||
|
||||
const ExamDuplicateSchema = z.object({
|
||||
@@ -600,124 +676,157 @@ export async function duplicateExamAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
const parsed = ExamDuplicateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid duplicate data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
const user = await getCurrentUser()
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(exams).values({
|
||||
id: newExamId,
|
||||
title: `${source.title} (Copy)`,
|
||||
description: omitScheduledAtFromDescription(source.description),
|
||||
creatorId: user?.id ?? "user_teacher_math",
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
status: "draft",
|
||||
structure: source.structure,
|
||||
})
|
||||
const ctx = await requirePermission(Permissions.EXAM_DUPLICATE)
|
||||
|
||||
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 parsed = ExamDuplicateSchema.safeParse({
|
||||
examId: formData.get("examId"),
|
||||
})
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to duplicate exam")
|
||||
|
||||
if (!parsed.success) {
|
||||
return invalidFormState<string>(parsed.error, {
|
||||
fallbackMessage: "Invalid duplicate data",
|
||||
useFirstMessage: false,
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
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,
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return failState<string>("Database error: Failed to duplicate exam")
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(newExamId, "Exam duplicated")
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<string>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
revalidatePath("/teacher/exams/all")
|
||||
|
||||
return successState(newExamId, "Exam duplicated")
|
||||
}
|
||||
|
||||
export async function getExamPreviewAction(
|
||||
examId: string
|
||||
): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>> {
|
||||
try {
|
||||
const exam = await db.query.exams.findFirst({
|
||||
where: eq(exams.id, examId),
|
||||
with: {
|
||||
questions: {
|
||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
||||
with: {
|
||||
question: true
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
if (!exam) {
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
|
||||
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,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
|
||||
}
|
||||
const questions = exam.questions.map((eq) => eq.question)
|
||||
return successState({
|
||||
structure: exam.structure,
|
||||
questions,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubjectsAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||
try {
|
||||
const allSubjects = await db.query.subjects.findMany({
|
||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
|
||||
return successState(allSubjects.map((s) => ({ id: s.id, name: s.name })))
|
||||
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 })))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch subjects:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch subjects:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<{ id: string; name: string }[]>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGradesAction(): Promise<ActionState<{ id: string; name: string }[]>> {
|
||||
try {
|
||||
const allGrades = await db.query.grades.findMany({
|
||||
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
||||
})
|
||||
await requirePermission(Permissions.EXAM_READ)
|
||||
|
||||
return successState(allGrades.map((g) => ({ id: g.id, name: g.name })))
|
||||
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 })))
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch grades:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch grades:", error)
|
||||
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return failState<{ id: string; name: string }[]>(error.message)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function getCurrentUser() {
|
||||
return { id: "user_teacher_math", role: "teacher" }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user