- Add comprehensive audit report for exam and homework module - Create exam-homework i18n message files (zh-CN + en) and register namespace - Add permission check to gradeHomeworkSubmissionAction to prevent horizontal privilege escalation - Add Error Boundary + loading.tsx for 5 key pages (exam build/proctoring, homework assignment/submissions, student assignment) - Refactor exam-columns to createExamColumns(t) factory for i18n support - Refactor exam-data-table to manage columns internally via useTranslations - Replace hardcoded strings with i18n keys in all exam/homework components and pages - Add getHomeworkSubmissionForGrading data-access for secure grading flow
298 lines
8.2 KiB
TypeScript
298 lines
8.2 KiB
TypeScript
import "server-only"
|
|
|
|
import { createId } from "@paralleldrive/cuid2"
|
|
import { and, count, eq } from "drizzle-orm"
|
|
|
|
import { db } from "@/shared/db"
|
|
import {
|
|
homeworkAnswers,
|
|
homeworkAssignmentQuestions,
|
|
homeworkAssignmentTargets,
|
|
homeworkAssignments,
|
|
homeworkSubmissions,
|
|
} from "@/shared/db/schema"
|
|
import {
|
|
getActiveStudentIdsByClassId,
|
|
getClassTeacherById as getClassTeacherIdFromClass,
|
|
getTeacherSubjectIdsByClass,
|
|
} from "@/modules/classes/data-access"
|
|
import {
|
|
getExamWithQuestionsForHomework as getExamWithQuestionsFromExams,
|
|
type ExamWithQuestionsForHomework,
|
|
} from "@/modules/exams/data-access"
|
|
import type { DataScope } from "@/shared/types/permissions"
|
|
|
|
// ---- Types ----
|
|
|
|
export type HomeworkExamQuestionData = {
|
|
questionId: string
|
|
score: number | null
|
|
order: number | null
|
|
}
|
|
|
|
export type HomeworkExamData = ExamWithQuestionsForHomework
|
|
|
|
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 | null
|
|
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) ----
|
|
// These delegate to cross-module data-access interfaces to avoid direct DB queries.
|
|
|
|
export const getClassTeacherById = async (
|
|
classId: string
|
|
): Promise<{ id: string; teacherId: string | null } | null> => {
|
|
const teacherId = await getClassTeacherIdFromClass(classId)
|
|
if (teacherId === null) return null
|
|
return { id: classId, teacherId }
|
|
}
|
|
|
|
export const getExamWithQuestionsForHomework = async (
|
|
examId: string
|
|
): Promise<HomeworkExamData | null> => {
|
|
return await getExamWithQuestionsFromExams(examId)
|
|
}
|
|
|
|
export const getTeacherAssignedSubjectIds = async (
|
|
classId: string,
|
|
teacherId: string
|
|
): Promise<string[]> => {
|
|
return await getTeacherSubjectIdsByClass(classId, teacherId)
|
|
}
|
|
|
|
export const getActiveClassStudentIdsForHomework = async (
|
|
classId: string,
|
|
_dataScope: DataScope,
|
|
_userId: string,
|
|
_classTeacherId: string | null
|
|
): Promise<string[]> => {
|
|
// Permission/scope filtering is handled by requirePermission in actions.ts.
|
|
// This function returns active students for the class via the classes data-access interface.
|
|
return await getActiveStudentIdsByClassId(classId)
|
|
}
|
|
|
|
export const getHomeworkSubmissionForPermission = async (
|
|
submissionId: string
|
|
): Promise<HomeworkSubmissionPermissionData | null> => {
|
|
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,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 批改权限校验:获取提交记录及其作业的创建者信息
|
|
* 用于 gradeHomeworkSubmissionAction 校验教师是否有权批改该提交
|
|
* 返回 null 表示提交记录不存在
|
|
*/
|
|
export const getHomeworkSubmissionForGrading = async (
|
|
submissionId: string
|
|
): Promise<{
|
|
id: string
|
|
assignmentId: string
|
|
creatorId: string
|
|
sourceExamId: string | null
|
|
} | null> => {
|
|
const submission = await db.query.homeworkSubmissions.findFirst({
|
|
where: eq(homeworkSubmissions.id, submissionId),
|
|
with: { assignment: true },
|
|
})
|
|
if (!submission) return null
|
|
return {
|
|
id: submission.id,
|
|
assignmentId: submission.assignmentId,
|
|
creatorId: submission.assignment.creatorId,
|
|
sourceExamId: submission.assignment.sourceExamId,
|
|
}
|
|
}
|
|
|
|
// ---- Write functions ----
|
|
|
|
export const createHomeworkAssignment = async (
|
|
input: CreateHomeworkAssignmentData
|
|
): Promise<string> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
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<void> => {
|
|
await db.transaction(async (tx) => {
|
|
let totalScore = 0
|
|
for (const ans of answers) {
|
|
await tx
|
|
.update(homeworkAnswers)
|
|
.set({ score: ans.score, feedback: ans.feedback, updatedAt: new Date() })
|
|
.where(eq(homeworkAnswers.id, ans.id))
|
|
totalScore += ans.score
|
|
}
|
|
|
|
await tx
|
|
.update(homeworkSubmissions)
|
|
.set({ score: totalScore, status: "graded", updatedAt: new Date() })
|
|
.where(eq(homeworkSubmissions.id, submissionId))
|
|
})
|
|
}
|