Add student questions API routes
Implements GET/POST /api/questions, GET /api/questions/[id] with read tracking, POST /api/questions/[id]/messages with email notifications, PATCH /api/questions/[id]/close for staff, and POST /api/student/question-upload for file attachments. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user