diff --git a/src/app/api/questions/[id]/close/route.ts b/src/app/api/questions/[id]/close/route.ts new file mode 100644 index 0000000..91941cf --- /dev/null +++ b/src/app/api/questions/[id]/close/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function PATCH( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + if (session.user.role !== "admin" && session.user.role !== "curator") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const { id } = await params; + + const question = await prisma.studentQuestion.findUnique({ where: { id } }); + if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (question.status === "CLOSED") { + return NextResponse.json({ error: "Already closed" }, { status: 400 }); + } + + const updated = await prisma.studentQuestion.update({ + where: { id }, + data: { status: "CLOSED", closedAt: new Date(), closedById: session.user.id }, + }); + + return NextResponse.json(updated); +} diff --git a/src/app/api/questions/[id]/messages/route.ts b/src/app/api/questions/[id]/messages/route.ts new file mode 100644 index 0000000..58c0bbb --- /dev/null +++ b/src/app/api/questions/[id]/messages/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { sendQuestionCreatedEmail, sendQuestionReplyEmail } from "@/lib/email"; + +interface FileAttachment { + name: string; + url: string; + size: number; +} + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { id } = await params; + const isStaff = session.user.role === "admin" || session.user.role === "curator"; + + const question = await prisma.studentQuestion.findUnique({ + where: { id }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + + if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!isStaff && question.userId !== session.user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json(); + const { text, files } = body as { text: string; files?: FileAttachment[] }; + + if (!text?.trim()) { + return NextResponse.json({ error: "text is required" }, { status: 400 }); + } + + const message = await prisma.studentQuestionMessage.create({ + data: { + questionId: id, + authorId: session.user.id, + text: text.trim(), + files: files?.length ? (files as object[]) : undefined, + }, + include: { author: { select: { id: true, name: true, role: true } } }, + }); + + // Touch question.updatedAt + await prisma.studentQuestion.update({ + where: { id }, + data: { updatedAt: new Date() }, + }); + + // Send notifications + if (isStaff) { + await sendQuestionReplyEmail( + question.user.email, + question.user.name, + question.title, + id, + ); + } else { + const staff = await prisma.user.findMany({ + where: { role: { in: ["admin", "curator"] } }, + select: { email: true, name: true }, + }); + await Promise.all( + staff.map((s) => + sendQuestionCreatedEmail(s.email, s.name, session.user.name, question.title) + ) + ); + } + + return NextResponse.json(message, { status: 201 }); +} diff --git a/src/app/api/questions/[id]/route.ts b/src/app/api/questions/[id]/route.ts new file mode 100644 index 0000000..581d7d3 --- /dev/null +++ b/src/app/api/questions/[id]/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; + +export async function GET( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const { id } = await params; + const isStaff = session.user.role === "admin" || session.user.role === "curator"; + + const question = await prisma.studentQuestion.findUnique({ + where: { id }, + include: { + user: { select: { id: true, name: true } }, + course: { select: { id: true, title: true } }, + messages: { + include: { author: { select: { id: true, name: true, role: true } } }, + orderBy: { createdAt: "asc" }, + }, + }, + }); + + if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!isStaff && question.userId !== session.user.id) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + // Mark unread messages as read + const unreadWhere = isStaff + ? { questionId: id, isRead: false, author: { role: "student" } } + : { questionId: id, isRead: false, NOT: { authorId: session.user.id } }; + + await prisma.studentQuestionMessage.updateMany({ + where: unreadWhere, + data: { isRead: true }, + }); + + return NextResponse.json(question); +} diff --git a/src/app/api/questions/route.ts b/src/app/api/questions/route.ts new file mode 100644 index 0000000..0c88d8e --- /dev/null +++ b/src/app/api/questions/route.ts @@ -0,0 +1,88 @@ +import { NextRequest, NextResponse } from "next/server"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { sendQuestionCreatedEmail } from "@/lib/email"; + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + const isStaff = session.user.role === "admin" || session.user.role === "curator"; + + const questions = await prisma.studentQuestion.findMany({ + where: isStaff ? undefined : { userId: session.user.id }, + include: { + user: { select: { id: true, name: true, email: true } }, + course: { select: { id: true, title: true } }, + _count: { + select: { + messages: { + where: isStaff + ? { isRead: false, author: { role: "student" } } + : { isRead: false, NOT: { authorId: session.user.id } }, + }, + }, + }, + }, + orderBy: { updatedAt: "desc" }, + }); + + return NextResponse.json( + questions.map((q) => ({ + id: q.id, + title: q.title, + status: q.status, + createdAt: q.createdAt, + updatedAt: q.updatedAt, + user: q.user, + course: q.course, + unreadCount: q._count.messages, + })) + ); +} + +export async function POST(req: NextRequest) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + if (session.user.role !== "student") { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const body = await req.json(); + const { title, text, courseId } = body as { + title: string; + text: string; + courseId?: string; + }; + + if (!title?.trim() || !text?.trim()) { + return NextResponse.json({ error: "title and text are required" }, { status: 400 }); + } + + const question = await prisma.studentQuestion.create({ + data: { + userId: session.user.id, + courseId: courseId ?? null, + title: title.trim(), + messages: { + create: { + authorId: session.user.id, + text: text.trim(), + }, + }, + }, + }); + + const staff = await prisma.user.findMany({ + where: { role: { in: ["admin", "curator"] } }, + select: { email: true, name: true }, + }); + await Promise.all( + staff.map((s) => + sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim()) + ) + ); + + return NextResponse.json(question, { status: 201 }); +} diff --git a/src/app/api/student/question-upload/route.ts b/src/app/api/student/question-upload/route.ts new file mode 100644 index 0000000..7a55c65 --- /dev/null +++ b/src/app/api/student/question-upload/route.ts @@ -0,0 +1,38 @@ +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"; + +const ALLOWED_TYPES = new Set([ + "image/jpeg", "image/png", "image/gif", "image/webp", + "application/pdf", "text/markdown", "text/plain", +]); +const MAX_BYTES = 10 * 1024 * 1024; // 10 MB + +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 }); + + if (file.size > MAX_BYTES) { + return NextResponse.json({ error: "Файл слишком большой (макс. 10 МБ)" }, { status: 413 }); + } + + if (!ALLOWED_TYPES.has(file.type)) { + return NextResponse.json( + { error: "Разрешены только jpg, png, pdf, md" }, + { status: 415 } + ); + } + + const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin"; + const key = `questions/tmp/${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 }); +}