"use server" import { revalidatePath } from "next/cache" import { headers } from "next/headers" import { createId } from "@paralleldrive/cuid2" import { and, count, eq } from "drizzle-orm" import { db } from "@/shared/db" import { classes, classEnrollments, exams, homeworkAnswers, homeworkAssignmentQuestions, homeworkAssignmentTargets, homeworkAssignments, homeworkSubmissions, users, } from "@/shared/db/schema" import type { ActionState } from "@/shared/types/action-state" import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema" type CurrentUser = { id: string; role: "admin" | "teacher" | "student" } async function getCurrentUser() { const ref = (await headers()).get("referer") || "" const roleHint: CurrentUser["role"] = ref.includes("/admin/") ? "admin" : ref.includes("/student/") ? "student" : ref.includes("/teacher/") ? "teacher" : "teacher" const byRole = await db.query.users.findFirst({ where: eq(users.role, roleHint), orderBy: (u, { asc }) => [asc(u.createdAt)], }) if (byRole) return { id: byRole.id, role: roleHint } const anyUser = await db.query.users.findFirst({ orderBy: (u, { asc }) => [asc(u.createdAt)], }) if (anyUser) return { id: anyUser.id, role: roleHint } return { id: "user_teacher_math", role: roleHint } } async function ensureTeacher() { const user = await getCurrentUser() if (!user || (user.role !== "teacher" && user.role !== "admin")) throw new Error("Unauthorized") return user } async function ensureStudent() { const user = await getCurrentUser() if (!user || user.role !== "student") throw new Error("Unauthorized") return user } const parseStudentIds = (raw: string): string[] => { return raw .split(/[,\n\r\t ]+/g) .map((s) => s.trim()) .filter((s) => s.length > 0) } export async function createHomeworkAssignmentAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const user = await ensureTeacher() const targetStudentIdsJson = formData.get("targetStudentIdsJson") const targetStudentIdsText = formData.get("targetStudentIdsText") const parsed = CreateHomeworkAssignmentSchema.safeParse({ sourceExamId: formData.get("sourceExamId"), classId: formData.get("classId"), title: formData.get("title") || undefined, description: formData.get("description") || undefined, availableAt: formData.get("availableAt") || undefined, dueAt: formData.get("dueAt") || undefined, allowLate: formData.get("allowLate") || undefined, lateDueAt: formData.get("lateDueAt") || undefined, maxAttempts: formData.get("maxAttempts") || undefined, publish: formData.get("publish") || undefined, targetStudentIds: typeof targetStudentIdsJson === "string" && targetStudentIdsJson.length > 0 ? (JSON.parse(targetStudentIdsJson) as unknown) : typeof targetStudentIdsText === "string" && targetStudentIdsText.trim().length > 0 ? parseStudentIds(targetStudentIdsText) : undefined, }) if (!parsed.success) { return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors, } } const input = parsed.data const publish = input.publish ?? true const [ownedClass] = await db .select({ id: classes.id }) .from(classes) .where(user.role === "admin" ? eq(classes.id, input.classId) : and(eq(classes.id, input.classId), eq(classes.teacherId, user.id))) .limit(1) if (!ownedClass) return { success: false, message: "Class not found" } const exam = await db.query.exams.findFirst({ where: eq(exams.id, input.sourceExamId), with: { questions: { orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)], }, }, }) if (!exam) return { success: false, message: "Exam not found" } const assignmentId = createId() const availableAt = input.availableAt ? new Date(input.availableAt) : null const dueAt = input.dueAt ? new Date(input.dueAt) : null const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null const classStudentIds = ( await db .select({ studentId: classEnrollments.studentId }) .from(classEnrollments) .innerJoin(classes, eq(classes.id, classEnrollments.classId)) .where( and( eq(classEnrollments.classId, input.classId), eq(classEnrollments.status, "active"), user.role === "admin" ? eq(classes.id, input.classId) : eq(classes.teacherId, user.id) ) ) ).map((r) => r.studentId) const classStudentIdSet = new Set(classStudentIds) const targetStudentIds = input.targetStudentIds && input.targetStudentIds.length > 0 ? input.targetStudentIds.filter((id) => classStudentIdSet.has(id)) : classStudentIds if (publish && targetStudentIds.length === 0) { return { success: false, message: "No active students in this class" } } await db.transaction(async (tx) => { await tx.insert(homeworkAssignments).values({ id: assignmentId, sourceExamId: input.sourceExamId, title: input.title?.trim().length ? input.title.trim() : exam.title, description: input.description ?? null, structure: publish ? (exam.structure as unknown) : null, status: publish ? "published" : "draft", creatorId: user.id, availableAt, dueAt, allowLate: input.allowLate ?? false, lateDueAt, maxAttempts: input.maxAttempts ?? 1, }) if (publish && exam.questions.length > 0) { await tx.insert(homeworkAssignmentQuestions).values( exam.questions.map((q) => ({ assignmentId, questionId: q.questionId, score: q.score ?? 0, order: q.order ?? 0, })) ) } if (publish && targetStudentIds.length > 0) { await tx.insert(homeworkAssignmentTargets).values( targetStudentIds.map((studentId) => ({ assignmentId, studentId, })) ) } }) revalidatePath("/teacher/homework/assignments") revalidatePath("/teacher/homework/submissions") return { success: true, message: "Assignment created", data: assignmentId } } catch (error) { if (error instanceof Error) return { success: false, message: error.message } return { success: false, message: "Unexpected error" } } } export async function startHomeworkSubmissionAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const user = await ensureStudent() const assignmentId = formData.get("assignmentId") if (typeof assignmentId !== "string" || assignmentId.length === 0) return { success: false, message: "Missing assignmentId" } const assignment = await db.query.homeworkAssignments.findFirst({ where: eq(homeworkAssignments.id, assignmentId), }) if (!assignment) return { success: false, message: "Assignment not found" } if (assignment.status !== "published") return { success: false, message: "Assignment not available" } const target = await db.query.homeworkAssignmentTargets.findFirst({ where: and(eq(homeworkAssignmentTargets.assignmentId, assignmentId), eq(homeworkAssignmentTargets.studentId, user.id)), }) if (!target) return { success: false, message: "Not assigned" } if (assignment.availableAt && assignment.availableAt > new Date()) return { success: false, message: "Not available yet" } const [attemptRow] = await db .select({ c: count() }) .from(homeworkSubmissions) .where(and(eq(homeworkSubmissions.assignmentId, assignmentId), eq(homeworkSubmissions.studentId, user.id))) const attemptNo = (attemptRow?.c ?? 0) + 1 if (attemptNo > assignment.maxAttempts) return { success: false, message: "No attempts left" } const submissionId = createId() await db.insert(homeworkSubmissions).values({ id: submissionId, assignmentId, studentId: user.id, attemptNo, status: "started", startedAt: new Date(), }) revalidatePath("/student/learning/assignments") return { success: true, message: "Started", data: submissionId } } catch (error) { if (error instanceof Error) return { success: false, message: error.message } return { success: false, message: "Unexpected error" } } } export async function saveHomeworkAnswerAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const user = await ensureStudent() const submissionId = formData.get("submissionId") const questionId = formData.get("questionId") const answerJson = formData.get("answerJson") if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" } if (typeof questionId !== "string" || questionId.length === 0) return { success: false, message: "Missing questionId" } const submission = await db.query.homeworkSubmissions.findFirst({ where: eq(homeworkSubmissions.id, submissionId), with: { assignment: true }, }) if (!submission) return { success: false, message: "Submission not found" } if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" } if (submission.status !== "started") return { success: false, message: "Submission is locked" } const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null await db.transaction(async (tx) => { const existing = await tx.query.homeworkAnswers.findFirst({ where: and(eq(homeworkAnswers.submissionId, submissionId), eq(homeworkAnswers.questionId, questionId)), }) if (existing) { await tx .update(homeworkAnswers) .set({ answerContent: payload, updatedAt: new Date() }) .where(eq(homeworkAnswers.id, existing.id)) } else { await tx.insert(homeworkAnswers).values({ id: createId(), submissionId, questionId, answerContent: payload, }) } }) return { success: true, message: "Saved", data: submissionId } } catch (error) { if (error instanceof Error) return { success: false, message: error.message } return { success: false, message: "Unexpected error" } } } export async function submitHomeworkAction( prevState: ActionState | null, formData: FormData ): Promise> { try { const user = await ensureStudent() const submissionId = formData.get("submissionId") if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" } const submission = await db.query.homeworkSubmissions.findFirst({ where: eq(homeworkSubmissions.id, submissionId), with: { assignment: true }, }) if (!submission) return { success: false, message: "Submission not found" } if (submission.studentId !== user.id) return { success: false, message: "Unauthorized" } if (submission.status !== "started") return { success: false, message: "Already submitted" } const now = new Date() const dueAt = submission.assignment.dueAt const allowLate = submission.assignment.allowLate const lateDueAt = submission.assignment.lateDueAt if (dueAt && now > dueAt && !allowLate) return { success: false, message: "Past due" } if (allowLate && lateDueAt && now > lateDueAt) return { success: false, message: "Past late due" } const isLate = Boolean(dueAt && now > dueAt) await db .update(homeworkSubmissions) .set({ status: "submitted", submittedAt: now, isLate, updatedAt: now }) .where(eq(homeworkSubmissions.id, submissionId)) revalidatePath("/teacher/homework/submissions") revalidatePath("/student/learning/assignments") return { success: true, message: "Submitted", data: submissionId } } catch (error) { if (error instanceof Error) return { success: false, message: error.message } return { success: false, message: "Unexpected error" } } } export async function gradeHomeworkSubmissionAction( prevState: ActionState | null, formData: FormData ): Promise> { try { await ensureTeacher() const rawAnswers = formData.get("answersJson") as string | null const parsed = GradeHomeworkSchema.safeParse({ submissionId: formData.get("submissionId"), answers: rawAnswers ? JSON.parse(rawAnswers) : [], }) if (!parsed.success) { return { success: false, message: "Invalid grading data", errors: parsed.error.flatten().fieldErrors, } } const { submissionId, answers } = parsed.data let totalScore = 0 for (const ans of answers) { await db .update(homeworkAnswers) .set({ score: ans.score, feedback: ans.feedback ?? null, updatedAt: new Date() }) .where(eq(homeworkAnswers.id, ans.id)) totalScore += ans.score } await db .update(homeworkSubmissions) .set({ score: totalScore, status: "graded", updatedAt: new Date() }) .where(eq(homeworkSubmissions.id, submissionId)) revalidatePath("/teacher/homework/submissions") return { success: true, message: "Grading saved" } } catch (error) { if (error instanceof Error) return { success: false, message: error.message } return { success: false, message: "Unexpected error" } } }