From 3855bbd4bec53202f18a93f49aab8ffceb5ed590 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Wed, 8 Apr 2026 14:01:55 +0500 Subject: [PATCH] Add homework review workflow: statuses, audio, file attachments, tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HomeworkSubmission: add status (PENDING/REVIEWING/APPROVED/REJECTED) + statusAt - HomeworkFeedback: add files (Json) + audioUrl fields - Curator detail page: meta table, content tabs, feedback history with audio/files - FeedbackForm: file upload, audio recorder (Web Audio API + S3), action buttons - AudioRecorder component: record → preview → upload to S3 - ContentTabs: toggle between homework description and lesson content (TipTap read-only) - Homework list: 4-color status badges with proper filtering - API routes: /api/curator/upload and /api/curator/audio-upload Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 2 + .../migration.sql | 2 + prisma/schema.prisma | 16 +- src/app/api/curator/audio-upload/route.ts | 23 ++ src/app/api/curator/upload/route.ts | 23 ++ src/app/curator/dashboard/page.tsx | 2 +- .../homework/[submissionId]/actions.ts | 57 ++++- .../homework/[submissionId]/content-tabs.tsx | 53 ++++ .../homework/[submissionId]/delete-button.tsx | 40 +++ .../homework/[submissionId]/feedback-form.tsx | 192 +++++++++++++-- .../curator/homework/[submissionId]/page.tsx | 228 ++++++++++++++---- src/app/curator/homework/page.tsx | 33 +-- src/components/admin/homework-filters.tsx | 6 +- src/components/curator/audio-recorder.tsx | 132 ++++++++++ src/components/curator/content-viewer.tsx | 32 +++ 15 files changed, 743 insertions(+), 98 deletions(-) create mode 100644 prisma/migrations/20260409100000_add_submission_status/migration.sql create mode 100644 prisma/migrations/20260409200000_add_feedback_fields/migration.sql create mode 100644 src/app/api/curator/audio-upload/route.ts create mode 100644 src/app/api/curator/upload/route.ts create mode 100644 src/app/curator/homework/[submissionId]/content-tabs.tsx create mode 100644 src/app/curator/homework/[submissionId]/delete-button.tsx create mode 100644 src/components/curator/audio-recorder.tsx create mode 100644 src/components/curator/content-viewer.tsx diff --git a/prisma/migrations/20260409100000_add_submission_status/migration.sql b/prisma/migrations/20260409100000_add_submission_status/migration.sql new file mode 100644 index 0000000..0ce5153 --- /dev/null +++ b/prisma/migrations/20260409100000_add_submission_status/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "HomeworkSubmission" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'PENDING'; +ALTER TABLE "HomeworkSubmission" ADD COLUMN "statusAt" TIMESTAMP(3); diff --git a/prisma/migrations/20260409200000_add_feedback_fields/migration.sql b/prisma/migrations/20260409200000_add_feedback_fields/migration.sql new file mode 100644 index 0000000..46f795e --- /dev/null +++ b/prisma/migrations/20260409200000_add_feedback_fields/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "HomeworkFeedback" ADD COLUMN "files" JSONB; +ALTER TABLE "HomeworkFeedback" ADD COLUMN "audioUrl" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index daab18d..6f64e3d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -258,12 +258,14 @@ model Homework { } model HomeworkSubmission { - id String @id @default(cuid()) - homeworkId String - userId String - text String? - files Json? - submittedAt DateTime @default(now()) + id String @id @default(cuid()) + homeworkId String + userId String + text String? + files Json? + status String @default("PENDING") // PENDING | REVIEWING | APPROVED | REJECTED + statusAt DateTime? + submittedAt DateTime @default(now()) homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -275,6 +277,8 @@ model HomeworkFeedback { submissionId String curatorId String text String + files Json? // [{name, url, size}] + audioUrl String? createdAt DateTime @default(now()) submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) diff --git a/src/app/api/curator/audio-upload/route.ts b/src/app/api/curator/audio-upload/route.ts new file mode 100644 index 0000000..a3574f4 --- /dev/null +++ b/src/app/api/curator/audio-upload/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { uploadFile } from "@/lib/s3"; +import { randomUUID } from "crypto"; + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const form = await req.formData(); + const file = form.get("file") as File | null; + if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); + + const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm"; + const key = `feedback-audio/${session.user.id}/${randomUUID()}.${ext}`; + const buffer = Buffer.from(await file.arrayBuffer()); + const url = await uploadFile(key, buffer, file.type || "audio/webm"); + + return NextResponse.json({ url }); +} diff --git a/src/app/api/curator/upload/route.ts b/src/app/api/curator/upload/route.ts new file mode 100644 index 0000000..c6b1625 --- /dev/null +++ b/src/app/api/curator/upload/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { uploadFile } from "@/lib/s3"; +import { randomUUID } from "crypto"; + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const form = await req.formData(); + const file = form.get("file") as File | null; + if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); + + const ext = file.name.split(".").pop() ?? "bin"; + const key = `feedback/${session.user.id}/${randomUUID()}.${ext}`; + const buffer = Buffer.from(await file.arrayBuffer()); + const url = await uploadFile(key, buffer, file.type); + + return NextResponse.json({ name: file.name, url, size: file.size }); +} diff --git a/src/app/curator/dashboard/page.tsx b/src/app/curator/dashboard/page.tsx index e5c0031..60fe142 100644 --- a/src/app/curator/dashboard/page.tsx +++ b/src/app/curator/dashboard/page.tsx @@ -13,7 +13,7 @@ export default async function CuratorDashboard() { prisma.homeworkSubmission.count(), prisma.homeworkFeedback.count({ where: { - createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, + createdAt: { gte: new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) }, curatorId: session.user.id, }, }), diff --git a/src/app/curator/homework/[submissionId]/actions.ts b/src/app/curator/homework/[submissionId]/actions.ts index bd5d442..513e682 100644 --- a/src/app/curator/homework/[submissionId]/actions.ts +++ b/src/app/curator/homework/[submissionId]/actions.ts @@ -6,17 +6,43 @@ import { headers } from "next/headers"; import { revalidatePath } from "next/cache"; import { sendFeedbackReceivedEmail } from "@/lib/email"; -export async function submitFeedback(submissionId: string, text: string) { +async function requireCurator() { const session = await auth.api.getSession({ headers: await headers() }); if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) { throw new Error("Forbidden"); } + return session; +} - await prisma.homeworkFeedback.create({ - data: { submissionId, curatorId: session.user.id, text }, - }); +export async function submitFeedback( + submissionId: string, + data: { + text: string; + files?: { name: string; url: string; size: number }[]; + audioUrl?: string | null; + action: "approve" | "reject"; + } +) { + const session = await requireCurator(); + const status = data.action === "approve" ? "APPROVED" : "REJECTED"; - // Send email to student + await prisma.$transaction([ + prisma.homeworkFeedback.create({ + data: { + submissionId, + curatorId: session.user.id, + text: data.text, + files: data.files ?? [], + audioUrl: data.audioUrl ?? null, + }, + }), + prisma.homeworkSubmission.update({ + where: { id: submissionId }, + data: { status, statusAt: new Date() }, + }), + ]); + + // Send email notification to student const submission = await prisma.homeworkSubmission.findUnique({ where: { id: submissionId }, include: { @@ -26,6 +52,7 @@ export async function submitFeedback(submissionId: string, text: string) { lesson: { select: { title: true, + id: true, module: { select: { course: { select: { slug: true } } } }, }, }, @@ -36,12 +63,12 @@ export async function submitFeedback(submissionId: string, text: string) { if (submission) { const { lesson } = submission.homework; - const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${submission.homework.lessonId}`; + const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${lesson.id}`; await sendFeedbackReceivedEmail( submission.user.email, submission.user.name, lesson.title, - text, + data.text, lessonUrl ); } @@ -49,3 +76,19 @@ export async function submitFeedback(submissionId: string, text: string) { revalidatePath("/curator/homework"); revalidatePath(`/curator/homework/${submissionId}`); } + +export async function setReviewing(submissionId: string) { + await requireCurator(); + await prisma.homeworkSubmission.update({ + where: { id: submissionId }, + data: { status: "REVIEWING", statusAt: new Date() }, + }); + revalidatePath("/curator/homework"); + revalidatePath(`/curator/homework/${submissionId}`); +} + +export async function deleteSubmission(submissionId: string) { + await requireCurator(); + await prisma.homeworkSubmission.delete({ where: { id: submissionId } }); + revalidatePath("/curator/homework"); +} diff --git a/src/app/curator/homework/[submissionId]/content-tabs.tsx b/src/app/curator/homework/[submissionId]/content-tabs.tsx new file mode 100644 index 0000000..34191dc --- /dev/null +++ b/src/app/curator/homework/[submissionId]/content-tabs.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState } from "react"; +import { ContentViewer } from "@/components/curator/content-viewer"; + +interface Props { + homeworkDescription: string; + lessonContent: unknown; +} + +export function ContentTabs({ homeworkDescription, lessonContent }: Props) { + const [tab, setTab] = useState<"homework" | "lesson">("homework"); + + return ( +
+ {/* Tab bar */} +
+ {(["homework", "lesson"] as const).map((t) => { + const label = t === "homework" ? "Содержимое ДЗ" : "Содержимое урока"; + const active = tab === t; + return ( + + ); + })} +
+ + {/* Content */} +
+ {tab === "homework" ? ( +
{homeworkDescription}
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/app/curator/homework/[submissionId]/delete-button.tsx b/src/app/curator/homework/[submissionId]/delete-button.tsx new file mode 100644 index 0000000..e82df41 --- /dev/null +++ b/src/app/curator/homework/[submissionId]/delete-button.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { deleteSubmission } from "./actions"; + +export function DeleteSubmissionButton({ + submissionId, + userName, +}: { + submissionId: string; + userName: string; +}) { + const [pending, startTransition] = useTransition(); + const router = useRouter(); + + function handleDelete() { + if (!confirm(`Удалить работу студента ${userName}? Это действие нельзя отменить.`)) return; + startTransition(async () => { + await deleteSubmission(submissionId); + router.push("/curator/homework"); + }); + } + + return ( + + ); +} diff --git a/src/app/curator/homework/[submissionId]/feedback-form.tsx b/src/app/curator/homework/[submissionId]/feedback-form.tsx index a0f6042..19598cc 100644 --- a/src/app/curator/homework/[submissionId]/feedback-form.tsx +++ b/src/app/curator/homework/[submissionId]/feedback-form.tsx @@ -1,33 +1,91 @@ "use client"; -import { useState, useTransition } from "react"; +import { useState, useTransition, useRef } from "react"; import { useRouter } from "next/navigation"; -import { submitFeedback } from "./actions"; +import Link from "next/link"; +import { submitFeedback, setReviewing } from "./actions"; +import { AudioRecorder } from "@/components/curator/audio-recorder"; -export function FeedbackForm({ submissionId }: { submissionId: string }) { +interface FileItem { + name: string; + url: string; + size: number; +} + +function formatSize(bytes: number) { + if (bytes < 1024) return `${bytes} Б`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`; + return `${(bytes / 1024 / 1024).toFixed(1)} МБ`; +} + +export function FeedbackForm({ + submissionId, + currentStatus, +}: { + submissionId: string; + currentStatus: string; +}) { const [text, setText] = useState(""); + const [files, setFiles] = useState([]); + const [audioUrl, setAudioUrl] = useState(null); + const [uploading, setUploading] = useState(false); const [pending, startTransition] = useTransition(); + const fileInputRef = useRef(null); const router = useRouter(); - function handleSubmit(e: React.FormEvent) { - e.preventDefault(); + async function handleFileChange(e: React.ChangeEvent) { + const picked = Array.from(e.target.files ?? []); + if (!picked.length) return; + setUploading(true); + const uploaded: FileItem[] = []; + for (const f of picked) { + const form = new FormData(); + form.append("file", f); + const res = await fetch("/api/curator/upload", { method: "POST", body: form }); + const data = await res.json(); + if (data.url) uploaded.push({ name: data.name, url: data.url, size: data.size }); + } + setFiles((prev) => [...prev, ...uploaded]); + setUploading(false); + if (fileInputRef.current) fileInputRef.current.value = ""; + } + + function handleAction(action: "approve" | "reject") { if (!text.trim()) return; startTransition(async () => { - await submitFeedback(submissionId, text.trim()); + await submitFeedback(submissionId, { + text: text.trim(), + files, + audioUrl, + action, + }); router.push("/curator/homework"); }); } + function handleReviewing() { + startTransition(async () => { + await setReviewing(submissionId); + }); + } + + const isWorking = pending || uploading; + return ( -
-

- Написать фидбек +

+

+ Ваш ответ

+ + {/* Text */}