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) => (
(