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

60 KiB
Raw Blame History

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:

// ─────────────────────────────────────────────
// 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:

  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:

  questions  StudentQuestion[]
  • Step 4: Create and apply migration
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
grep -r "StudentQuestion\b" src/generated/prisma/models/ | head -3

Expected: at least 3 lines mentioning StudentQuestion.

  • Step 6: Type-check
npm run type-check

Expected: no errors.

  • Step 7: Commit
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

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
npm run type-check
  • Step 3: Commit
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

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
npm run type-check
  • Step 3: Smoke test (manual)

Start dev server: npm run dev

# 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
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

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
npm run type-check
  • Step 3: Commit
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

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
npm run type-check
  • Step 3: Commit
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

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
npm run type-check
  • Step 3: Commit
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

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
npm run type-check
  • Step 3: Commit
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

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
npm run type-check
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

"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
npm run type-check
  • Step 3: Test in browser — create a question, confirm redirect to thread

  • Step 4: Commit

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:

"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:

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
npm run type-check
  • Step 4: Test in browser — open a question, send a reply, verify message appears

  • Step 5: Commit

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:

"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:

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:

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
npm run type-check
  • Step 5: Test in browser as admin — visit /admin/questions, select a question, reply, close

  • Step 6: Commit

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:

"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:

// 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:

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:

<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:

<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
npm run type-check
  • Step 7: Test — admin sidebar shows "Вопросы" with badge when there are unread questions; student header shows "Вопросы" link

  • Step 8: Commit

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:

import { sendHomeworkSubmittedEmail, sendHomeworkUpdatedEmail } from "@/lib/email";

Then in the if (existing) branch (update path), after the prisma.homeworkSubmission.update(...) call, add:

  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
npm run type-check
  • Step 3: Build check
npm run build

Expected: successful build, no type errors.

  • Step 4: Commit
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