# Student Questions — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a threaded student question system with file attachments, open/closed status, email notifications for all parties, and a split-view admin/curator interface.
**Architecture:** Two new Prisma models (`StudentQuestion`, `StudentQuestionMessage`) backed by REST API routes at `/api/questions/*`. Student pages under `(student)/questions/`. Admin and curator share a client-side split-view component (`QuestionSplitView`). Email notifications via existing Resend helpers in `src/lib/email.ts`. Also adds email notification when a student updates an existing homework submission.
**Tech Stack:** Next.js 16 App Router, Prisma 7 (output `src/generated/prisma`), PostgreSQL, Better Auth, Resend, AWS S3 (Hetzner), TypeScript, Tailwind v4
---
## File Map
| Action | Path | Responsibility |
|---|---|---|
| Modify | `prisma/schema.prisma` | Add `QuestionStatus` enum, `StudentQuestion`, `StudentQuestionMessage`, back-relations on `User` and `Course` |
| New | `src/app/api/questions/route.ts` | GET list, POST create |
| New | `src/app/api/questions/[id]/route.ts` | GET detail + mark messages read |
| New | `src/app/api/questions/[id]/messages/route.ts` | POST add message |
| New | `src/app/api/questions/[id]/close/route.ts` | PATCH close question |
| New | `src/app/api/student/question-upload/route.ts` | POST upload attachment file |
| Modify | `src/lib/email.ts` | Add `sendQuestionCreatedEmail`, `sendQuestionReplyEmail`, `sendHomeworkUpdatedEmail` |
| New | `src/app/(student)/questions/page.tsx` | Student questions list (server) |
| New | `src/app/(student)/questions/new/page.tsx` | New question form (client) |
| New | `src/app/(student)/questions/[id]/page.tsx` | Thread view — server fetch + client reply |
| New | `src/components/questions/QuestionThread.tsx` | Client component: messages + reply form |
| New | `src/app/admin/questions/page.tsx` | Admin questions page (renders QuestionSplitView) |
| New | `src/app/curator/questions/page.tsx` | Curator questions page (same) |
| New | `src/components/questions/QuestionSplitView.tsx` | Client split-view: list + selected thread + reply |
| Modify | `src/components/admin/admin-nav.tsx` | Add "Вопросы" link with unread badge |
| Modify | `src/components/admin/admin-shell.tsx` | Accept + forward `questionsBadge: number` prop |
| Modify | `src/app/admin/layout.tsx` | Fetch unread question count, pass to AdminShell |
| Modify | `src/app/curator/layout.tsx` | Add "Вопросы" link with badge to curator sidebar |
| Modify | `src/app/(student)/layout.tsx` | Add "Вопросы" link to student header nav |
| Modify | `src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts` | Send email when student updates existing submission |
---
## Task 1: Prisma Schema
**Files:**
- Modify: `prisma/schema.prisma`
- [ ] **Step 1: Add QuestionStatus enum and two new models**
Find the end of the schema (after `LessonComment` model) and add:
```prisma
// ─────────────────────────────────────────────
// Student Questions
// ─────────────────────────────────────────────
enum QuestionStatus {
OPEN
CLOSED
}
model StudentQuestion {
id String @id @default(cuid())
userId String
courseId String?
title String
status QuestionStatus @default(OPEN)
closedAt DateTime?
closedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
closedBy User? @relation("QuestionClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
messages StudentQuestionMessage[]
}
model StudentQuestionMessage {
id String @id @default(cuid())
questionId String
authorId String
text String
files Json? // [{name, url, size}]
isRead Boolean @default(false)
createdAt DateTime @default(now())
question StudentQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
}
```
- [ ] **Step 2: Add back-relations to User model**
In `model User { ... }`, after the last relation line (e.g. after `feedbacks HomeworkFeedback[]`), add:
```prisma
questions StudentQuestion[]
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
questionMessages StudentQuestionMessage[]
```
- [ ] **Step 3: Add back-relation to Course model**
In `model Course { ... }`, after the existing relation lines, add:
```prisma
questions StudentQuestion[]
```
- [ ] **Step 4: Create and apply migration**
```bash
cd /path/to/lms-system
npx prisma migrate dev --name add_student_questions
```
Expected output: `Your database is now in sync with your schema.`
- [ ] **Step 5: Verify generated types exist**
```bash
grep -r "StudentQuestion\b" src/generated/prisma/models/ | head -3
```
Expected: at least 3 lines mentioning `StudentQuestion`.
- [ ] **Step 6: Type-check**
```bash
npm run type-check
```
Expected: no errors.
- [ ] **Step 7: Commit**
```bash
git add prisma/schema.prisma prisma/migrations/
git commit -m "Add StudentQuestion and StudentQuestionMessage models"
```
---
## Task 2: Email Helpers
**Files:**
- Modify: `src/lib/email.ts`
- [ ] **Step 1: Add three email helpers at the end of the file**
```typescript
export async function sendQuestionCreatedEmail(
to: string,
recipientName: string,
studentName: string,
questionTitle: string,
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Новый вопрос от ${studentName}`,
html: base(`
Привет, ${recipientName}!
Студент ${studentName} задал новый вопрос:
${quote(questionTitle)}
Откройте панель, чтобы ответить:
${btn(`${BASE_URL}/admin/questions`, "Перейти к вопросам")}
`, school),
}).catch((e) => console.error("[email] sendQuestionCreatedEmail:", e));
}
export async function sendQuestionReplyEmail(
to: string,
studentName: string,
questionTitle: string,
questionId: string,
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Ответ на ваш вопрос`,
html: base(`
Привет, ${studentName}!
Школа ответила на ваш вопрос:
${quote(questionTitle)}
Откройте тред чтобы прочитать ответ:
${btn(`${BASE_URL}/questions/${questionId}`, "Читать ответ")}
`, school),
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
}
export async function sendHomeworkUpdatedEmail(
to: string,
recipientName: string,
studentName: string,
lessonTitle: string,
submissionId: string,
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Студент обновил работу — ${lessonTitle}`,
html: base(`
Привет, ${recipientName}!
Студент ${studentName} обновил работу по уроку «${lessonTitle}».
Откройте работу чтобы проверить изменения:
${btn(`${BASE_URL}/curator/homework/${submissionId}`, "Открыть работу")}
`, school),
}).catch((e) => console.error("[email] sendHomeworkUpdatedEmail:", e));
}
```
- [ ] **Step 2: Type-check**
```bash
npm run type-check
```
- [ ] **Step 3: Commit**
```bash
git add src/lib/email.ts
git commit -m "Add question and homework-update email helpers"
```
---
## Task 3: API — Questions List + Create
**Files:**
- New: `src/app/api/questions/route.ts`
- [ ] **Step 1: Create the file**
```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";
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 });
}
```
- [ ] **Step 2: Type-check**
```bash
npm run type-check
```
- [ ] **Step 3: Smoke test (manual)**
Start dev server: `npm run dev`
```bash
# Get session cookie first by logging in at http://localhost:3000/login, then:
curl -s http://localhost:3000/api/questions \
-H "Cookie: " | jq '.'
```
Expected: `[]` (empty array, no error).
- [ ] **Step 4: Commit**
```bash
git add src/app/api/questions/route.ts
git commit -m "Add GET /api/questions and POST /api/questions"
```
---
## Task 4: API — Question Detail
**Files:**
- New: `src/app/api/questions/[id]/route.ts`
- [ ] **Step 1: Create the file**
```typescript
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);
}
```
- [ ] **Step 2: Type-check**
```bash
npm run type-check
```
- [ ] **Step 3: Commit**
```bash
git add src/app/api/questions/[id]/route.ts
git commit -m "Add GET /api/questions/[id] with read tracking"
```
---
## Task 5: API — Add Message
**Files:**
- New: `src/app/api/questions/[id]/messages/route.ts`
- [ ] **Step 1: Create the file**
```typescript
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 so it surfaces in the sorted list
await prisma.studentQuestion.update({
where: { id },
data: { updatedAt: new Date() },
});
// Send notifications
if (isStaff) {
// Staff replied → notify student
await sendQuestionReplyEmail(
question.user.email,
question.user.name,
question.title,
id,
);
} else {
// Student added message → notify all staff
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 });
}
```
- [ ] **Step 2: Type-check**
```bash
npm run type-check
```
- [ ] **Step 3: Commit**
```bash
git add src/app/api/questions/[id]/messages/route.ts
git commit -m "Add POST /api/questions/[id]/messages with email notifications"
```
---
## Task 6: API — Close Question
**Files:**
- New: `src/app/api/questions/[id]/close/route.ts`
- [ ] **Step 1: Create the file**
```typescript
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);
}
```
- [ ] **Step 2: Type-check**
```bash
npm run type-check
```
- [ ] **Step 3: Commit**
```bash
git add src/app/api/questions/[id]/close/route.ts
git commit -m "Add PATCH /api/questions/[id]/close"
```
---
## Task 7: API — File Upload
**Files:**
- New: `src/app/api/student/question-upload/route.ts`
- [ ] **Step 1: Create the file**
```typescript
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 });
}
```
- [ ] **Step 2: Type-check**
```bash
npm run type-check
```
- [ ] **Step 3: Commit**
```bash
git add src/app/api/student/question-upload/route.ts
git commit -m "Add question file upload endpoint"
```
---
## Task 8: Student — Questions List Page
**Files:**
- New: `src/app/(student)/questions/page.tsx`
- [ ] **Step 1: Create the page**
```typescript
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function QuestionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const questions = await prisma.studentQuestion.findMany({
where: { userId: session.user.id },
include: {
_count: {
select: {
messages: {
where: { isRead: false, NOT: { authorId: session.user.id } },
},
},
},
messages: {
orderBy: { createdAt: "desc" },
take: 1,
include: { author: { select: { name: true } } },
},
},
orderBy: { updatedAt: "desc" },
});
return (
Мои вопросы
+ Задать вопрос
{questions.length === 0 && (
У вас ещё нет вопросов.
)}
{questions.map((q) => {
const unread = q._count.messages > 0;
const lastMsg = q.messages[0];
return (
{q.title}
{q.status === "OPEN" ? "● ОТКРЫТ" : "✓ ЗАКРЫТ"}
{unread && (
Новый ответ от школы
)}
{lastMsg && !unread && (
{q.messages.length > 0
? `${q.messages.length > 1 ? "сообщений" : "сообщение"} · последнее от ${lastMsg.author.name}`
: ""}
)}
);
})}
);
}
```
- [ ] **Step 2: Type-check**
```bash
npm run type-check
```
- [ ] **Step 3: Verify page renders at http://localhost:3000/questions (logged in as student)**
- [ ] **Step 4: Commit**
```bash
git add src/app/(student)/questions/page.tsx
git commit -m "Add student questions list page"
```
---
## Task 9: Student — New Question Form
**Files:**
- New: `src/app/(student)/questions/new/page.tsx`
- [ ] **Step 1: Create the page**
```typescript
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function NewQuestionPage() {
const router = useRouter();
const [title, setTitle] = useState("");
const [text, setText] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !text.trim()) return;
setLoading(true);
setError("");
try {
const res = await fetch("/api/questions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, text }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Ошибка при создании вопроса");
}
const q = await res.json();
router.push(`/questions/${q.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
} finally {
setLoading(false);
}
}
return (
);
}
```
- [ ] **Step 2: Type-check**
```bash
npm run type-check
```
- [ ] **Step 3: Test in browser — create a question, confirm redirect to thread**
- [ ] **Step 4: Commit**
```bash
git add src/app/(student)/questions/new/page.tsx
git commit -m "Add new question form page"
```
---
## Task 10: Student — Thread Page
**Files:**
- New: `src/components/questions/QuestionThread.tsx`
- New: `src/app/(student)/questions/[id]/page.tsx`
- [ ] **Step 1: Create the thread client component**
Create `src/components/questions/QuestionThread.tsx`:
```typescript
"use client";
import { useState, useRef } from "react";
interface FileAttachment {
name: string;
url: string;
size: number;
}
interface Message {
id: string;
authorId: string;
text: string;
files: FileAttachment[] | null;
isRead: boolean;
createdAt: string;
author: { id: string; name: string; role: string };
}
interface QuestionThreadProps {
questionId: string;
questionStatus: "OPEN" | "CLOSED";
currentUserId: string;
initialMessages: Message[];
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString("ru", {
day: "numeric",
month: "long",
hour: "2-digit",
minute: "2-digit",
});
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function QuestionThread({
questionId,
questionStatus,
currentUserId,
initialMessages,
}: QuestionThreadProps) {
const [messages, setMessages] = useState(initialMessages);
const [text, setText] = useState("");
const [files, setFiles] = useState([]);
const [uploading, setUploading] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef(null);
async function handleFileSelect(e: React.ChangeEvent) {
const selected = Array.from(e.target.files ?? []);
if (!selected.length) return;
setUploading(true);
setError("");
try {
const uploaded: FileAttachment[] = [];
for (const f of selected) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка загрузки файла");
}
uploaded.push(await res.json());
}
setFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleSend() {
if (!text.trim() && files.length === 0) return;
setSending(true);
setError("");
try {
const res = await fetch(`/api/questions/${questionId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), files }),
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка отправки");
}
const msg = await res.json();
setMessages((prev) => [...prev, msg]);
setText("");
setFiles([]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
} finally {
setSending(false);
}
}
return (
{/* Messages */}
{messages.map((msg) => {
const isMine = msg.authorId === currentUserId;
const isNew = !msg.isRead && !isMine;
return (
{isMine ? "Ты" : msg.author.name} · {formatDate(msg.createdAt)}
{isNew && (
· 🔵 новое
)}
{msg.text}
{msg.files && msg.files.length > 0 && (
)}
);
})}
{/* Queued files preview */}
{files.length > 0 && (
{files.map((f, i) => (
📎 {f.name}
))}
)}
{/* Reply form */}
);
}
```
- [ ] **Step 2: Create the thread page**
Create `src/app/(student)/questions/[id]/page.tsx`:
```typescript
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect, notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { QuestionThread } from "@/components/questions/QuestionThread";
export default async function QuestionThreadPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const { id } = await params;
const question = await prisma.studentQuestion.findUnique({
where: { id },
include: {
user: { select: { id: true, name: true } },
messages: {
include: { author: { select: { id: true, name: true, role: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!question || question.userId !== session.user.id) notFound();
// Mark staff messages as read
await prisma.studentQuestionMessage.updateMany({
where: {
questionId: id,
isRead: false,
NOT: { authorId: session.user.id },
},
data: { isRead: true },
});
return (
{question.title}
Создан{" "}
{new Date(question.createdAt).toLocaleDateString("ru", {
day: "numeric",
month: "long",
})}{" "}
·{" "}
{question.status === "OPEN" ? "● Открыт" : "✓ Закрыт"}
← Все вопросы
({
...m,
files: m.files as Array<{ name: string; url: string; size: number }> | null,
createdAt: m.createdAt.toISOString(),
}))}
/>
);
}
```
- [ ] **Step 3: Type-check**
```bash
npm run type-check
```
- [ ] **Step 4: Test in browser — open a question, send a reply, verify message appears**
- [ ] **Step 5: Commit**
```bash
git add src/components/questions/QuestionThread.tsx src/app/(student)/questions/[id]/page.tsx
git commit -m "Add student question thread page with reply form"
```
---
## Task 11: Admin/Curator — Split-View
**Files:**
- New: `src/components/questions/QuestionSplitView.tsx`
- New: `src/app/admin/questions/page.tsx`
- New: `src/app/curator/questions/page.tsx`
- [ ] **Step 1: Create the split-view component**
Create `src/components/questions/QuestionSplitView.tsx`:
```typescript
"use client";
import { useState, useEffect, useCallback } from "react";
interface FileAttachment {
name: string;
url: string;
size: number;
}
interface Message {
id: string;
authorId: string;
text: string;
files: FileAttachment[] | null;
isRead: boolean;
createdAt: string;
author: { id: string; name: string; role: string };
}
interface QuestionSummary {
id: string;
title: string;
status: "OPEN" | "CLOSED";
unreadCount: number;
updatedAt: string;
user: { id: string; name: string };
course: { id: string; title: string } | null;
}
interface QuestionDetail {
id: string;
title: string;
status: "OPEN" | "CLOSED";
createdAt: string;
user: { id: string; name: string };
messages: Message[];
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString("ru", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function QuestionSplitView({ currentUserId }: { currentUserId: string }) {
const [questions, setQuestions] = useState([]);
const [selectedId, setSelectedId] = useState(null);
const [detail, setDetail] = useState(null);
const [tab, setTab] = useState<"open" | "closed">("open");
const [replyText, setReplyText] = useState("");
const [sending, setSending] = useState(false);
const [closing, setClosing] = useState(false);
const [error, setError] = useState("");
const fetchList = useCallback(async () => {
const res = await fetch("/api/questions");
if (res.ok) {
const data = await res.json();
setQuestions(data);
}
}, []);
useEffect(() => {
fetchList();
}, [fetchList]);
async function selectQuestion(id: string) {
setSelectedId(id);
setReplyText("");
setError("");
const res = await fetch(`/api/questions/${id}`);
if (res.ok) {
const data = await res.json();
setDetail({
...data,
createdAt: data.createdAt,
messages: data.messages.map((m: Message & { createdAt: string }) => ({
...m,
files: m.files as FileAttachment[] | null,
})),
});
// Refresh list to clear unread badge
fetchList();
}
}
async function handleReply() {
if (!replyText.trim() || !selectedId) return;
setSending(true);
setError("");
try {
const res = await fetch(`/api/questions/${selectedId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: replyText.trim() }),
});
if (!res.ok) throw new Error("Ошибка отправки");
const msg = await res.json();
setDetail((prev) => prev ? { ...prev, messages: [...prev.messages, msg] } : null);
setReplyText("");
fetchList();
} catch {
setError("Не удалось отправить ответ");
} finally {
setSending(false);
}
}
async function handleClose() {
if (!selectedId) return;
setClosing(true);
try {
const res = await fetch(`/api/questions/${selectedId}/close`, { method: "PATCH" });
if (!res.ok) throw new Error("Ошибка закрытия");
setDetail((prev) => prev ? { ...prev, status: "CLOSED" } : null);
fetchList();
} catch {
setError("Не удалось закрыть вопрос");
} finally {
setClosing(false);
}
}
const filtered = questions.filter((q) =>
tab === "open" ? q.status === "OPEN" : q.status === "CLOSED"
);
return (
{/* Left panel */}
{/* Tabs */}
{(["open", "closed"] as const).map((t) => (
))}
{/* Question list */}
{filtered.length === 0 && (
Нет вопросов
)}
{filtered.map((q) => (
))}
{/* Right panel */}
{!detail ? (
) : (
<>
{/* Thread header */}
{detail.title}
{detail.user.name} ·{" "}
{new Date(detail.createdAt).toLocaleDateString("ru", {
day: "numeric",
month: "long",
})}
{detail.status === "OPEN" && (
)}
{/* Messages */}
{detail.messages.map((msg) => {
const isMine = msg.authorId === currentUserId;
return (
{msg.author.name} · {formatDate(msg.createdAt)}
{msg.text}
{msg.files && msg.files.length > 0 && (
)}
);
})}
{/* Reply form */}
setReplyText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleReply();
}
}}
disabled={detail.status === "CLOSED"}
placeholder={detail.status === "CLOSED" ? "Вопрос закрыт" : "Написать ответ..."}
className="flex-1 text-sm px-3 py-2 outline-none"
style={{
border: "1px solid var(--border)",
background: "var(--surface)",
color: "var(--foreground)",
}}
/>
{error && (
{error}
)}
>
)}
);
}
```
- [ ] **Step 2: Create admin questions page**
Create `src/app/admin/questions/page.tsx`:
```typescript
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { QuestionSplitView } from "@/components/questions/QuestionSplitView";
export default async function AdminQuestionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") redirect("/login");
return ;
}
```
- [ ] **Step 3: Create curator questions page**
Create `src/app/curator/questions/page.tsx`:
```typescript
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { QuestionSplitView } from "@/components/questions/QuestionSplitView";
export default async function CuratorQuestionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "admin" && session.user.role !== "curator") redirect("/dashboard");
return ;
}
```
- [ ] **Step 4: Type-check**
```bash
npm run type-check
```
- [ ] **Step 5: Test in browser as admin — visit /admin/questions, select a question, reply, close**
- [ ] **Step 6: Commit**
```bash
git add src/components/questions/QuestionSplitView.tsx \
src/app/admin/questions/page.tsx \
src/app/curator/questions/page.tsx
git commit -m "Add admin/curator split-view questions page"
```
---
## Task 12: Navigation Updates + Badge
**Files:**
- Modify: `src/components/admin/admin-nav.tsx`
- Modify: `src/components/admin/admin-shell.tsx`
- Modify: `src/app/admin/layout.tsx`
- Modify: `src/app/curator/layout.tsx`
- Modify: `src/app/(student)/layout.tsx`
- [ ] **Step 1: Update AdminNav to accept and display badge**
In `src/components/admin/admin-nav.tsx`, replace the entire file with:
```typescript
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
{ href: "/admin/dashboard", label: "Обзор" },
{ href: "/admin/courses", label: "Курсы" },
{ href: "/admin/categories", label: "Категории" },
{ href: "/admin/users", label: "Пользователи" },
{ href: "/curator/homework", label: "ДЗ на проверку" },
{ href: "/admin/questions", label: "Вопросы", badgeKey: "questions" },
{ href: "/admin/quizzes", label: "Тесты" },
{ href: "/admin/comments", label: "Комментарии" },
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
{ href: "/admin/settings", label: "Настройки" },
];
export function AdminNav({ questionsBadge = 0 }: { questionsBadge?: number }) {
const pathname = usePathname();
return (
<>
{links.map(({ href, label, badgeKey }) => {
const active =
pathname === href ||
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
(href === "/curator/homework" && pathname.startsWith("/curator"));
return (
{label}
{badgeKey === "questions" && questionsBadge > 0 && (
{questionsBadge}
)}
);
})}
>
);
}
```
- [ ] **Step 2: Update AdminShell to forward badge**
In `src/components/admin/admin-shell.tsx`, change the component signature and `` usage:
```typescript
// Change signature:
export function AdminShell({
children,
userName,
questionsBadge = 0,
}: {
children: React.ReactNode;
userName: string;
questionsBadge?: number;
}) {
// ...existing code...
// Change to:
//
```
- [ ] **Step 3: Update admin/layout.tsx to fetch and pass badge**
In `src/app/admin/layout.tsx`, add badge fetch and pass to AdminShell:
```typescript
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { AdminShell } from "@/components/admin/admin-shell";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "admin") redirect("/dashboard");
const questionsBadge = await prisma.studentQuestion.count({
where: {
status: "OPEN",
messages: {
some: { isRead: false, author: { role: "student" } },
},
},
});
return (
{children}
);
}
```
- [ ] **Step 4: Add Вопросы link to curator sidebar**
In `src/app/curator/layout.tsx`, inside the curator `