# 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 (
← Все вопросы

Новый вопрос

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)", }} />