"use server"; import { revalidatePath } from "next/cache"; import prisma from "@/lib/prisma"; import { assertCourseManagePermission, assertTeacherOrAdmin, getAuthenticatedActor, } from "@/app/(protected)/dashboard/actions/course-auth"; interface AssignmentProblemInput { problemId: string; order?: number; } interface CreateAssignmentInput { courseId: string; title: string; description?: string; opensAt?: string; dueAt?: string; published?: boolean; problems: AssignmentProblemInput[]; } interface UpdateAssignmentInput { title?: string; description?: string | null; opensAt?: string | null; dueAt?: string | null; published?: boolean; problems?: AssignmentProblemInput[]; } async function validateProblems(problems: AssignmentProblemInput[]) { if (!Array.isArray(problems) || problems.length === 0) { throw new Error("作业至少需要一道题目"); } const uniqueProblemIds = [...new Set(problems.map((item) => item.problemId))]; const existingProblems = await prisma.problem.findMany({ where: { id: { in: uniqueProblemIds }, isPublished: true, }, select: { id: true }, }); if (existingProblems.length !== uniqueProblemIds.length) { throw new Error("题目不存在或未发布"); } } function parseDateValue(value?: string | null) { if (!value) { return null; } const date = new Date(value); if (Number.isNaN(date.getTime())) { throw new Error("日期格式不合法"); } return date; } export async function createAssignment(input: CreateAssignmentInput) { const actor = await getAuthenticatedActor(); assertTeacherOrAdmin(actor); await assertCourseManagePermission(input.courseId, actor); await validateProblems(input.problems); const title = input.title.trim(); if (!title) { throw new Error("作业标题不能为空"); } const opensAt = parseDateValue(input.opensAt); const dueAt = parseDateValue(input.dueAt); if (opensAt && dueAt && opensAt >= dueAt) { throw new Error("截止时间必须晚于开始时间"); } const assignment = await prisma.assignment.create({ data: { courseId: input.courseId, title, description: input.description?.trim() || null, opensAt, dueAt, published: Boolean(input.published), createdById: actor.id, problems: { create: input.problems.map((item, index) => ({ problemId: item.problemId, order: item.order ?? index + 1, })), }, }, select: { id: true, courseId: true, }, }); revalidatePath(`/dashboard/teacher/courses/${assignment.courseId}`); revalidatePath( `/dashboard/teacher/courses/${assignment.courseId}/assignments/${assignment.id}` ); revalidatePath("/dashboard/student/courses"); return assignment; } export async function updateAssignment( assignmentId: string, input: UpdateAssignmentInput ) { const actor = await getAuthenticatedActor(); assertTeacherOrAdmin(actor); const assignment = await prisma.assignment.findUnique({ where: { id: assignmentId }, select: { id: true, courseId: true }, }); if (!assignment) { throw new Error("作业不存在"); } await assertCourseManagePermission(assignment.courseId, actor); const nextData: { title?: string; description?: string | null; opensAt?: Date | null; dueAt?: Date | null; published?: boolean; } = {}; if (typeof input.title === "string") { const title = input.title.trim(); if (!title) { throw new Error("作业标题不能为空"); } nextData.title = title; } if (typeof input.description !== "undefined") { nextData.description = input.description?.trim() || null; } if (typeof input.opensAt !== "undefined") { nextData.opensAt = parseDateValue(input.opensAt); } if (typeof input.dueAt !== "undefined") { nextData.dueAt = parseDateValue(input.dueAt); } if (typeof input.published === "boolean") { nextData.published = input.published; } if (nextData.opensAt && nextData.dueAt && nextData.opensAt >= nextData.dueAt) { throw new Error("截止时间必须晚于开始时间"); } await prisma.assignment.update({ where: { id: assignmentId }, data: nextData, }); if (input.problems) { await validateProblems(input.problems); await prisma.assignmentProblem.deleteMany({ where: { assignmentId }, }); await prisma.assignmentProblem.createMany({ data: input.problems.map((item, index) => ({ assignmentId, problemId: item.problemId, order: item.order ?? index + 1, })), skipDuplicates: true, }); } revalidatePath(`/dashboard/teacher/courses/${assignment.courseId}`); revalidatePath( `/dashboard/teacher/courses/${assignment.courseId}/assignments/${assignmentId}` ); revalidatePath( `/dashboard/student/courses/${assignment.courseId}/assignments/${assignmentId}` ); } export async function publishAssignment( assignmentId: string, published: boolean = true ) { return updateAssignment(assignmentId, { published }); } export async function getAssignmentDetail(assignmentId: string) { const actor = await getAuthenticatedActor(); assertTeacherOrAdmin(actor); const assignment = await prisma.assignment.findUnique({ where: { id: assignmentId }, select: { id: true, title: true, description: true, opensAt: true, dueAt: true, published: true, courseId: true, course: { select: { id: true, title: true, teacherId: true, teacher: { select: { name: true, email: true, }, }, }, }, problems: { orderBy: [{ order: "asc" }, { problem: { displayId: "asc" } }], select: { problemId: true, order: true, problem: { select: { displayId: true, difficulty: true, localizations: { where: { type: "TITLE", locale: "zh" }, select: { content: true }, }, }, }, }, }, _count: { select: { submissions: true, }, }, }, }); if (!assignment) { throw new Error("作业不存在"); } await assertCourseManagePermission(assignment.courseId, actor); return assignment; } export async function listCourseAssignments(courseId: string) { const actor = await getAuthenticatedActor(); assertTeacherOrAdmin(actor); await assertCourseManagePermission(courseId, actor); return prisma.assignment.findMany({ where: { courseId }, orderBy: [{ dueAt: "asc" }, { createdAt: "desc" }], select: { id: true, title: true, description: true, opensAt: true, dueAt: true, published: true, createdAt: true, _count: { select: { problems: true, submissions: true, }, }, }, }); } export async function listAssignableProblems() { const actor = await getAuthenticatedActor(); assertTeacherOrAdmin(actor); return prisma.problem.findMany({ where: { isPublished: true }, orderBy: { displayId: "asc" }, select: { id: true, displayId: true, difficulty: true, localizations: { where: { type: "TITLE", locale: "zh" }, select: { content: true }, }, }, }); }