Files
lms-sb/src/app/api/questions/[id]/messages/route.ts
T
admins 9fbb7aea6c Accept CDN URLs for question attachments
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>
2026-06-20 15:11:36 +05:00

96 lines
2.8 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
import { isAllowedPublicUrl } from "@/lib/s3";
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 });
}
if (question.status === "CLOSED") {
return NextResponse.json({ error: "Question is closed" }, { status: 409 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { text, files } = body as { text: string; files?: FileAttachment[] };
if (!text?.trim()) {
return NextResponse.json({ error: "text is 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 [msg] = await prisma.$transaction([
prisma.studentQuestionMessage.create({
data: {
questionId: id,
authorId: session.user.id,
text: text.trim(),
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
},
include: { author: { select: { id: true, name: true, role: true } } },
}),
prisma.studentQuestion.update({
where: { id },
data: { updatedAt: new Date() },
}),
]);
// Send notifications (fire-and-forget, outside transaction)
if (isStaff) {
void 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 },
});
void Promise.all(
staff.map((s) =>
sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title)
)
);
}
return NextResponse.json(msg, { status: 201 });
}