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

+ Новый вопрос +

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