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 ( +