From 84d6636bd1e69f26bc9cdba0646ed620c0d70099 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Thu, 18 Jun 2026 02:31:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20P1-2=20actions=20=E5=B1=82=20DB=20?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E4=B8=8B=E6=B2=89=E5=88=B0=20data-access=20(?= =?UTF-8?q?exams/homework/questions/announcements)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/modules/announcements/actions.ts | 65 ++--- src/modules/announcements/data-access.ts | 66 +++++ src/modules/announcements/types.ts | 23 ++ src/modules/exams/actions.ts | 134 +++------ src/modules/exams/data-access.ts | 151 +++++++++++ src/modules/homework/actions.ts | 227 +++++----------- src/modules/homework/data-access-write.ts | 317 ++++++++++++++++++++++ src/modules/questions/actions.ts | 146 +--------- src/modules/questions/data-access.ts | 167 +++++++++++- 9 files changed, 858 insertions(+), 438 deletions(-) create mode 100644 src/modules/homework/data-access-write.ts diff --git a/src/modules/announcements/actions.ts b/src/modules/announcements/actions.ts index 6f6bc36..8c96067 100644 --- a/src/modules/announcements/actions.ts +++ b/src/modules/announcements/actions.ts @@ -2,16 +2,21 @@ import { revalidatePath } from "next/cache" import { createId } from "@paralleldrive/cuid2" -import { eq } from "drizzle-orm" import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { db } from "@/shared/db" -import { announcements } from "@/shared/db/schema" import type { ActionState } from "@/shared/types/action-state" import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema" -import { getAnnouncements, getAnnouncementById } from "./data-access" +import { + getAnnouncements, + getAnnouncementById, + insertAnnouncement, + updateAnnouncementById, + deleteAnnouncementById, + publishAnnouncementById, + archiveAnnouncementById, +} from "./data-access" import type { GetAnnouncementsParams, Announcement } from "./types" export async function createAnnouncementAction( @@ -49,9 +54,8 @@ export async function createAnnouncementAction( ? new Date(input.publishedAt) : null - const id = createId() - await db.insert(announcements).values({ - id, + const id = await insertAnnouncement({ + id: createId(), title: input.title, content: input.content, type: input.type, @@ -105,7 +109,6 @@ export async function updateAnnouncementAction( } const input = parsed.data - const wasPublished = existing.status === "published" const isPublished = input.status === "published" const publishedAt = isPublished ? existing.publishedAt @@ -115,24 +118,20 @@ export async function updateAnnouncementAction( ? new Date(input.publishedAt) : null - await db - .update(announcements) - .set({ - title: input.title, - content: input.content, - type: input.type, - status: input.status, - targetGradeId: input.targetGradeId, - targetClassId: input.targetClassId, - publishedAt, - updatedAt: new Date(), - }) - .where(eq(announcements.id, id)) + await updateAnnouncementById(id, { + title: input.title, + content: input.content, + type: input.type, + status: input.status, + targetGradeId: input.targetGradeId, + targetClassId: input.targetClassId, + publishedAt, + updatedAt: new Date(), + }) revalidatePath("/admin/announcements") revalidatePath(`/admin/announcements/${id}`) revalidatePath("/announcements") - void wasPublished return { success: true, message: "Announcement updated", data: id } } catch (e) { @@ -151,7 +150,7 @@ export async function deleteAnnouncementAction(id: string): Promise { + await db.insert(announcements).values({ + id: data.id, + title: data.title, + content: data.content, + type: data.type, + status: data.status, + targetGradeId: data.targetGradeId, + targetClassId: data.targetClassId, + authorId: data.authorId, + publishedAt: data.publishedAt, + }) + return data.id +} + +export async function updateAnnouncementById( + id: string, + data: AnnouncementUpdateData +): Promise { + await db + .update(announcements) + .set({ + title: data.title, + content: data.content, + type: data.type, + status: data.status, + targetGradeId: data.targetGradeId, + targetClassId: data.targetClassId, + publishedAt: data.publishedAt, + updatedAt: data.updatedAt, + }) + .where(eq(announcements.id, id)) +} + +export async function deleteAnnouncementById(id: string): Promise { + await db.delete(announcements).where(eq(announcements.id, id)) +} + +export async function publishAnnouncementById( + id: string, + publishedAt: Date +): Promise { + await db + .update(announcements) + .set({ + status: "published", + publishedAt, + updatedAt: new Date(), + }) + .where(eq(announcements.id, id)) +} + +export async function archiveAnnouncementById(id: string): Promise { + await db + .update(announcements) + .set({ + status: "archived", + updatedAt: new Date(), + }) + .where(eq(announcements.id, id)) +} diff --git a/src/modules/announcements/types.ts b/src/modules/announcements/types.ts index c38fa2e..68eb32f 100644 --- a/src/modules/announcements/types.ts +++ b/src/modules/announcements/types.ts @@ -25,3 +25,26 @@ export type GetAnnouncementsParams = { page?: number pageSize?: number } + +export interface AnnouncementInsertData { + id: string + title: string + content: string + type: AnnouncementType + status: AnnouncementStatus + targetGradeId: string | null + targetClassId: string | null + authorId: string + publishedAt: Date | null +} + +export interface AnnouncementUpdateData { + title: string + content: string + type: AnnouncementType + status: AnnouncementStatus + targetGradeId: string | null + targetClassId: string | null + publishedAt: Date | null + updatedAt: Date +} diff --git a/src/modules/exams/actions.ts b/src/modules/exams/actions.ts index 24c29ca..764d35c 100644 --- a/src/modules/exams/actions.ts +++ b/src/modules/exams/actions.ts @@ -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("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 = {} - 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("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("You can only delete exams you created") } } try { - await db.delete(exams).where(eq(exams.id, examId)) + await deleteExamById(examId) } catch { return failState("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("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("Exam not found") + } + newExamId = duplicatedId } catch { return failState("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 [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 [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") diff --git a/src/modules/exams/data-access.ts b/src/modules/exams/data-access.ts index 6993fb7..41d018f 100644 --- a/src/modules/exams/data-access.ts +++ b/src/modules/exams/data-access.ts @@ -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 => { + 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 => { + 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 = {} + 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 => { + 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 => { + 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> => { + 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> => { + 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 })) +} diff --git a/src/modules/homework/actions.ts b/src/modules/homework/actions.ts index 27f4425..4db426a 100644 --- a/src/modules/homework/actions.ts +++ b/src/modules/homework/actions.ts @@ -2,25 +2,24 @@ import { revalidatePath } from "next/cache" import { createId } from "@paralleldrive/cuid2" -import { and, count, eq } from "drizzle-orm" import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" import { Permissions } from "@/shared/types/permissions" -import { db } from "@/shared/db" -import { - classes, - classEnrollments, - classSubjectTeachers, - exams, - homeworkAnswers, - homeworkAssignmentQuestions, - homeworkAssignmentTargets, - homeworkAssignments, - homeworkSubmissions, -} from "@/shared/db/schema" import type { ActionState } from "@/shared/types/action-state" import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema" +import { + createHomeworkAssignment, + getActiveClassStudentIdsForHomework, + getClassTeacherById, + getExamWithQuestionsForHomework, + getHomeworkSubmissionForPermission, + getTeacherAssignedSubjectIds, + gradeHomeworkAnswers, + markHomeworkSubmitted, + saveHomeworkAnswer, + startHomeworkSubmission, +} from "./data-access-write" const parseStudentIds = (raw: string): string[] => { return raw @@ -69,64 +68,32 @@ export async function createHomeworkAssignmentAction( const input = parsed.data const publish = input.publish ?? true - const [classRow] = await db - .select({ id: classes.id, teacherId: classes.teacherId }) - .from(classes) - .where(eq(classes.id, input.classId)) - .limit(1) + const classRow = await getClassTeacherById(input.classId) if (!classRow) 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)], - }, - }, - }) - + const exam = await getExamWithQuestionsForHomework(input.sourceExamId) if (!exam) return { success: false, message: "Exam not found" } if (ctx.dataScope.type !== "all" && classRow.teacherId !== ctx.userId) { - const assignedSubjectRows = await db - .select({ subjectId: classSubjectTeachers.subjectId }) - .from(classSubjectTeachers) - .where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, ctx.userId))) - if (assignedSubjectRows.length === 0) { + const assignedSubjectIds = await getTeacherAssignedSubjectIds(input.classId, ctx.userId) + if (assignedSubjectIds.length === 0) { return { success: false, message: "Not assigned to this class" } } - const assignedSubjectIds = new Set(assignedSubjectRows.map((r) => r.subjectId)) + const assignedSubjectSet = new Set(assignedSubjectIds) if (!exam.subjectId) { return { success: false, message: "Exam subject not set" } } - if (!assignedSubjectIds.has(exam.subjectId)) { + if (!assignedSubjectSet.has(exam.subjectId)) { return { success: false, message: "Not assigned to this subject" } } } - 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 classScope = - ctx.dataScope.type === "all" - ? eq(classes.id, input.classId) - : classRow.teacherId === ctx.userId - ? eq(classes.teacherId, ctx.userId) - : eq(classes.id, input.classId) - - 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"), classScope) - ) - ).map((r) => r.studentId) - + const classStudentIds = await getActiveClassStudentIdsForHomework( + input.classId, + ctx.dataScope, + ctx.userId, + classRow.teacherId + ) const classStudentIdSet = new Set(classStudentIds) const targetStudentIds = @@ -138,41 +105,27 @@ export async function createHomeworkAssignmentAction( 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: ctx.userId, - availableAt, - dueAt, - allowLate: input.allowLate ?? false, - lateDueAt, - maxAttempts: input.maxAttempts ?? 1, - }) + 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 - 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, - })) - ) - } + await createHomeworkAssignment({ + assignmentId, + sourceExamId: input.sourceExamId, + title: input.title?.trim().length ? input.title.trim() : exam.title, + description: input.description ?? null, + structure: exam.structure, + status: publish ? "published" : "draft", + creatorId: ctx.userId, + availableAt, + dueAt, + allowLate: input.allowLate ?? false, + lateDueAt, + maxAttempts: input.maxAttempts ?? 1, + publish, + questions: exam.questions, + targetStudentIds, }) revalidatePath("/teacher/homework/assignments") @@ -197,40 +150,14 @@ export async function startHomeworkSubmissionAction( 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, ctx.userId)), - }) - 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, ctx.userId))) - - 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: ctx.userId, - attemptNo, - status: "started", - startedAt: new Date(), - }) + const result = await startHomeworkSubmission(assignmentId, ctx.userId) + if ("error" in result) { + return { success: false, message: result.error } + } revalidatePath("/student/learning/assignments") - return { success: true, message: "Started", data: submissionId } + return { success: true, message: "Started", data: result.submissionId } } catch (e) { if (e instanceof PermissionDeniedError) { return { success: false, message: e.message } @@ -252,35 +179,14 @@ export async function saveHomeworkAnswerAction( 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 }, - }) + const submission = await getHomeworkSubmissionForPermission(submissionId) if (!submission) return { success: false, message: "Submission not found" } if (submission.studentId !== ctx.userId) 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, - }) - } - }) + await saveHomeworkAnswer(submissionId, questionId, payload) return { success: true, message: "Saved", data: submissionId } } catch (e) { @@ -301,10 +207,7 @@ export async function submitHomeworkAction( 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 }, - }) + const submission = await getHomeworkSubmissionForPermission(submissionId) if (!submission) return { success: false, message: "Submission not found" } if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" } if (submission.status !== "started") return { success: false, message: "Already submitted" } @@ -319,10 +222,7 @@ export async function submitHomeworkAction( const isLate = Boolean(dueAt && now > dueAt) - await db - .update(homeworkSubmissions) - .set({ status: "submitted", submittedAt: now, isLate, updatedAt: now }) - .where(eq(homeworkSubmissions.id, submissionId)) + await markHomeworkSubmitted(submissionId, isLate) revalidatePath("/teacher/homework/submissions") revalidatePath("/student/learning/assignments") @@ -359,20 +259,15 @@ export async function gradeHomeworkSubmissionAction( } 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)) + await gradeHomeworkAnswers( + submissionId, + answers.map((ans) => ({ + id: ans.id, + score: ans.score, + feedback: ans.feedback ?? null, + })) + ) revalidatePath("/teacher/homework/submissions") diff --git a/src/modules/homework/data-access-write.ts b/src/modules/homework/data-access-write.ts new file mode 100644 index 0000000..40abfdb --- /dev/null +++ b/src/modules/homework/data-access-write.ts @@ -0,0 +1,317 @@ +import "server-only" + +import { createId } from "@paralleldrive/cuid2" +import { and, count, eq } from "drizzle-orm" + +import { db } from "@/shared/db" +import { + classes, + classEnrollments, + classSubjectTeachers, + exams, + homeworkAnswers, + homeworkAssignmentQuestions, + homeworkAssignmentTargets, + homeworkAssignments, + homeworkSubmissions, +} from "@/shared/db/schema" +import type { DataScope } from "@/shared/types/permissions" + +// ---- Types ---- + +export type HomeworkExamQuestionData = { + questionId: string + score: number | null + order: number | null +} + +export type HomeworkExamData = { + id: string + title: string + subjectId: string | null + structure: unknown + questions: HomeworkExamQuestionData[] +} + +export type HomeworkSubmissionPermissionData = { + id: string + studentId: string + status: string | null + assignment: { + dueAt: Date | null + allowLate: boolean + lateDueAt: Date | null + } +} + +export type CreateHomeworkAssignmentData = { + assignmentId: string + sourceExamId: string + title: string + description: string | null + structure: unknown + status: string + creatorId: string + availableAt: Date | null + dueAt: Date | null + allowLate: boolean + lateDueAt: Date | null + maxAttempts: number + publish: boolean + questions: HomeworkExamQuestionData[] + targetStudentIds: string[] +} + +// ---- Query helpers (for permission/validation in actions) ---- + +export const getClassTeacherById = async ( + classId: string +): Promise<{ id: string; teacherId: string } | null> => { + const [row] = await db + .select({ id: classes.id, teacherId: classes.teacherId }) + .from(classes) + .where(eq(classes.id, classId)) + .limit(1) + return row ?? null +} + +export const getExamWithQuestionsForHomework = async ( + examId: string +): Promise => { + const exam = await db.query.exams.findFirst({ + where: eq(exams.id, examId), + with: { + questions: { + orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)], + }, + }, + }) + if (!exam) return null + return { + id: exam.id, + title: exam.title, + subjectId: exam.subjectId, + structure: exam.structure, + questions: exam.questions.map((q) => ({ + questionId: q.questionId, + score: q.score ?? null, + order: q.order ?? null, + })), + } +} + +export const getTeacherAssignedSubjectIds = async ( + classId: string, + teacherId: string +): Promise => { + const rows = await db + .select({ subjectId: classSubjectTeachers.subjectId }) + .from(classSubjectTeachers) + .where( + and( + eq(classSubjectTeachers.classId, classId), + eq(classSubjectTeachers.teacherId, teacherId) + ) + ) + return rows.map((r) => r.subjectId) +} + +export const getActiveClassStudentIdsForHomework = async ( + classId: string, + dataScope: DataScope, + userId: string, + classTeacherId: string +): Promise => { + const classScope = + dataScope.type === "all" + ? eq(classes.id, classId) + : classTeacherId === userId + ? eq(classes.teacherId, userId) + : eq(classes.id, classId) + + const rows = await db + .select({ studentId: classEnrollments.studentId }) + .from(classEnrollments) + .innerJoin(classes, eq(classes.id, classEnrollments.classId)) + .where( + and( + eq(classEnrollments.classId, classId), + eq(classEnrollments.status, "active"), + classScope + ) + ) + + return rows.map((r) => r.studentId) +} + +export const getHomeworkSubmissionForPermission = async ( + submissionId: string +): Promise => { + const submission = await db.query.homeworkSubmissions.findFirst({ + where: eq(homeworkSubmissions.id, submissionId), + with: { assignment: true }, + }) + if (!submission) return null + return { + id: submission.id, + studentId: submission.studentId, + status: submission.status, + assignment: { + dueAt: submission.assignment.dueAt, + allowLate: submission.assignment.allowLate, + lateDueAt: submission.assignment.lateDueAt, + }, + } +} + +// ---- Write functions ---- + +export const createHomeworkAssignment = async ( + input: CreateHomeworkAssignmentData +): Promise => { + await db.transaction(async (tx) => { + await tx.insert(homeworkAssignments).values({ + id: input.assignmentId, + sourceExamId: input.sourceExamId, + title: input.title, + description: input.description, + structure: input.publish ? input.structure : null, + status: input.status, + creatorId: input.creatorId, + availableAt: input.availableAt, + dueAt: input.dueAt, + allowLate: input.allowLate, + lateDueAt: input.lateDueAt, + maxAttempts: input.maxAttempts, + }) + + if (input.publish && input.questions.length > 0) { + await tx.insert(homeworkAssignmentQuestions).values( + input.questions.map((q) => ({ + assignmentId: input.assignmentId, + questionId: q.questionId, + score: q.score ?? 0, + order: q.order ?? 0, + })) + ) + } + + if (input.publish && input.targetStudentIds.length > 0) { + await tx.insert(homeworkAssignmentTargets).values( + input.targetStudentIds.map((studentId) => ({ + assignmentId: input.assignmentId, + studentId, + })) + ) + } + }) + + return input.assignmentId +} + +export const startHomeworkSubmission = async ( + assignmentId: string, + studentId: string +): Promise<{ submissionId: string } | { error: string }> => { + const assignment = await db.query.homeworkAssignments.findFirst({ + where: eq(homeworkAssignments.id, assignmentId), + }) + if (!assignment) return { error: "Assignment not found" } + if (assignment.status !== "published") return { error: "Assignment not available" } + + const target = await db.query.homeworkAssignmentTargets.findFirst({ + where: and( + eq(homeworkAssignmentTargets.assignmentId, assignmentId), + eq(homeworkAssignmentTargets.studentId, studentId) + ), + }) + if (!target) return { error: "Not assigned" } + + if (assignment.availableAt && assignment.availableAt > new Date()) { + return { error: "Not available yet" } + } + + const [attemptRow] = await db + .select({ c: count() }) + .from(homeworkSubmissions) + .where( + and( + eq(homeworkSubmissions.assignmentId, assignmentId), + eq(homeworkSubmissions.studentId, studentId) + ) + ) + + const attemptNo = (attemptRow?.c ?? 0) + 1 + if (attemptNo > assignment.maxAttempts) return { error: "No attempts left" } + + const submissionId = createId() + await db.insert(homeworkSubmissions).values({ + id: submissionId, + assignmentId, + studentId, + attemptNo, + status: "started", + startedAt: new Date(), + }) + + return { submissionId } +} + +export const saveHomeworkAnswer = async ( + submissionId: string, + questionId: string, + answerContent: unknown +): Promise => { + 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, updatedAt: new Date() }) + .where(eq(homeworkAnswers.id, existing.id)) + } else { + await tx.insert(homeworkAnswers).values({ + id: createId(), + submissionId, + questionId, + answerContent, + }) + } + }) +} + +export const markHomeworkSubmitted = async ( + submissionId: string, + isLate: boolean +): Promise => { + const now = new Date() + await db + .update(homeworkSubmissions) + .set({ status: "submitted", submittedAt: now, isLate, updatedAt: now }) + .where(eq(homeworkSubmissions.id, submissionId)) +} + +export const gradeHomeworkAnswers = async ( + submissionId: string, + answers: Array<{ id: string; score: number; feedback: string | null }> +): Promise => { + let totalScore = 0 + for (const ans of answers) { + await db + .update(homeworkAnswers) + .set({ score: ans.score, feedback: ans.feedback, 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)) +} diff --git a/src/modules/questions/actions.ts b/src/modules/questions/actions.ts index 71904bc..77e7fbe 100644 --- a/src/modules/questions/actions.ts +++ b/src/modules/questions/actions.ts @@ -2,55 +2,21 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"; import { Permissions } from "@/shared/types/permissions"; -import { db } from "@/shared/db"; -import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks } from "@/shared/db/schema"; import { CreateQuestionSchema } from "./schema"; import type { CreateQuestionInput } from "./schema"; import { ActionState } from "@/shared/types/action-state"; import { revalidatePath } from "next/cache"; -import { createId } from "@paralleldrive/cuid2"; -import { and, asc, eq } from "drizzle-orm"; import { z } from "zod"; -import { getQuestions, type GetQuestionsParams } from "./data-access"; +import { + createQuestionWithRelations, + deleteQuestionByIdRecursive, + getKnowledgePointOptions, + getQuestions, + updateQuestionById, + type GetQuestionsParams, +} from "./data-access"; import type { KnowledgePointOption } from "./types"; -type Tx = Parameters[0]>[0] - -async function insertQuestionWithRelations( - tx: Tx, - input: z.infer, - authorId: string, - parentId: string | null = null -) { - const newQuestionId = createId(); - - await tx.insert(questions).values({ - id: newQuestionId, - content: input.content, - type: input.type, - difficulty: input.difficulty, - authorId: authorId, - parentId: parentId, - }); - - if (input.knowledgePointIds && input.knowledgePointIds.length > 0) { - await tx.insert(questionsToKnowledgePoints).values( - input.knowledgePointIds.map((kpId) => ({ - questionId: newQuestionId, - knowledgePointId: kpId, - })) - ); - } - - if (input.subQuestions && input.subQuestions.length > 0) { - for (const subQ of input.subQuestions) { - await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId); - } - } - - return newQuestionId; -} - export async function createNestedQuestion( prevState: ActionState | undefined, formData: FormData | CreateQuestionInput @@ -81,9 +47,7 @@ export async function createNestedQuestion( const input = validatedFields.data; - await db.transaction(async (tx) => { - await insertQuestionWithRelations(tx, input, ctx.userId, null); - }); + await createQuestionWithRelations(input, ctx.userId); revalidatePath("/teacher/questions"); @@ -114,7 +78,7 @@ const UpdateQuestionSchema = z.object({ id: z.string().min(1), type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]), difficulty: z.number().min(1).max(5), - content: z.any(), + content: z.unknown(), knowledgePointIds: z.array(z.string()).optional(), }); @@ -140,32 +104,9 @@ export async function updateQuestionAction( }; } - const input = parsed.data; + const { id, ...updateData } = parsed.data; - await db.transaction(async (tx) => { - await tx - .update(questions) - .set({ - type: input.type, - difficulty: input.difficulty, - content: input.content, - updatedAt: new Date(), - }) - .where(canEditAll ? eq(questions.id, input.id) : and(eq(questions.id, input.id), eq(questions.authorId, ctx.userId))); - - await tx - .delete(questionsToKnowledgePoints) - .where(eq(questionsToKnowledgePoints.questionId, input.id)); - - if (input.knowledgePointIds && input.knowledgePointIds.length > 0) { - await tx.insert(questionsToKnowledgePoints).values( - input.knowledgePointIds.map((kpId) => ({ - questionId: input.id, - knowledgePointId: kpId, - })) - ); - } - }); + await updateQuestionById(id, updateData, canEditAll, ctx.userId); revalidatePath("/teacher/questions"); @@ -181,19 +122,6 @@ export async function updateQuestionAction( } } -async function deleteQuestionRecursive(tx: Tx, questionId: string) { - const children = await tx - .select({ id: questions.id }) - .from(questions) - .where(eq(questions.parentId, questionId)); - - for (const child of children) { - await deleteQuestionRecursive(tx, child.id); - } - - await tx.delete(questions).where(eq(questions.id, questionId)); -} - export async function deleteQuestionAction( prevState: ActionState | undefined, formData: FormData @@ -207,21 +135,7 @@ export async function deleteQuestionAction( return { success: false, message: "Invalid question ID" }; } - await db.transaction(async (tx) => { - const q = await tx.query.questions.findFirst({ - where: eq(questions.id, questionId), - }); - - if (!q) { - throw new Error("Question not found"); - } - - if (!canDeleteAll && q.authorId !== ctx.userId) { - throw new Error("Unauthorized"); - } - - await deleteQuestionRecursive(tx, questionId); - }); + await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId); revalidatePath("/teacher/questions"); @@ -252,39 +166,7 @@ export async function getQuestionsAction(params: GetQuestionsParams) { export async function getKnowledgePointOptionsAction(): Promise { try { await requirePermission(Permissions.QUESTION_READ); - - const rows = await db - .select({ - id: knowledgePoints.id, - name: knowledgePoints.name, - chapterId: chapters.id, - chapterTitle: chapters.title, - textbookId: textbooks.id, - textbookTitle: textbooks.title, - subject: textbooks.subject, - grade: textbooks.grade, - }) - .from(knowledgePoints) - .leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) - .leftJoin(textbooks, eq(textbooks.id, chapters.textbookId)) - .orderBy( - asc(textbooks.title), - asc(chapters.order), - asc(chapters.title), - asc(knowledgePoints.order), - asc(knowledgePoints.name) - ); - - return rows.map((row) => ({ - id: row.id, - name: row.name, - chapterId: row.chapterId ?? null, - chapterTitle: row.chapterTitle ?? null, - textbookId: row.textbookId ?? null, - textbookTitle: row.textbookTitle ?? null, - subject: row.subject ?? null, - grade: row.grade ?? null, - })); + return await getKnowledgePointOptions(); } catch (e) { if (e instanceof PermissionDeniedError) { throw e; diff --git a/src/modules/questions/data-access.ts b/src/modules/questions/data-access.ts index 3cd9c42..ed14cbf 100644 --- a/src/modules/questions/data-access.ts +++ b/src/modules/questions/data-access.ts @@ -1,10 +1,21 @@ import 'server-only'; import { db } from "@/shared/db"; -import { questions, questionsToKnowledgePoints } from "@/shared/db/schema"; -import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"; +import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks } from "@/shared/db/schema"; +import { and, asc, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"; import { cache } from "react"; -import type { Question, QuestionType } from "./types"; +import { createId } from "@paralleldrive/cuid2"; +import type { CreateQuestionInput } from "./schema"; +import type { KnowledgePointOption, Question, QuestionType } from "./types"; + +type Tx = Parameters[0]>[0] + +export type UpdateQuestionInput = { + type: QuestionType + difficulty: number + content: unknown + knowledgePointIds?: string[] +} export type GetQuestionsParams = { q?: string; @@ -136,3 +147,153 @@ export const getQuestionsDashboardStats = cache(async (): Promise { + const newQuestionId = createId(); + + await tx.insert(questions).values({ + id: newQuestionId, + content: input.content, + type: input.type, + difficulty: input.difficulty, + authorId: authorId, + parentId: parentId, + }); + + if (input.knowledgePointIds && input.knowledgePointIds.length > 0) { + await tx.insert(questionsToKnowledgePoints).values( + input.knowledgePointIds.map((kpId) => ({ + questionId: newQuestionId, + knowledgePointId: kpId, + })) + ); + } + + if (input.subQuestions && input.subQuestions.length > 0) { + for (const subQ of input.subQuestions) { + await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId); + } + } + + return newQuestionId; +} + +export async function createQuestionWithRelations( + input: CreateQuestionInput, + authorId: string +): Promise { + return await db.transaction(async (tx) => { + return await insertQuestionWithRelations(tx, input, authorId, null); + }); +} + +export async function updateQuestionById( + id: string, + input: UpdateQuestionInput, + canEditAll: boolean, + authorId: string +): Promise { + await db.transaction(async (tx) => { + await tx + .update(questions) + .set({ + type: input.type, + difficulty: input.difficulty, + content: input.content, + updatedAt: new Date(), + }) + .where( + canEditAll + ? eq(questions.id, id) + : and(eq(questions.id, id), eq(questions.authorId, authorId)) + ); + + await tx + .delete(questionsToKnowledgePoints) + .where(eq(questionsToKnowledgePoints.questionId, id)); + + if (input.knowledgePointIds && input.knowledgePointIds.length > 0) { + await tx.insert(questionsToKnowledgePoints).values( + input.knowledgePointIds.map((kpId) => ({ + questionId: id, + knowledgePointId: kpId, + })) + ); + } + }); +} + +async function deleteQuestionRecursive(tx: Tx, questionId: string): Promise { + const children = await tx + .select({ id: questions.id }) + .from(questions) + .where(eq(questions.parentId, questionId)); + + for (const child of children) { + await deleteQuestionRecursive(tx, child.id); + } + + await tx.delete(questions).where(eq(questions.id, questionId)); +} + +export async function deleteQuestionByIdRecursive( + questionId: string, + canDeleteAll: boolean, + authorId: string +): Promise { + await db.transaction(async (tx) => { + const q = await tx.query.questions.findFirst({ + where: eq(questions.id, questionId), + }); + + if (!q) { + throw new Error("Question not found"); + } + + if (!canDeleteAll && q.authorId !== authorId) { + throw new Error("Unauthorized"); + } + + await deleteQuestionRecursive(tx, questionId); + }); +} + +export async function getKnowledgePointOptions(): Promise { + const rows = await db + .select({ + id: knowledgePoints.id, + name: knowledgePoints.name, + chapterId: chapters.id, + chapterTitle: chapters.title, + textbookId: textbooks.id, + textbookTitle: textbooks.title, + subject: textbooks.subject, + grade: textbooks.grade, + }) + .from(knowledgePoints) + .leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) + .leftJoin(textbooks, eq(textbooks.id, chapters.textbookId)) + .orderBy( + asc(textbooks.title), + asc(chapters.order), + asc(chapters.title), + asc(knowledgePoints.order), + asc(knowledgePoints.name) + ); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + chapterId: row.chapterId ?? null, + chapterTitle: row.chapterTitle ?? null, + textbookId: row.textbookId ?? null, + textbookTitle: row.textbookTitle ?? null, + subject: row.subject ?? null, + grade: row.grade ?? null, + })); +}