9fbb7aea6c
Attachments uploaded via question-upload get a CDN URL (files.second-brain.ru via S3_CDN_URL), but the validator only accepted the direct S3 endpoint prefix — so every attachment was silently dropped (message text saved, file lost). Add isAllowedPublicUrl (CDN + direct S3) in lib/s3 and use it in both question routes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
116 lines
3.2 KiB
TypeScript
116 lines
3.2 KiB
TypeScript
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";
|
|
import { isAllowedPublicUrl } from "@/lib/s3";
|
|
|
|
interface FileAttachment {
|
|
name: string;
|
|
url: string;
|
|
size: number;
|
|
}
|
|
|
|
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 userSelect = isStaff
|
|
? { id: true as const, name: true as const, email: true as const }
|
|
: { id: true as const, name: true as const };
|
|
|
|
const questions = await prisma.studentQuestion.findMany({
|
|
where: isStaff ? undefined : { userId: session.user.id },
|
|
include: {
|
|
user: { select: userSelect },
|
|
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 });
|
|
}
|
|
|
|
let body: unknown;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
|
}
|
|
const { title, text, courseId, files } = body as {
|
|
title: string;
|
|
text: string;
|
|
courseId?: string;
|
|
files?: FileAttachment[];
|
|
};
|
|
|
|
if (!title?.trim() || !text?.trim()) {
|
|
return NextResponse.json({ error: "title and text are required" }, { status: 400 });
|
|
}
|
|
|
|
const safeFiles = files
|
|
?.filter(
|
|
(f) =>
|
|
typeof f.name === "string" &&
|
|
isAllowedPublicUrl(f.url) &&
|
|
typeof f.size === "number"
|
|
)
|
|
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
|
|
|
|
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(),
|
|
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const staff = await prisma.user.findMany({
|
|
where: { role: { in: ["admin", "curator"] } },
|
|
select: { email: true, name: true },
|
|
});
|
|
void Promise.all(
|
|
staff.map((s) =>
|
|
sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim())
|
|
)
|
|
);
|
|
|
|
return NextResponse.json(question, { status: 201 });
|
|
}
|