769 lines
24 KiB
TypeScript
769 lines
24 KiB
TypeScript
"use server"
|
|
|
|
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 {
|
|
buildExamDescription,
|
|
deleteExamById,
|
|
duplicateExam,
|
|
getExamCreatorId,
|
|
getExamGrades,
|
|
getExamPreview,
|
|
getExamSubjects,
|
|
persistAiGeneratedExamDraft,
|
|
persistExamDraft,
|
|
resolveSubjectGradeNames,
|
|
updateExamWithQuestions,
|
|
} from "./data-access"
|
|
import {
|
|
AiGeneratedStructureSchema,
|
|
AiInsertQuestionSchema,
|
|
AiQuestionSchema,
|
|
generateAiCreateDraftFromSource,
|
|
generateAiPreviewData,
|
|
regenerateAiQuestionByInstruction,
|
|
} from "./ai-pipeline"
|
|
import type {
|
|
AiGeneratedQuestion,
|
|
AiGeneratedStructureNode,
|
|
AiPreviewData,
|
|
AiRewriteQuestionData,
|
|
} from "./ai-pipeline"
|
|
export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline"
|
|
|
|
const ExamCreateSchema = z.object({
|
|
title: z.string().min(1),
|
|
subject: z.string().min(1),
|
|
grade: z.string().min(1),
|
|
difficulty: z.coerce.number().int().min(1).max(5),
|
|
totalScore: z.coerce.number().int().min(1),
|
|
durationMin: z.coerce.number().int().min(1),
|
|
scheduledAt: z.string().optional().nullable(),
|
|
questions: z
|
|
.array(
|
|
z.object({
|
|
id: z.string(),
|
|
score: z.coerce.number().int().min(0),
|
|
})
|
|
)
|
|
.optional(),
|
|
})
|
|
|
|
const getStringValue = (formData: FormData, key: string) => {
|
|
const value = formData.get(key)
|
|
return typeof value === "string" ? value : undefined
|
|
}
|
|
|
|
const failState = <T>(message: string, errors?: Record<string, string[]>): ActionState<T> => ({
|
|
success: false,
|
|
message,
|
|
errors,
|
|
})
|
|
|
|
const successState = <T>(data: T, message?: string): ActionState<T> => ({
|
|
success: true,
|
|
message,
|
|
data,
|
|
})
|
|
|
|
const invalidFormState = <T>(
|
|
error: z.ZodError,
|
|
options?: { fallbackMessage?: string; useFirstMessage?: boolean }
|
|
): ActionState<T> => {
|
|
const errors = error.flatten().fieldErrors
|
|
const fallbackMessage = options?.fallbackMessage ?? "Invalid form data"
|
|
const useFirstMessage = options?.useFirstMessage ?? true
|
|
const messages = Object.values(errors).flatMap((items) => items ?? [])
|
|
const firstMessage = messages.find((msg): msg is string => typeof msg === "string" && msg.length > 0)
|
|
return failState<T>(useFirstMessage ? (firstMessage ?? fallbackMessage) : fallbackMessage, errors)
|
|
}
|
|
|
|
const prepareExamCreateContext = async (input: {
|
|
subject: string
|
|
grade: string
|
|
difficulty: number
|
|
totalScore: number
|
|
durationMin: number
|
|
scheduledAt?: string | null
|
|
}) => {
|
|
const examId = createId()
|
|
const scheduled = input.scheduledAt || undefined
|
|
const resolvedNames = await resolveSubjectGradeNames({
|
|
subjectId: input.subject,
|
|
gradeId: input.grade,
|
|
})
|
|
const subjectName = resolvedNames.subjectName ?? input.subject
|
|
const gradeName = resolvedNames.gradeName ?? input.grade
|
|
const buildDescription = (options?: { questionCount?: number }) => buildExamDescription({
|
|
subject: subjectName,
|
|
grade: gradeName,
|
|
difficulty: input.difficulty,
|
|
totalScore: input.totalScore,
|
|
durationMin: input.durationMin,
|
|
scheduledAt: scheduled,
|
|
questionCount: options?.questionCount,
|
|
})
|
|
return { examId, scheduled, subjectName, gradeName, buildDescription }
|
|
}
|
|
|
|
const loadAiDraftQuestionsAndStructure = async (input: {
|
|
rawAiQuestions: string | null
|
|
rawStructure: string | null
|
|
title: string
|
|
subject: string
|
|
grade: string
|
|
difficulty: number
|
|
totalScore: number
|
|
durationMin: number
|
|
aiSourceText?: string
|
|
aiQuestionCount?: number
|
|
aiProviderId?: string
|
|
}): Promise<
|
|
| { ok: true; generated: AiGeneratedQuestion[]; structure: AiGeneratedStructureNode[] }
|
|
| { ok: false; message: string }
|
|
> => {
|
|
if (input.rawAiQuestions) {
|
|
let parsedQuestions: unknown = null
|
|
try {
|
|
parsedQuestions = JSON.parse(input.rawAiQuestions)
|
|
} catch {
|
|
return { ok: false, message: "Invalid AI preview payload" }
|
|
}
|
|
const validated = z.array(AiInsertQuestionSchema).safeParse(parsedQuestions)
|
|
if (!validated.success || validated.data.length === 0) {
|
|
return { ok: false, message: "Invalid AI preview payload" }
|
|
}
|
|
const generated = validated.data.map((q) => ({
|
|
id: q.id,
|
|
type: q.type,
|
|
difficulty: q.difficulty,
|
|
content: q.content,
|
|
score: q.score,
|
|
}))
|
|
let structure: AiGeneratedStructureNode[] = []
|
|
if (input.rawStructure) {
|
|
try {
|
|
const parsedStructure = JSON.parse(input.rawStructure)
|
|
const validatedStructure = AiGeneratedStructureSchema.safeParse(parsedStructure)
|
|
if (validatedStructure.success) {
|
|
structure = validatedStructure.data
|
|
} else {
|
|
return { ok: false, message: "Invalid preview structure" }
|
|
}
|
|
} catch {
|
|
return { ok: false, message: "Invalid preview structure" }
|
|
}
|
|
}
|
|
if (structure.length === 0) {
|
|
structure = generated.map((q) => ({
|
|
id: createId(),
|
|
type: "question",
|
|
questionId: q.id,
|
|
score: q.score,
|
|
}))
|
|
}
|
|
return { ok: true, generated, structure }
|
|
}
|
|
|
|
const sourceText = input.aiSourceText?.trim()
|
|
if (!sourceText) {
|
|
return { ok: false, message: "Please analyze and preview before creating" }
|
|
}
|
|
const aiDraft = await generateAiCreateDraftFromSource({
|
|
title: input.title,
|
|
subject: input.subject,
|
|
grade: input.grade,
|
|
difficulty: input.difficulty,
|
|
totalScore: input.totalScore,
|
|
durationMin: input.durationMin,
|
|
questionCount: input.aiQuestionCount,
|
|
sourceText,
|
|
aiProviderId: input.aiProviderId,
|
|
})
|
|
if (!aiDraft.ok) {
|
|
return { ok: false, message: aiDraft.message }
|
|
}
|
|
return { ok: true, generated: aiDraft.generated, structure: aiDraft.structure }
|
|
}
|
|
|
|
const prepareAiPreviewRequest = async (input: {
|
|
title?: string
|
|
subject?: string
|
|
grade?: string
|
|
difficulty?: number
|
|
totalScore?: number
|
|
durationMin?: number
|
|
aiSourceText: string
|
|
aiQuestionCount?: number
|
|
aiProviderId?: string
|
|
}) => {
|
|
const resolvedNames = await resolveSubjectGradeNames({
|
|
subjectId: input.subject,
|
|
gradeId: input.grade,
|
|
})
|
|
const title = input.title && input.title.trim().length > 0 ? input.title : "AI Exam"
|
|
const subjectName = input.subject ? resolvedNames.subjectName ?? input.subject : undefined
|
|
const gradeName = input.grade ? resolvedNames.gradeName ?? input.grade : undefined
|
|
return {
|
|
title,
|
|
subject: subjectName,
|
|
grade: gradeName,
|
|
difficulty: input.difficulty ?? 3,
|
|
totalScore: input.totalScore ?? 100,
|
|
durationMin: input.durationMin ?? 90,
|
|
questionCount: input.aiQuestionCount,
|
|
sourceText: input.aiSourceText,
|
|
aiProviderId: input.aiProviderId,
|
|
}
|
|
}
|
|
|
|
const parseRegenerateAiQuestionInput = (
|
|
formData: FormData
|
|
):
|
|
| {
|
|
ok: true
|
|
instruction: string
|
|
aiProviderId?: string
|
|
sourceText?: string
|
|
originalQuestion: z.infer<typeof AiQuestionSchema>
|
|
}
|
|
| { ok: false; state: ActionState<AiRewriteQuestionData> } => {
|
|
const instruction = getStringValue(formData, "instruction")?.trim()
|
|
const aiProviderId = getStringValue(formData, "aiProviderId")?.trim()
|
|
const sourceText = getStringValue(formData, "sourceText")?.trim()
|
|
const questionJson = getStringValue(formData, "questionJson")
|
|
if (!instruction) {
|
|
return { ok: false, state: failState<AiRewriteQuestionData>("Please enter rewrite instruction") }
|
|
}
|
|
if (!questionJson) {
|
|
return { ok: false, state: failState<AiRewriteQuestionData>("No selected question data") }
|
|
}
|
|
try {
|
|
const parsedQuestion = JSON.parse(questionJson) as unknown
|
|
const validatedQuestion = AiQuestionSchema.safeParse(parsedQuestion)
|
|
if (!validatedQuestion.success) {
|
|
return { ok: false, state: failState<AiRewriteQuestionData>("Selected question format invalid") }
|
|
}
|
|
return {
|
|
ok: true,
|
|
instruction,
|
|
aiProviderId,
|
|
sourceText,
|
|
originalQuestion: validatedQuestion.data,
|
|
}
|
|
} catch {
|
|
return { ok: false, state: failState<AiRewriteQuestionData>("Selected question format invalid") }
|
|
}
|
|
}
|
|
|
|
export async function createExamAction(
|
|
prevState: ActionState<string> | null,
|
|
formData: FormData
|
|
): Promise<ActionState<string>> {
|
|
try {
|
|
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) {
|
|
if (error instanceof PermissionDeniedError) {
|
|
return failState<string>(error.message)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const AiExamCreateSchema = ExamCreateSchema.extend({
|
|
aiSourceText: z.string().optional(),
|
|
aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(),
|
|
aiProviderId: z.string().min(1).optional(),
|
|
})
|
|
|
|
const AiExamPreviewSchema = z.object({
|
|
title: z.string().optional(),
|
|
subject: z.string().optional(),
|
|
grade: z.string().optional(),
|
|
difficulty: z.coerce.number().int().min(1).max(5).optional(),
|
|
totalScore: z.coerce.number().int().min(1).optional(),
|
|
durationMin: z.coerce.number().int().min(1).optional(),
|
|
aiSourceText: z.string().min(1),
|
|
aiQuestionCount: z.coerce.number().int().min(1).max(200).optional(),
|
|
aiProviderId: z.string().min(1).optional(),
|
|
})
|
|
|
|
export async function createAiExamAction(
|
|
prevState: ActionState<string> | null,
|
|
formData: FormData
|
|
): Promise<ActionState<string>> {
|
|
try {
|
|
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) {
|
|
if (error instanceof PermissionDeniedError) {
|
|
return failState<string>(error.message)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function previewAiExamAction(
|
|
prevState: ActionState<AiPreviewData> | null,
|
|
formData: FormData
|
|
): Promise<ActionState<AiPreviewData>> {
|
|
try {
|
|
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
|
|
|
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,
|
|
})
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
export async function regenerateAiQuestionAction(
|
|
prevState: ActionState<AiRewriteQuestionData> | null,
|
|
formData: FormData
|
|
): Promise<ActionState<AiRewriteQuestionData>> {
|
|
try {
|
|
await requirePermission(Permissions.EXAM_AI_GENERATE)
|
|
|
|
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)
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
const ExamUpdateSchema = z.object({
|
|
examId: z.string().min(1),
|
|
questions: z
|
|
.array(
|
|
z.object({
|
|
id: z.string(),
|
|
score: z.coerce.number().int().min(0),
|
|
})
|
|
)
|
|
.optional(),
|
|
structure: z.unknown().optional(),
|
|
status: z.enum(["draft", "published", "archived"]).optional(),
|
|
})
|
|
|
|
export async function updateExamAction(
|
|
prevState: ActionState<string> | null,
|
|
formData: FormData
|
|
): Promise<ActionState<string>> {
|
|
try {
|
|
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 creatorId = await getExamCreatorId(examId)
|
|
if (!creatorId || creatorId !== ctx.userId) {
|
|
return failState<string>("You can only update exams you created")
|
|
}
|
|
}
|
|
|
|
try {
|
|
await updateExamWithQuestions(examId, {
|
|
questions: questions ?? undefined,
|
|
structure,
|
|
status,
|
|
})
|
|
} 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
|
|
}
|
|
}
|
|
|
|
const ExamDeleteSchema = z.object({
|
|
examId: z.string().min(1),
|
|
})
|
|
|
|
export async function deleteExamAction(
|
|
prevState: ActionState<string> | null,
|
|
formData: FormData
|
|
): Promise<ActionState<string>> {
|
|
try {
|
|
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 creatorId = await getExamCreatorId(examId)
|
|
if (!creatorId || creatorId !== ctx.userId) {
|
|
return failState<string>("You can only delete exams you created")
|
|
}
|
|
}
|
|
|
|
try {
|
|
await deleteExamById(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
|
|
}
|
|
}
|
|
|
|
const ExamDuplicateSchema = z.object({
|
|
examId: z.string().min(1),
|
|
})
|
|
|
|
export async function duplicateExamAction(
|
|
prevState: ActionState<string> | null,
|
|
formData: FormData
|
|
): Promise<ActionState<string>> {
|
|
try {
|
|
const ctx = await requirePermission(Permissions.EXAM_DUPLICATE)
|
|
|
|
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
|
|
|
|
let newExamId: string
|
|
try {
|
|
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")
|
|
}
|
|
|
|
revalidatePath("/teacher/exams/all")
|
|
|
|
return successState(newExamId, "Exam duplicated")
|
|
} catch (error) {
|
|
if (error instanceof PermissionDeniedError) {
|
|
return failState<string>(error.message)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
export async function getExamPreviewAction(
|
|
examId: string
|
|
): Promise<ActionState<{ structure: unknown; questions: Array<{ id: string }> }>> {
|
|
try {
|
|
await requirePermission(Permissions.EXAM_READ)
|
|
|
|
try {
|
|
const exam = await getExamPreview(examId)
|
|
|
|
if (!exam) {
|
|
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
|
|
}
|
|
return successState({
|
|
structure: exam.structure,
|
|
questions: exam.questions,
|
|
})
|
|
} catch (error) {
|
|
console.error(error)
|
|
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Failed to load exam preview")
|
|
}
|
|
} catch (error) {
|
|
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 {
|
|
await requirePermission(Permissions.EXAM_READ)
|
|
|
|
try {
|
|
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")
|
|
}
|
|
} catch (error) {
|
|
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 {
|
|
await requirePermission(Permissions.EXAM_READ)
|
|
|
|
try {
|
|
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")
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof PermissionDeniedError) {
|
|
return failState<{ id: string; name: string }[]>(error.message)
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
|