diff --git a/src/app/api/questions/[id]/messages/route.ts b/src/app/api/questions/[id]/messages/route.ts index 4c95ad8..3dd8f64 100644 --- a/src/app/api/questions/[id]/messages/route.ts +++ b/src/app/api/questions/[id]/messages/route.ts @@ -10,6 +10,13 @@ interface FileAttachment { size: number; } +function buildS3Prefix(): string { + const endpoint = process.env.S3_ENDPOINT ?? ""; + const bucket = process.env.S3_BUCKET ?? ""; + // e.g. https://fsn1.your-objectstorage.com/lms-uploads/ + return `${endpoint}/${bucket}/`; +} + export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -33,41 +40,49 @@ export async function POST( return NextResponse.json({ error: "Question is closed" }, { status: 409 }); } - const body = await req.json(); + 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 s3Prefix = buildS3Prefix(); const safeFiles = files ?.filter( (f) => typeof f.name === "string" && typeof f.url === "string" && + f.url.startsWith("https://") && + f.url.startsWith(s3Prefix) && typeof f.size === "number" ) .map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) })); - const message = await 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 } } }, - }); + 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() }, + }), + ]); - // Touch question.updatedAt - await prisma.studentQuestion.update({ - where: { id }, - data: { updatedAt: new Date() }, - }); - - // Send notifications + // Send notifications (fire-and-forget, outside transaction) if (isStaff) { - await sendQuestionReplyEmail( + void sendQuestionReplyEmail( question.user.email, question.user.name, question.title, @@ -78,12 +93,12 @@ export async function POST( where: { role: { in: ["admin", "curator"] } }, select: { email: true, name: true }, }); - await Promise.all( + void Promise.all( staff.map((s) => sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title) ) ); } - return NextResponse.json(message, { status: 201 }); + return NextResponse.json(msg, { status: 201 }); } diff --git a/src/app/api/questions/route.ts b/src/app/api/questions/route.ts index b176fca..38384bf 100644 --- a/src/app/api/questions/route.ts +++ b/src/app/api/questions/route.ts @@ -53,7 +53,12 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Forbidden" }, { status: 403 }); } - const body = await req.json(); + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } const { title, text, courseId } = body as { title: string; text: string; @@ -82,7 +87,7 @@ export async function POST(req: NextRequest) { where: { role: { in: ["admin", "curator"] } }, select: { email: true, name: true }, }); - await Promise.all( + void Promise.all( staff.map((s) => sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim()) ) diff --git a/src/app/curator/layout.tsx b/src/app/curator/layout.tsx index 05facde..4bdde0d 100644 --- a/src/app/curator/layout.tsx +++ b/src/app/curator/layout.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { LogoutButton } from "@/components/layout/logout-button"; import { AdminShell } from "@/components/admin/admin-shell"; import { getSetting } from "@/lib/settings"; +import { prisma } from "@/lib/prisma"; export default async function CuratorLayout({ children }: { children: React.ReactNode }) { const session = await auth.api.getSession({ headers: await headers() }); @@ -19,7 +20,17 @@ export default async function CuratorLayout({ children }: { children: React.Reac // Admin uses the admin shell with sidebar if (session.user.role === "admin") { - return {children}; + const questionsBadge = await prisma.studentQuestion.count({ + where: { + messages: { + some: { + isRead: false, + author: { role: "student" }, + }, + }, + }, + }); + return {children}; } return ( diff --git a/src/components/questions/QuestionSplitView.tsx b/src/components/questions/QuestionSplitView.tsx index a52adc5..492e5ee 100644 --- a/src/components/questions/QuestionSplitView.tsx +++ b/src/components/questions/QuestionSplitView.tsx @@ -295,7 +295,7 @@ export function QuestionSplitView({ currentUserId }: { currentUserId: string }) {msg.files.map((f) => ( (