Files
lms-sb/docs/superpowers/plans/2026-05-19-student-questions.md

1988 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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(`
<p ${p}>Привет, ${recipientName}!</p>
<p ${p}>Студент <strong>${studentName}</strong> задал новый вопрос:</p>
${quote(questionTitle)}
<p ${pLast}>Откройте панель, чтобы ответить:</p>
${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(`
<p ${p}>Привет, ${studentName}!</p>
<p ${p}>Школа ответила на ваш вопрос:</p>
${quote(questionTitle)}
<p ${pLast}>Откройте тред чтобы прочитать ответ:</p>
${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(`
<p ${p}>Привет, ${recipientName}!</p>
<p ${p}>Студент <strong>${studentName}</strong> обновил работу по уроку <strong>«${lessonTitle}»</strong>.</p>
<p ${pLast}>Откройте работу чтобы проверить изменения:</p>
${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: <paste session 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 (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold" style={{ color: "var(--foreground)" }}>
Мои вопросы
</h1>
<Link
href="/questions/new"
className="text-sm font-bold px-4 py-2"
style={{
background: "var(--surface)",
border: "2px solid var(--border-strong)",
color: "var(--foreground)",
}}
>
+ Задать вопрос
</Link>
</div>
{questions.length === 0 && (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
У вас ещё нет вопросов.
</p>
)}
<div className="flex flex-col gap-2">
{questions.map((q) => {
const unread = q._count.messages > 0;
const lastMsg = q.messages[0];
return (
<Link
key={q.id}
href={`/questions/${q.id}`}
className="block p-3 rounded-sm transition-colors"
style={{
border: unread ? "2px solid var(--border-strong)" : "1px solid var(--border)",
background: q.status === "CLOSED" ? "var(--surface-muted)" : "var(--surface)",
opacity: q.status === "CLOSED" ? 0.7 : 1,
}}
>
<div className="flex items-start justify-between gap-2 mb-1">
<span
className="text-sm"
style={{
fontWeight: unread ? 700 : 400,
color: q.status === "CLOSED" ? "var(--muted-foreground)" : "var(--foreground)",
}}
>
{q.title}
</span>
<span
className="text-xs shrink-0 px-1.5 py-0.5 rounded-sm"
style={
q.status === "OPEN"
? { background: "#E8F0D8", border: "1px solid var(--border)", color: "var(--foreground)" }
: { background: "var(--surface-muted)", color: "var(--muted-foreground)" }
}
>
{q.status === "OPEN" ? "● ОТКРЫТ" : "✓ ЗАКРЫТ"}
</span>
</div>
{unread && (
<div className="flex items-center gap-1.5 mb-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ background: "var(--foreground)" }}
/>
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
Новый ответ от школы
</span>
</div>
)}
{lastMsg && !unread && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{q.messages.length > 0
? `${q.messages.length > 1 ? "сообщений" : "сообщение"} · последнее от ${lastMsg.author.name}`
: ""}
</p>
)}
</Link>
);
})}
</div>
</div>
);
}
```
- [ ] **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 (
<div className="max-w-xl mx-auto px-4 py-8">
<div className="flex items-center gap-3 mb-6">
<a
href="/questions"
className="text-sm"
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
>
Все вопросы
</a>
</div>
<h1 className="text-xl font-bold mb-6" style={{ color: "var(--foreground)" }}>
Новый вопрос
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label
className="block text-sm font-bold mb-1"
style={{ color: "var(--foreground)" }}
>
Тема вопроса
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Кратко опишите суть вопроса"
required
className="w-full text-sm px-3 py-2 outline-none"
style={{
border: "2px solid var(--border)",
background: "var(--surface)",
color: "var(--foreground)",
}}
/>
</div>
<div>
<label
className="block text-sm font-bold mb-1"
style={{ color: "var(--foreground)" }}
>
Описание
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Подробно опишите вопрос или проблему"
required
rows={6}
className="w-full text-sm px-3 py-2 outline-none resize-none"
style={{
border: "2px solid var(--border)",
background: "var(--surface)",
color: "var(--foreground)",
}}
/>
</div>
{error && (
<p className="text-sm" style={{ color: "#c00" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !title.trim() || !text.trim()}
className="self-end text-sm font-bold px-6 py-2"
style={{
background: "var(--foreground)",
color: "var(--background)",
border: "none",
opacity: loading ? 0.6 : 1,
}}
>
{loading ? "Отправка..." : "Отправить →"}
</button>
</form>
</div>
);
}
```
- [ ] **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<Message[]>(initialMessages);
const [text, setText] = useState("");
const [files, setFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="flex flex-col gap-2 w-full">
{/* Messages */}
<div className="flex flex-col gap-2 mb-4 max-h-96 overflow-y-auto pr-1">
{messages.map((msg) => {
const isMine = msg.authorId === currentUserId;
const isNew = !msg.isRead && !isMine;
return (
<div
key={msg.id}
className="max-w-[88%] px-3 py-2 rounded-sm text-sm"
style={{
alignSelf: isMine ? "flex-start" : "flex-end",
background: isMine ? "#E8E8E0" : "#F5F5F0",
border: isMine ? "none" : `2px solid #E8F0D8${isNew ? "" : ""}`,
borderLeft: !isMine && isNew ? "3px solid #323232" : undefined,
}}
>
<p
className="text-xs mb-1"
style={{ color: "var(--muted-foreground)" }}
>
{isMine ? "Ты" : msg.author.name} · {formatDate(msg.createdAt)}
{isNew && (
<strong style={{ color: "#323232" }}> · 🔵 новое</strong>
)}
</p>
<p style={{ color: "var(--foreground)" }}>{msg.text}</p>
{msg.files && msg.files.length > 0 && (
<div className="flex flex-col gap-1 mt-2">
{msg.files.map((f, i) => (
<a
key={i}
href={f.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
style={{
background: "#F5F5F0",
border: "1px solid #AAAAAA",
color: "var(--foreground)",
width: "fit-content",
}}
>
📎 <span>{f.name}</span>
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
</a>
))}
</div>
)}
</div>
);
})}
</div>
{/* Queued files preview */}
{files.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{files.map((f, i) => (
<div
key={i}
className="flex items-center gap-1 text-xs px-2 py-1 rounded-sm"
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
>
📎 {f.name}
<button
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-1 text-xs"
style={{ color: "var(--muted-foreground)" }}
>
×
</button>
</div>
))}
</div>
)}
{/* Reply form */}
<div
className="rounded-sm p-2"
style={{
border: "2px solid #AAAAAA",
background: "var(--surface)",
opacity: questionStatus === "CLOSED" ? 0.5 : 1,
pointerEvents: questionStatus === "CLOSED" ? "none" : "auto",
}}
>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={questionStatus === "CLOSED" ? "Вопрос закрыт" : "Написать сообщение..."}
rows={3}
disabled={questionStatus === "CLOSED"}
className="w-full text-sm outline-none resize-none bg-transparent"
style={{ border: "none", color: "var(--foreground)" }}
/>
<div
className="flex items-center justify-between pt-2 mt-1"
style={{ borderTop: "1px solid #E8E8E0" }}
>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-xs px-2 py-1 rounded-sm"
style={{ background: "var(--surface)", border: "1px solid #AAAAAA" }}
>
{uploading ? "Загрузка..." : "📎 Прикрепить"}
</button>
<span className="text-xs" style={{ color: "#999" }}>
jpg, png, pdf, md · до 10 МБ
</span>
</div>
<button
type="button"
onClick={handleSend}
disabled={sending || (!text.trim() && files.length === 0)}
className="text-xs font-bold px-4 py-1.5 rounded-sm"
style={{
background: "var(--foreground)",
color: "var(--background)",
border: "none",
opacity: sending ? 0.6 : 1,
}}
>
{sending ? "..." : "Отправить →"}
</button>
</div>
{error && (
<p className="text-xs mt-1" style={{ color: "#c00" }}>
{error}
</p>
)}
</div>
</div>
);
}
```
- [ ] **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 (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-lg font-bold mb-1" style={{ color: "var(--foreground)" }}>
{question.title}
</h1>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Создан{" "}
{new Date(question.createdAt).toLocaleDateString("ru", {
day: "numeric",
month: "long",
})}{" "}
·{" "}
<span
style={{
color: question.status === "OPEN" ? "var(--foreground)" : "var(--muted-foreground)",
fontWeight: 700,
}}
>
{question.status === "OPEN" ? "● Открыт" : "✓ Закрыт"}
</span>
</p>
</div>
<Link
href="/questions"
className="text-xs"
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
>
Все вопросы
</Link>
</div>
<QuestionThread
questionId={id}
questionStatus={question.status}
currentUserId={session.user.id}
initialMessages={question.messages.map((m) => ({
...m,
files: m.files as Array<{ name: string; url: string; size: number }> | null,
createdAt: m.createdAt.toISOString(),
}))}
/>
</div>
);
}
```
- [ ] **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<QuestionSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<QuestionDetail | null>(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 (
<div className="flex h-[calc(100vh-56px)]">
{/* Left panel */}
<div
className="w-72 shrink-0 flex flex-col"
style={{ borderRight: "2px solid var(--border)" }}
>
{/* Tabs */}
<div className="flex p-2 gap-1" style={{ borderBottom: "1px solid var(--border)" }}>
{(["open", "closed"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className="text-xs px-3 py-1 rounded-sm font-medium"
style={
tab === t
? { background: "var(--foreground)", color: "var(--background)" }
: { background: "var(--surface-muted)", color: "var(--muted-foreground)" }
}
>
{t === "open"
? `Открытые ${questions.filter((q) => q.status === "OPEN").length}`
: "Закрытые"}
</button>
))}
</div>
{/* Question list */}
<div className="flex-1 overflow-y-auto">
{filtered.length === 0 && (
<p className="text-xs p-4" style={{ color: "var(--muted-foreground)" }}>
Нет вопросов
</p>
)}
{filtered.map((q) => (
<button
key={q.id}
onClick={() => selectQuestion(q.id)}
className="w-full text-left p-3 block"
style={{
borderLeft: selectedId === q.id ? "3px solid var(--foreground)" : "3px solid transparent",
borderBottom: "1px solid var(--border)",
background:
q.unreadCount > 0
? "#E8F0D8"
: selectedId === q.id
? "var(--surface)"
: "transparent",
}}
>
<p className="text-sm font-bold truncate" style={{ color: "var(--foreground)" }}>
{q.user.name}
</p>
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
{q.title}
</p>
<p className="text-xs mt-0.5" style={{ color: "#999" }}>
{formatDate(q.updatedAt)}
{q.unreadCount > 0 && (
<span style={{ color: "#c00", marginLeft: 4 }}>🔴 новое</span>
)}
</p>
</button>
))}
</div>
</div>
{/* Right panel */}
<div className="flex-1 flex flex-col overflow-hidden">
{!detail ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Выберите вопрос
</p>
</div>
) : (
<>
{/* Thread header */}
<div
className="flex items-start justify-between p-4"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div>
<p className="text-sm font-bold" style={{ color: "var(--foreground)" }}>
{detail.title}
</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{detail.user.name} ·{" "}
{new Date(detail.createdAt).toLocaleDateString("ru", {
day: "numeric",
month: "long",
})}
</p>
</div>
{detail.status === "OPEN" && (
<button
onClick={handleClose}
disabled={closing}
className="text-xs px-3 py-1.5 rounded-sm"
style={{
background: "var(--surface-muted)",
border: "1px solid var(--border)",
color: "var(--foreground)",
}}
>
{closing ? "..." : "✓ Закрыть вопрос"}
</button>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3">
{detail.messages.map((msg) => {
const isMine = msg.authorId === currentUserId;
return (
<div
key={msg.id}
className="max-w-[85%] px-3 py-2 rounded-sm text-sm"
style={{
alignSelf: isMine ? "flex-end" : "flex-start",
background: isMine ? "#F5F5F0" : "#E8E8E0",
border: isMine ? "2px solid #E8F0D8" : "none",
}}
>
<p className="text-xs mb-1" style={{ color: "#666" }}>
{msg.author.name} · {formatDate(msg.createdAt)}
</p>
<p>{msg.text}</p>
{msg.files && msg.files.length > 0 && (
<div className="flex flex-col gap-1 mt-2">
{msg.files.map((f, i) => (
<a
key={i}
href={f.url}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
style={{
background: "#F5F5F0",
border: "1px solid #AAAAAA",
color: "var(--foreground)",
width: "fit-content",
}}
>
📎 {f.name}
<span style={{ color: "#999" }}>
· {formatFileSize(f.size)}
</span>
</a>
))}
</div>
)}
</div>
);
})}
</div>
{/* Reply form */}
<div className="p-3" style={{ borderTop: "1px solid var(--border)" }}>
<div
className="flex gap-2"
style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }}
>
<input
type="text"
value={replyText}
onChange={(e) => 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)",
}}
/>
<button
onClick={handleReply}
disabled={sending || !replyText.trim() || detail.status === "CLOSED"}
className="text-xs font-bold px-4 py-2"
style={{
background: "var(--foreground)",
color: "var(--background)",
border: "none",
opacity: sending ? 0.6 : 1,
}}
>
{sending ? "..." : "Отправить"}
</button>
</div>
{error && (
<p className="text-xs mt-1" style={{ color: "#c00" }}>
{error}
</p>
)}
</div>
</>
)}
</div>
</div>
);
}
```
- [ ] **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 <QuestionSplitView currentUserId={session.user.id} />;
}
```
- [ ] **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 <QuestionSplitView currentUserId={session.user.id} />;
}
```
- [ ] **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 (
<Link
key={href}
href={href}
className="admin-sidebar-nav-link flex items-center justify-between"
style={
active
? {
color: "#E8F0D8",
borderLeftColor: "#E8F0D8",
backgroundColor: "var(--sidebar-surface)",
}
: undefined
}
>
<span>{label}</span>
{badgeKey === "questions" && questionsBadge > 0 && (
<span
className="text-xs font-bold px-1.5 py-0.5 rounded-sm"
style={{ background: "#c00", color: "#fff", fontSize: "10px" }}
>
{questionsBadge}
</span>
)}
</Link>
);
})}
</>
);
}
```
- [ ] **Step 2: Update AdminShell to forward badge**
In `src/components/admin/admin-shell.tsx`, change the component signature and `<AdminNav />` usage:
```typescript
// Change signature:
export function AdminShell({
children,
userName,
questionsBadge = 0,
}: {
children: React.ReactNode;
userName: string;
questionsBadge?: number;
}) {
// ...existing code...
// Change <AdminNav /> to:
// <AdminNav questionsBadge={questionsBadge} />
```
- [ ] **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 (
<AdminShell userName={session.user.name} questionsBadge={questionsBadge}>
{children}
</AdminShell>
);
}
```
- [ ] **Step 4: Add Вопросы link to curator sidebar**
In `src/app/curator/layout.tsx`, inside the curator `<nav>` block, add after the homework link:
```typescript
<NavLink href="/curator/questions">Вопросы</NavLink>
```
- [ ] **Step 5: Add Вопросы link to student header**
In `src/app/(student)/layout.tsx`, in the `<header>` element, add a nav link between the logo and the right-side profile links. Find the `<div className="flex items-center gap-4">` that contains the profile link and logout, and add before it:
```typescript
<nav className="hidden md:flex items-center gap-4">
<Link
href="/questions"
className="text-sm hover:underline"
style={{ color: "var(--muted-foreground)" }}
>
Вопросы
</Link>
</nav>
```
- [ ] **Step 6: Type-check**
```bash
npm run type-check
```
- [ ] **Step 7: Test — admin sidebar shows "Вопросы" with badge when there are unread questions; student header shows "Вопросы" link**
- [ ] **Step 8: Commit**
```bash
git add src/components/admin/admin-nav.tsx \
src/components/admin/admin-shell.tsx \
src/app/admin/layout.tsx \
src/app/curator/layout.tsx \
src/app/(student)/layout.tsx
git commit -m "Add Вопросы nav links and unread badge for admin/curator/student"
```
---
## Task 13: Homework Update Notifications
**Files:**
- Modify: `src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts`
- [ ] **Step 1: Add sendHomeworkUpdatedEmail import and call**
In `src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts`, change the import line:
```typescript
import { sendHomeworkSubmittedEmail, sendHomeworkUpdatedEmail } from "@/lib/email";
```
Then in the `if (existing)` branch (update path), after the `prisma.homeworkSubmission.update(...)` call, add:
```typescript
if (existing) {
const updated = await prisma.homeworkSubmission.update({
where: { id: existing.id },
data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
});
submissionId = updated.id;
// Notify staff that the student updated their submission
const [lesson, admins] = await Promise.all([
prisma.homework.findUnique({
where: { id: homeworkId },
include: { lesson: { select: { title: true } } },
}),
prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
}),
]);
if (lesson) {
await Promise.all(
admins.map((a) =>
sendHomeworkUpdatedEmail(a.email, a.name, session.user.name, lesson.lesson.title, submissionId)
)
);
}
}
```
- [ ] **Step 2: Type-check**
```bash
npm run type-check
```
- [ ] **Step 3: Build check**
```bash
npm run build
```
Expected: successful build, no type errors.
- [ ] **Step 4: Commit**
```bash
git add src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts
git commit -m "Send email to staff when student updates existing homework submission"
```
---
## Self-Review
**Spec coverage:**
- ✅ StudentQuestion + StudentQuestionMessage models (Task 1)
- ✅ Routes: /questions, /questions/new, /questions/[id] for student (Tasks 8, 9, 10)
- ✅ Admin/curator split-view at /admin/questions, /curator/questions (Task 11)
- ✅ File attachments up to 10 MB (Task 7, Task 10)
- ✅ Open/closed status, only staff can close (Task 6, Task 11)
- ✅ Email notifications: school on new question / new student message (Tasks 5, 3)
- ✅ Email notifications: student when staff replies (Task 5)
- ✅ Admin sidebar badge (Task 12)
- ✅ Homework update notifications (Task 13)
- ✅ Read tracking (Tasks 4, 10)
- ✅ "Вопросы" in student header nav (Task 12)
**Type consistency:**
- `FileAttachment` interface: `{name, url, size}` — consistent across Tasks 7, 10, 11
- `QuestionStatus`: `"OPEN" | "CLOSED"` string — used consistently (Prisma enum maps to string in TS)
- `params: Promise<{ id: string }>` — Next.js 16 async params pattern, used in Tasks 4, 5, 6
- `auth.api.getSession({ headers: await headers() })` — consistent auth pattern throughout