From 48a93989058e670c845b4ce61ac6f3ff8c7e5c06 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Wed, 8 Apr 2026 14:17:46 +0500 Subject: [PATCH] Add optional audio response for students in homework submissions - Course: add allowAudio toggle (per-course setting, off by default) - HomeworkSubmission: add audioUrl field - Student: AudioRecorder in homework form when allowAudio is enabled - Student: show audio player in submission view and curator feedback view - Curator: show student audio on submission detail page - AudioRecorder: accept uploadUrl prop (reused for student/curator) - API: /api/student/audio-upload route for S3 upload Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 2 + prisma/schema.prisma | 2 + .../lessons/[lessonId]/homework-actions.ts | 7 +- .../[slug]/lessons/[lessonId]/page.tsx | 14 +++- src/app/admin/courses/actions.ts | 3 +- src/app/api/student/audio-upload/route.ts | 21 +++++ .../curator/homework/[submissionId]/page.tsx | 13 ++++ src/components/admin/course-edit-form.tsx | 41 +++++++--- src/components/curator/audio-recorder.tsx | 5 +- src/components/student/homework-section.tsx | 77 +++++++++++++++---- 10 files changed, 152 insertions(+), 33 deletions(-) create mode 100644 prisma/migrations/20260409300000_add_audio_fields/migration.sql create mode 100644 src/app/api/student/audio-upload/route.ts diff --git a/prisma/migrations/20260409300000_add_audio_fields/migration.sql b/prisma/migrations/20260409300000_add_audio_fields/migration.sql new file mode 100644 index 0000000..06c1b53 --- /dev/null +++ b/prisma/migrations/20260409300000_add_audio_fields/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Course" ADD COLUMN "allowAudio" BOOLEAN NOT NULL DEFAULT false; +ALTER TABLE "HomeworkSubmission" ADD COLUMN "audioUrl" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6f64e3d..871a3c8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -96,6 +96,7 @@ model Course { description String? coverImage String? published Boolean @default(false) + allowAudio Boolean @default(false) order Int @default(0) categoryId String? createdAt DateTime @default(now()) @@ -265,6 +266,7 @@ model HomeworkSubmission { files Json? status String @default("PENDING") // PENDING | REVIEWING | APPROVED | REJECTED statusAt DateTime? + audioUrl String? submittedAt DateTime @default(now()) homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade) diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts b/src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts index 8f326d6..46711fe 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts @@ -17,7 +17,8 @@ export async function submitHomework( slug: string, lessonId: string, text: string, - files: HomeworkFile[] + files: HomeworkFile[], + audioUrl?: string | null ) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) throw new Error("Unauthorized"); @@ -36,12 +37,12 @@ export async function submitHomework( if (existing) { const updated = await prisma.homeworkSubmission.update({ where: { id: existing.id }, - data: { text, files: files as object[], submittedAt: new Date() }, + data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() }, }); submissionId = updated.id; } else { const created = await prisma.homeworkSubmission.create({ - data: { homeworkId, userId: session.user.id, text, files: files as object[] }, + data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null }, }); submissionId = created.id; diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx index 6c2ecd1..6ef9fec 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx @@ -61,7 +61,12 @@ export default async function LessonPage({ params }: Props) { const homeworkSubmission = lesson?.homework && session && !isAdmin ? await prisma.homeworkSubmission.findFirst({ where: { homeworkId: lesson.homework.id, userId: session.user.id }, - include: { feedbacks: { include: { curator: { select: { name: true } } }, orderBy: { createdAt: "desc" } } }, + include: { + feedbacks: { + include: { curator: { select: { name: true } } }, + orderBy: { createdAt: "asc" }, + }, + }, }) : null; @@ -145,9 +150,16 @@ export default async function LessonPage({ params }: Props) { submission={homeworkSubmission ? { ...homeworkSubmission, files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [], + audioUrl: homeworkSubmission.audioUrl ?? null, + feedbacks: homeworkSubmission.feedbacks.map((fb) => ({ + ...fb, + files: (fb.files as { name: string; url: string; size: number }[]) ?? [], + audioUrl: fb.audioUrl ?? null, + })), } : null} slug={slug} lessonId={lessonId} + allowAudio={lesson.module.course.allowAudio} /> )} diff --git a/src/app/admin/courses/actions.ts b/src/app/admin/courses/actions.ts index 89d03fc..198e123 100644 --- a/src/app/admin/courses/actions.ts +++ b/src/app/admin/courses/actions.ts @@ -43,12 +43,13 @@ export async function updateCourse(courseId: string, formData: FormData) { const slug = formData.get("slug") as string; const description = (formData.get("description") as string) || null; const published = formData.get("published") === "true"; + const allowAudio = formData.get("allowAudio") === "true"; const coverImage = (formData.get("coverImage") as string) || null; const categoryId = (formData.get("categoryId") as string) || null; await prisma.course.update({ where: { id: courseId }, - data: { title, slug, description, published, coverImage, categoryId }, + data: { title, slug, description, published, allowAudio, coverImage, categoryId }, }); revalidatePath("/admin/courses"); diff --git a/src/app/api/student/audio-upload/route.ts b/src/app/api/student/audio-upload/route.ts new file mode 100644 index 0000000..d05c099 --- /dev/null +++ b/src/app/api/student/audio-upload/route.ts @@ -0,0 +1,21 @@ +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) 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 = `homework-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/curator/homework/[submissionId]/page.tsx b/src/app/curator/homework/[submissionId]/page.tsx index 6267ef3..a2ed029 100644 --- a/src/app/curator/homework/[submissionId]/page.tsx +++ b/src/app/curator/homework/[submissionId]/page.tsx @@ -160,6 +160,19 @@ export default async function SubmissionPage({ params }: Props) { )} + {/* Student audio */} + {submission.audioUrl && ( +
+

+ Аудио студента +

+
+ )} + {/* Student files */} {files.length > 0 && (
diff --git a/src/components/admin/course-edit-form.tsx b/src/components/admin/course-edit-form.tsx index 590454e..3d54c4f 100644 --- a/src/components/admin/course-edit-form.tsx +++ b/src/components/admin/course-edit-form.tsx @@ -14,6 +14,7 @@ interface Course { description: string | null; coverImage: string | null; published: boolean; + allowAudio: boolean; categoryId: string | null; } @@ -24,6 +25,7 @@ interface Category { export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) { const [published, setPublished] = useState(course.published); + const [allowAudio, setAllowAudio] = useState(course.allowAudio); const [coverImage, setCoverImage] = useState(course.coverImage ?? ""); const [categoryId, setCategoryId] = useState(course.categoryId ?? ""); const [uploading, setUploading] = useState(false); @@ -45,6 +47,7 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca e.preventDefault(); const fd = new FormData(e.currentTarget); fd.set("published", String(published)); + fd.set("allowAudio", String(allowAudio)); fd.set("coverImage", coverImage); fd.set("categoryId", categoryId); startTransition(() => updateCourse(course.id, fd)); @@ -99,17 +102,33 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca {uploading && Загрузка...}
-
- - {published ? "Опубликован" : "Черновик"} +
+
+ + {published ? "Опубликован" : "Черновик"} +
+
+ + + {allowAudio ? "🎤 Аудио-ответ в ДЗ включён" : "Аудио-ответ в ДЗ выключен"} + +
- {submission!.feedbacks.map((fb) => ( -
-
-

Обратная связь

- - {fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")} - + {submission!.audioUrl && ( +
+

Аудио-ответ:

+
-

{fb.text}

-
- ))} + )} +
+ {submission!.feedbacks.map((fb) => { + const fbFiles = fb.files ?? []; + return ( +
+
+

Обратная связь

+ + {fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")} + +
+

{fb.text}

+ {fbFiles.length > 0 && ( +
+ {fbFiles.map((f) => ( + + 📎{f.name} + {formatSize(f.size)} + + ))} +
+ )} + {fb.audioUrl && ( +
+

Аудио от куратора:

+
+ )} +
+ ); + })}
)} @@ -127,6 +159,12 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props) {submission.text} )} + {submission.audioUrl && ( +
+

Аудио-ответ:

+
+ )} {submission.files.length > 0 && (
{submission.files.map((f) => ( @@ -169,12 +207,21 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
)} + {/* Audio recorder */} + {allowAudio && ( + + )} +