refactor: P1-2 actions 层 DB 操作下沉到 data-access (exams/homework/questions/announcements)
This commit is contained in:
@@ -2,16 +2,21 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { eq } from "drizzle-orm"
|
|
||||||
|
|
||||||
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { requireAuth, requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
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 type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
import { CreateAnnouncementSchema, UpdateAnnouncementSchema } from "./schema"
|
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"
|
import type { GetAnnouncementsParams, Announcement } from "./types"
|
||||||
|
|
||||||
export async function createAnnouncementAction(
|
export async function createAnnouncementAction(
|
||||||
@@ -49,9 +54,8 @@ export async function createAnnouncementAction(
|
|||||||
? new Date(input.publishedAt)
|
? new Date(input.publishedAt)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const id = createId()
|
const id = await insertAnnouncement({
|
||||||
await db.insert(announcements).values({
|
id: createId(),
|
||||||
id,
|
|
||||||
title: input.title,
|
title: input.title,
|
||||||
content: input.content,
|
content: input.content,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
@@ -105,7 +109,6 @@ export async function updateAnnouncementAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const input = parsed.data
|
const input = parsed.data
|
||||||
const wasPublished = existing.status === "published"
|
|
||||||
const isPublished = input.status === "published"
|
const isPublished = input.status === "published"
|
||||||
const publishedAt = isPublished
|
const publishedAt = isPublished
|
||||||
? existing.publishedAt
|
? existing.publishedAt
|
||||||
@@ -115,9 +118,7 @@ export async function updateAnnouncementAction(
|
|||||||
? new Date(input.publishedAt)
|
? new Date(input.publishedAt)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
await db
|
await updateAnnouncementById(id, {
|
||||||
.update(announcements)
|
|
||||||
.set({
|
|
||||||
title: input.title,
|
title: input.title,
|
||||||
content: input.content,
|
content: input.content,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
@@ -127,12 +128,10 @@ export async function updateAnnouncementAction(
|
|||||||
publishedAt,
|
publishedAt,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(announcements.id, id))
|
|
||||||
|
|
||||||
revalidatePath("/admin/announcements")
|
revalidatePath("/admin/announcements")
|
||||||
revalidatePath(`/admin/announcements/${id}`)
|
revalidatePath(`/admin/announcements/${id}`)
|
||||||
revalidatePath("/announcements")
|
revalidatePath("/announcements")
|
||||||
void wasPublished
|
|
||||||
|
|
||||||
return { success: true, message: "Announcement updated", data: id }
|
return { success: true, message: "Announcement updated", data: id }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -151,7 +150,7 @@ export async function deleteAnnouncementAction(id: string): Promise<ActionState<
|
|||||||
const existing = await getAnnouncementById(id)
|
const existing = await getAnnouncementById(id)
|
||||||
if (!existing) return { success: false, message: "Announcement not found" }
|
if (!existing) return { success: false, message: "Announcement not found" }
|
||||||
|
|
||||||
await db.delete(announcements).where(eq(announcements.id, id))
|
await deleteAnnouncementById(id)
|
||||||
|
|
||||||
revalidatePath("/admin/announcements")
|
revalidatePath("/admin/announcements")
|
||||||
revalidatePath("/announcements")
|
revalidatePath("/announcements")
|
||||||
@@ -173,14 +172,10 @@ export async function publishAnnouncementAction(id: string): Promise<ActionState
|
|||||||
const existing = await getAnnouncementById(id)
|
const existing = await getAnnouncementById(id)
|
||||||
if (!existing) return { success: false, message: "Announcement not found" }
|
if (!existing) return { success: false, message: "Announcement not found" }
|
||||||
|
|
||||||
await db
|
const publishedAt = existing.publishedAt
|
||||||
.update(announcements)
|
? new Date(existing.publishedAt)
|
||||||
.set({
|
: new Date()
|
||||||
status: "published",
|
await publishAnnouncementById(id, publishedAt)
|
||||||
publishedAt: existing.publishedAt ? new Date(existing.publishedAt) : new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(announcements.id, id))
|
|
||||||
|
|
||||||
revalidatePath("/admin/announcements")
|
revalidatePath("/admin/announcements")
|
||||||
revalidatePath(`/admin/announcements/${id}`)
|
revalidatePath(`/admin/announcements/${id}`)
|
||||||
@@ -203,13 +198,7 @@ export async function archiveAnnouncementAction(id: string): Promise<ActionState
|
|||||||
const existing = await getAnnouncementById(id)
|
const existing = await getAnnouncementById(id)
|
||||||
if (!existing) return { success: false, message: "Announcement not found" }
|
if (!existing) return { success: false, message: "Announcement not found" }
|
||||||
|
|
||||||
await db
|
await archiveAnnouncementById(id)
|
||||||
.update(announcements)
|
|
||||||
.set({
|
|
||||||
status: "archived",
|
|
||||||
updatedAt: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(announcements.id, id))
|
|
||||||
|
|
||||||
revalidatePath("/admin/announcements")
|
revalidatePath("/admin/announcements")
|
||||||
revalidatePath(`/admin/announcements/${id}`)
|
revalidatePath(`/admin/announcements/${id}`)
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { db } from "@/shared/db"
|
|||||||
import { announcements, users } from "@/shared/db/schema"
|
import { announcements, users } from "@/shared/db/schema"
|
||||||
import type {
|
import type {
|
||||||
Announcement,
|
Announcement,
|
||||||
|
AnnouncementInsertData,
|
||||||
AnnouncementStatus,
|
AnnouncementStatus,
|
||||||
AnnouncementType,
|
AnnouncementType,
|
||||||
|
AnnouncementUpdateData,
|
||||||
GetAnnouncementsParams,
|
GetAnnouncementsParams,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
@@ -118,3 +120,67 @@ export const getAnnouncementById = cache(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export async function insertAnnouncement(
|
||||||
|
data: AnnouncementInsertData
|
||||||
|
): Promise<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
await db.delete(announcements).where(eq(announcements.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishAnnouncementById(
|
||||||
|
id: string,
|
||||||
|
publishedAt: Date
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(announcements)
|
||||||
|
.set({
|
||||||
|
status: "published",
|
||||||
|
publishedAt,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(announcements.id, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveAnnouncementById(id: string): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(announcements)
|
||||||
|
.set({
|
||||||
|
status: "archived",
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(announcements.id, id))
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,3 +25,26 @@ export type GetAnnouncementsParams = {
|
|||||||
page?: number
|
page?: number
|
||||||
pageSize?: 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,19 @@ import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guar
|
|||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { db } from "@/shared/db"
|
import {
|
||||||
import { exams, examQuestions } from "@/shared/db/schema"
|
buildExamDescription,
|
||||||
import { eq } from "drizzle-orm"
|
deleteExamById,
|
||||||
import { buildExamDescription, omitScheduledAtFromDescription, persistAiGeneratedExamDraft, persistExamDraft, resolveSubjectGradeNames } from "./data-access"
|
duplicateExam,
|
||||||
|
getExamCreatorId,
|
||||||
|
getExamGrades,
|
||||||
|
getExamPreview,
|
||||||
|
getExamSubjects,
|
||||||
|
persistAiGeneratedExamDraft,
|
||||||
|
persistExamDraft,
|
||||||
|
resolveSubjectGradeNames,
|
||||||
|
updateExamWithQuestions,
|
||||||
|
} from "./data-access"
|
||||||
import {
|
import {
|
||||||
AiGeneratedStructureSchema,
|
AiGeneratedStructureSchema,
|
||||||
AiInsertQuestionSchema,
|
AiInsertQuestionSchema,
|
||||||
@@ -568,39 +577,18 @@ export async function updateExamAction(
|
|||||||
|
|
||||||
// Ownership check: non-admin users can only update their own exams
|
// Ownership check: non-admin users can only update their own exams
|
||||||
if (ctx.dataScope.type !== "all") {
|
if (ctx.dataScope.type !== "all") {
|
||||||
const exam = await db.query.exams.findFirst({
|
const creatorId = await getExamCreatorId(examId)
|
||||||
where: eq(exams.id, examId),
|
if (!creatorId || creatorId !== ctx.userId) {
|
||||||
columns: { creatorId: true },
|
|
||||||
})
|
|
||||||
if (!exam || exam.creatorId !== ctx.userId) {
|
|
||||||
return failState<string>("You can only update exams you created")
|
return failState<string>("You can only update exams you created")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (questions) {
|
await updateExamWithQuestions(examId, {
|
||||||
await db.delete(examQuestions).where(eq(examQuestions.examId, examId))
|
questions: questions ?? undefined,
|
||||||
if (questions.length > 0) {
|
structure,
|
||||||
await db.insert(examQuestions).values(
|
status,
|
||||||
questions.map((q, idx) => ({
|
})
|
||||||
examId,
|
|
||||||
questionId: q.id,
|
|
||||||
score: q.score ?? 0,
|
|
||||||
order: idx,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare update object
|
|
||||||
const updateData: Partial<typeof exams.$inferInsert> = {}
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
return failState<string>("Database error: Failed to update exam")
|
return failState<string>("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
|
// Ownership check: non-admin users can only delete their own exams
|
||||||
if (ctx.dataScope.type !== "all") {
|
if (ctx.dataScope.type !== "all") {
|
||||||
const exam = await db.query.exams.findFirst({
|
const creatorId = await getExamCreatorId(examId)
|
||||||
where: eq(exams.id, examId),
|
if (!creatorId || creatorId !== ctx.userId) {
|
||||||
columns: { creatorId: true },
|
|
||||||
})
|
|
||||||
if (!exam || exam.creatorId !== ctx.userId) {
|
|
||||||
return failState<string>("You can only delete exams you created")
|
return failState<string>("You can only delete exams you created")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.delete(exams).where(eq(exams.id, examId))
|
await deleteExamById(examId)
|
||||||
} catch {
|
} catch {
|
||||||
return failState<string>("Database error: Failed to delete exam")
|
return failState<string>("Database error: Failed to delete exam")
|
||||||
}
|
}
|
||||||
@@ -692,45 +677,13 @@ export async function duplicateExamAction(
|
|||||||
|
|
||||||
const { examId } = parsed.data
|
const { examId } = parsed.data
|
||||||
|
|
||||||
const source = await db.query.exams.findFirst({
|
let newExamId: string
|
||||||
where: eq(exams.id, examId),
|
try {
|
||||||
with: {
|
const duplicatedId = await duplicateExam(examId, ctx.userId)
|
||||||
questions: {
|
if (!duplicatedId) {
|
||||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!source) {
|
|
||||||
return failState<string>("Exam not found")
|
return failState<string>("Exam not found")
|
||||||
}
|
}
|
||||||
|
newExamId = duplicatedId
|
||||||
const newExamId = createId()
|
|
||||||
|
|
||||||
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,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} catch {
|
} catch {
|
||||||
return failState<string>("Database error: Failed to duplicate exam")
|
return failState<string>("Database error: Failed to duplicate exam")
|
||||||
}
|
}
|
||||||
@@ -753,25 +706,14 @@ export async function getExamPreviewAction(
|
|||||||
await requirePermission(Permissions.EXAM_READ)
|
await requirePermission(Permissions.EXAM_READ)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const exam = await db.query.exams.findFirst({
|
const exam = await getExamPreview(examId)
|
||||||
where: eq(exams.id, examId),
|
|
||||||
with: {
|
|
||||||
questions: {
|
|
||||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
|
||||||
with: {
|
|
||||||
question: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!exam) {
|
if (!exam) {
|
||||||
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
|
return failState<{ structure: unknown; questions: Array<{ id: string }> }>("Exam not found")
|
||||||
}
|
}
|
||||||
const questions = exam.questions.map((eq) => eq.question)
|
|
||||||
return successState({
|
return successState({
|
||||||
structure: exam.structure,
|
structure: exam.structure,
|
||||||
questions,
|
questions: exam.questions,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
@@ -790,11 +732,8 @@ export async function getSubjectsAction(): Promise<ActionState<{ id: string; nam
|
|||||||
await requirePermission(Permissions.EXAM_READ)
|
await requirePermission(Permissions.EXAM_READ)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allSubjects = await db.query.subjects.findMany({
|
const allSubjects = await getExamSubjects()
|
||||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
return successState(allSubjects)
|
||||||
})
|
|
||||||
|
|
||||||
return successState(allSubjects.map((s) => ({ id: s.id, name: s.name })))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch subjects:", error)
|
console.error("Failed to fetch subjects:", error)
|
||||||
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
return failState<{ id: string; name: string }[]>("Failed to load subjects")
|
||||||
@@ -812,11 +751,8 @@ export async function getGradesAction(): Promise<ActionState<{ id: string; name:
|
|||||||
await requirePermission(Permissions.EXAM_READ)
|
await requirePermission(Permissions.EXAM_READ)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allGrades = await db.query.grades.findMany({
|
const allGrades = await getExamGrades()
|
||||||
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
return successState(allGrades)
|
||||||
})
|
|
||||||
|
|
||||||
return successState(allGrades.map((g) => ({ id: g.id, name: g.name })))
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch grades:", error)
|
console.error("Failed to fetch grades:", error)
|
||||||
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
return failState<{ id: string; name: string }[]>("Failed to load grades")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { db } from "@/shared/db"
|
|||||||
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
|
import { exams, examQuestions, questions, subjects, grades, classes } from "@/shared/db/schema"
|
||||||
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
|
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
|
||||||
import { cache } from "react"
|
import { cache } from "react"
|
||||||
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
|
|
||||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||||
@@ -371,3 +372,153 @@ export const getExamsDashboardStats = cache(async (scope?: DataScope): Promise<E
|
|||||||
|
|
||||||
return { examCount: Number(row?.value ?? 0) }
|
return { examCount: Number(row?.value ?? 0) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get exam creator ID for ownership check.
|
||||||
|
* Returns null if exam not found.
|
||||||
|
*/
|
||||||
|
export const getExamCreatorId = async (examId: string): Promise<string | null> => {
|
||||||
|
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<void> => {
|
||||||
|
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<typeof exams.$inferInsert> = {}
|
||||||
|
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<void> => {
|
||||||
|
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<string | null> => {
|
||||||
|
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<Array<{ id: string; name: string }>> => {
|
||||||
|
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<Array<{ id: string; name: string }>> => {
|
||||||
|
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 }))
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,25 +2,24 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache"
|
import { revalidatePath } from "next/cache"
|
||||||
import { createId } from "@paralleldrive/cuid2"
|
import { createId } from "@paralleldrive/cuid2"
|
||||||
import { and, count, eq } from "drizzle-orm"
|
|
||||||
|
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
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 type { ActionState } from "@/shared/types/action-state"
|
||||||
|
|
||||||
import { CreateHomeworkAssignmentSchema, GradeHomeworkSchema } from "./schema"
|
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[] => {
|
const parseStudentIds = (raw: string): string[] => {
|
||||||
return raw
|
return raw
|
||||||
@@ -69,64 +68,32 @@ export async function createHomeworkAssignmentAction(
|
|||||||
const input = parsed.data
|
const input = parsed.data
|
||||||
const publish = input.publish ?? true
|
const publish = input.publish ?? true
|
||||||
|
|
||||||
const [classRow] = await db
|
const classRow = await getClassTeacherById(input.classId)
|
||||||
.select({ id: classes.id, teacherId: classes.teacherId })
|
|
||||||
.from(classes)
|
|
||||||
.where(eq(classes.id, input.classId))
|
|
||||||
.limit(1)
|
|
||||||
if (!classRow) return { success: false, message: "Class not found" }
|
if (!classRow) return { success: false, message: "Class not found" }
|
||||||
|
|
||||||
const exam = await db.query.exams.findFirst({
|
const exam = await getExamWithQuestionsForHomework(input.sourceExamId)
|
||||||
where: eq(exams.id, input.sourceExamId),
|
|
||||||
with: {
|
|
||||||
questions: {
|
|
||||||
orderBy: (examQuestions, { asc }) => [asc(examQuestions.order)],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!exam) return { success: false, message: "Exam not found" }
|
if (!exam) return { success: false, message: "Exam not found" }
|
||||||
|
|
||||||
if (ctx.dataScope.type !== "all" && classRow.teacherId !== ctx.userId) {
|
if (ctx.dataScope.type !== "all" && classRow.teacherId !== ctx.userId) {
|
||||||
const assignedSubjectRows = await db
|
const assignedSubjectIds = await getTeacherAssignedSubjectIds(input.classId, ctx.userId)
|
||||||
.select({ subjectId: classSubjectTeachers.subjectId })
|
if (assignedSubjectIds.length === 0) {
|
||||||
.from(classSubjectTeachers)
|
|
||||||
.where(and(eq(classSubjectTeachers.classId, input.classId), eq(classSubjectTeachers.teacherId, ctx.userId)))
|
|
||||||
if (assignedSubjectRows.length === 0) {
|
|
||||||
return { success: false, message: "Not assigned to this class" }
|
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) {
|
if (!exam.subjectId) {
|
||||||
return { success: false, message: "Exam subject not set" }
|
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" }
|
return { success: false, message: "Not assigned to this subject" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignmentId = createId()
|
const classStudentIds = await getActiveClassStudentIdsForHomework(
|
||||||
|
input.classId,
|
||||||
const availableAt = input.availableAt ? new Date(input.availableAt) : null
|
ctx.dataScope,
|
||||||
const dueAt = input.dueAt ? new Date(input.dueAt) : null
|
ctx.userId,
|
||||||
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
|
classRow.teacherId
|
||||||
|
|
||||||
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 classStudentIdSet = new Set(classStudentIds)
|
const classStudentIdSet = new Set(classStudentIds)
|
||||||
|
|
||||||
const targetStudentIds =
|
const targetStudentIds =
|
||||||
@@ -138,13 +105,17 @@ export async function createHomeworkAssignmentAction(
|
|||||||
return { success: false, message: "No active students in this class" }
|
return { success: false, message: "No active students in this class" }
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
const assignmentId = createId()
|
||||||
await tx.insert(homeworkAssignments).values({
|
const availableAt = input.availableAt ? new Date(input.availableAt) : null
|
||||||
id: assignmentId,
|
const dueAt = input.dueAt ? new Date(input.dueAt) : null
|
||||||
|
const lateDueAt = input.lateDueAt ? new Date(input.lateDueAt) : null
|
||||||
|
|
||||||
|
await createHomeworkAssignment({
|
||||||
|
assignmentId,
|
||||||
sourceExamId: input.sourceExamId,
|
sourceExamId: input.sourceExamId,
|
||||||
title: input.title?.trim().length ? input.title.trim() : exam.title,
|
title: input.title?.trim().length ? input.title.trim() : exam.title,
|
||||||
description: input.description ?? null,
|
description: input.description ?? null,
|
||||||
structure: publish ? (exam.structure as unknown) : null,
|
structure: exam.structure,
|
||||||
status: publish ? "published" : "draft",
|
status: publish ? "published" : "draft",
|
||||||
creatorId: ctx.userId,
|
creatorId: ctx.userId,
|
||||||
availableAt,
|
availableAt,
|
||||||
@@ -152,27 +123,9 @@ export async function createHomeworkAssignmentAction(
|
|||||||
allowLate: input.allowLate ?? false,
|
allowLate: input.allowLate ?? false,
|
||||||
lateDueAt,
|
lateDueAt,
|
||||||
maxAttempts: input.maxAttempts ?? 1,
|
maxAttempts: input.maxAttempts ?? 1,
|
||||||
})
|
publish,
|
||||||
|
questions: exam.questions,
|
||||||
if (publish && exam.questions.length > 0) {
|
targetStudentIds,
|
||||||
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/assignments")
|
||||||
@@ -197,40 +150,14 @@ export async function startHomeworkSubmissionAction(
|
|||||||
const assignmentId = formData.get("assignmentId")
|
const assignmentId = formData.get("assignmentId")
|
||||||
if (typeof assignmentId !== "string" || assignmentId.length === 0) return { success: false, message: "Missing assignmentId" }
|
if (typeof assignmentId !== "string" || assignmentId.length === 0) return { success: false, message: "Missing assignmentId" }
|
||||||
|
|
||||||
const assignment = await db.query.homeworkAssignments.findFirst({
|
const result = await startHomeworkSubmission(assignmentId, ctx.userId)
|
||||||
where: eq(homeworkAssignments.id, assignmentId),
|
if ("error" in result) {
|
||||||
})
|
return { success: false, message: result.error }
|
||||||
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(),
|
|
||||||
})
|
|
||||||
|
|
||||||
revalidatePath("/student/learning/assignments")
|
revalidatePath("/student/learning/assignments")
|
||||||
|
|
||||||
return { success: true, message: "Started", data: submissionId }
|
return { success: true, message: "Started", data: result.submissionId }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
if (e instanceof PermissionDeniedError) {
|
||||||
return { success: false, message: e.message }
|
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 submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
|
||||||
if (typeof questionId !== "string" || questionId.length === 0) return { success: false, message: "Missing questionId" }
|
if (typeof questionId !== "string" || questionId.length === 0) return { success: false, message: "Missing questionId" }
|
||||||
|
|
||||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
const submission = await getHomeworkSubmissionForPermission(submissionId)
|
||||||
where: eq(homeworkSubmissions.id, submissionId),
|
|
||||||
with: { assignment: true },
|
|
||||||
})
|
|
||||||
if (!submission) return { success: false, message: "Submission not found" }
|
if (!submission) return { success: false, message: "Submission not found" }
|
||||||
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
||||||
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
|
if (submission.status !== "started") return { success: false, message: "Submission is locked" }
|
||||||
|
|
||||||
const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null
|
const payload = typeof answerJson === "string" && answerJson.length > 0 ? JSON.parse(answerJson) : null
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await saveHomeworkAnswer(submissionId, questionId, payload)
|
||||||
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 }
|
return { success: true, message: "Saved", data: submissionId }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -301,10 +207,7 @@ export async function submitHomeworkAction(
|
|||||||
const submissionId = formData.get("submissionId")
|
const submissionId = formData.get("submissionId")
|
||||||
if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
|
if (typeof submissionId !== "string" || submissionId.length === 0) return { success: false, message: "Missing submissionId" }
|
||||||
|
|
||||||
const submission = await db.query.homeworkSubmissions.findFirst({
|
const submission = await getHomeworkSubmissionForPermission(submissionId)
|
||||||
where: eq(homeworkSubmissions.id, submissionId),
|
|
||||||
with: { assignment: true },
|
|
||||||
})
|
|
||||||
if (!submission) return { success: false, message: "Submission not found" }
|
if (!submission) return { success: false, message: "Submission not found" }
|
||||||
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
if (submission.studentId !== ctx.userId) return { success: false, message: "Unauthorized" }
|
||||||
if (submission.status !== "started") return { success: false, message: "Already submitted" }
|
if (submission.status !== "started") return { success: false, message: "Already submitted" }
|
||||||
@@ -319,10 +222,7 @@ export async function submitHomeworkAction(
|
|||||||
|
|
||||||
const isLate = Boolean(dueAt && now > dueAt)
|
const isLate = Boolean(dueAt && now > dueAt)
|
||||||
|
|
||||||
await db
|
await markHomeworkSubmitted(submissionId, isLate)
|
||||||
.update(homeworkSubmissions)
|
|
||||||
.set({ status: "submitted", submittedAt: now, isLate, updatedAt: now })
|
|
||||||
.where(eq(homeworkSubmissions.id, submissionId))
|
|
||||||
|
|
||||||
revalidatePath("/teacher/homework/submissions")
|
revalidatePath("/teacher/homework/submissions")
|
||||||
revalidatePath("/student/learning/assignments")
|
revalidatePath("/student/learning/assignments")
|
||||||
@@ -359,20 +259,15 @@ export async function gradeHomeworkSubmissionAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { submissionId, answers } = parsed.data
|
const { submissionId, answers } = parsed.data
|
||||||
let totalScore = 0
|
|
||||||
|
|
||||||
for (const ans of answers) {
|
await gradeHomeworkAnswers(
|
||||||
await db
|
submissionId,
|
||||||
.update(homeworkAnswers)
|
answers.map((ans) => ({
|
||||||
.set({ score: ans.score, feedback: ans.feedback ?? null, updatedAt: new Date() })
|
id: ans.id,
|
||||||
.where(eq(homeworkAnswers.id, ans.id))
|
score: ans.score,
|
||||||
totalScore += ans.score
|
feedback: ans.feedback ?? null,
|
||||||
}
|
}))
|
||||||
|
)
|
||||||
await db
|
|
||||||
.update(homeworkSubmissions)
|
|
||||||
.set({ score: totalScore, status: "graded", updatedAt: new Date() })
|
|
||||||
.where(eq(homeworkSubmissions.id, submissionId))
|
|
||||||
|
|
||||||
revalidatePath("/teacher/homework/submissions")
|
revalidatePath("/teacher/homework/submissions")
|
||||||
|
|
||||||
|
|||||||
317
src/modules/homework/data-access-write.ts
Normal file
317
src/modules/homework/data-access-write.ts
Normal file
@@ -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<HomeworkExamData | null> => {
|
||||||
|
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<string[]> => {
|
||||||
|
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<string[]> => {
|
||||||
|
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<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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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> => {
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -2,55 +2,21 @@
|
|||||||
|
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||||
import { Permissions } from "@/shared/types/permissions";
|
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 { CreateQuestionSchema } from "./schema";
|
||||||
import type { CreateQuestionInput } from "./schema";
|
import type { CreateQuestionInput } from "./schema";
|
||||||
import { ActionState } from "@/shared/types/action-state";
|
import { ActionState } from "@/shared/types/action-state";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { createId } from "@paralleldrive/cuid2";
|
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
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";
|
import type { KnowledgePointOption } from "./types";
|
||||||
|
|
||||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
|
|
||||||
|
|
||||||
async function insertQuestionWithRelations(
|
|
||||||
tx: Tx,
|
|
||||||
input: z.infer<typeof CreateQuestionSchema>,
|
|
||||||
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(
|
export async function createNestedQuestion(
|
||||||
prevState: ActionState<string> | undefined,
|
prevState: ActionState<string> | undefined,
|
||||||
formData: FormData | CreateQuestionInput
|
formData: FormData | CreateQuestionInput
|
||||||
@@ -81,9 +47,7 @@ export async function createNestedQuestion(
|
|||||||
|
|
||||||
const input = validatedFields.data;
|
const input = validatedFields.data;
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await createQuestionWithRelations(input, ctx.userId);
|
||||||
await insertQuestionWithRelations(tx, input, ctx.userId, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/teacher/questions");
|
revalidatePath("/teacher/questions");
|
||||||
|
|
||||||
@@ -114,7 +78,7 @@ const UpdateQuestionSchema = z.object({
|
|||||||
id: z.string().min(1),
|
id: z.string().min(1),
|
||||||
type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]),
|
type: z.enum(["single_choice", "multiple_choice", "text", "judgment", "composite"]),
|
||||||
difficulty: z.number().min(1).max(5),
|
difficulty: z.number().min(1).max(5),
|
||||||
content: z.any(),
|
content: z.unknown(),
|
||||||
knowledgePointIds: z.array(z.string()).optional(),
|
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 updateQuestionById(id, updateData, canEditAll, ctx.userId);
|
||||||
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,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/teacher/questions");
|
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(
|
export async function deleteQuestionAction(
|
||||||
prevState: ActionState<string> | undefined,
|
prevState: ActionState<string> | undefined,
|
||||||
formData: FormData
|
formData: FormData
|
||||||
@@ -207,21 +135,7 @@ export async function deleteQuestionAction(
|
|||||||
return { success: false, message: "Invalid question ID" };
|
return { success: false, message: "Invalid question ID" };
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await deleteQuestionByIdRecursive(questionId, canDeleteAll, ctx.userId);
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
||||||
revalidatePath("/teacher/questions");
|
revalidatePath("/teacher/questions");
|
||||||
|
|
||||||
@@ -252,39 +166,7 @@ export async function getQuestionsAction(params: GetQuestionsParams) {
|
|||||||
export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOption[]> {
|
export async function getKnowledgePointOptionsAction(): Promise<KnowledgePointOption[]> {
|
||||||
try {
|
try {
|
||||||
await requirePermission(Permissions.QUESTION_READ);
|
await requirePermission(Permissions.QUESTION_READ);
|
||||||
|
return await getKnowledgePointOptions();
|
||||||
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,
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
if (e instanceof PermissionDeniedError) {
|
||||||
throw e;
|
throw e;
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import 'server-only';
|
import 'server-only';
|
||||||
|
|
||||||
import { db } from "@/shared/db";
|
import { db } from "@/shared/db";
|
||||||
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
import { chapters, knowledgePoints, questions, questionsToKnowledgePoints, textbooks } from "@/shared/db/schema";
|
||||||
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
import { and, asc, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
|
||||||
import { cache } from "react";
|
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<Parameters<typeof db.transaction>[0]>[0]
|
||||||
|
|
||||||
|
export type UpdateQuestionInput = {
|
||||||
|
type: QuestionType
|
||||||
|
difficulty: number
|
||||||
|
content: unknown
|
||||||
|
knowledgePointIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type GetQuestionsParams = {
|
export type GetQuestionsParams = {
|
||||||
q?: string;
|
q?: string;
|
||||||
@@ -136,3 +147,153 @@ export const getQuestionsDashboardStats = cache(async (): Promise<QuestionsDashb
|
|||||||
const [row] = await db.select({ value: count() }).from(questions)
|
const [row] = await db.select({ value: count() }).from(questions)
|
||||||
return { questionCount: Number(row?.value ?? 0) }
|
return { questionCount: Number(row?.value ?? 0) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function insertQuestionWithRelations(
|
||||||
|
tx: Tx,
|
||||||
|
input: CreateQuestionInput,
|
||||||
|
authorId: string,
|
||||||
|
parentId: string | null = null
|
||||||
|
): Promise<string> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<KnowledgePointOption[]> {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user