diff --git a/docs/superpowers/plans/2026-05-19-student-questions.md b/docs/superpowers/plans/2026-05-19-student-questions.md
new file mode 100644
index 0000000..de68572
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-student-questions.md
@@ -0,0 +1,1987 @@
+# 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 `