1988 lines
60 KiB
Markdown
1988 lines
60 KiB
Markdown
# Student Questions — Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Add a threaded student question system with file attachments, open/closed status, email notifications for all parties, and a split-view admin/curator interface.
|
||
|
||
**Architecture:** Two new Prisma models (`StudentQuestion`, `StudentQuestionMessage`) backed by REST API routes at `/api/questions/*`. Student pages under `(student)/questions/`. Admin and curator share a client-side split-view component (`QuestionSplitView`). Email notifications via existing Resend helpers in `src/lib/email.ts`. Also adds email notification when a student updates an existing homework submission.
|
||
|
||
**Tech Stack:** Next.js 16 App Router, Prisma 7 (output `src/generated/prisma`), PostgreSQL, Better Auth, Resend, AWS S3 (Hetzner), TypeScript, Tailwind v4
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
| Action | Path | Responsibility |
|
||
|---|---|---|
|
||
| Modify | `prisma/schema.prisma` | Add `QuestionStatus` enum, `StudentQuestion`, `StudentQuestionMessage`, back-relations on `User` and `Course` |
|
||
| New | `src/app/api/questions/route.ts` | GET list, POST create |
|
||
| New | `src/app/api/questions/[id]/route.ts` | GET detail + mark messages read |
|
||
| New | `src/app/api/questions/[id]/messages/route.ts` | POST add message |
|
||
| New | `src/app/api/questions/[id]/close/route.ts` | PATCH close question |
|
||
| New | `src/app/api/student/question-upload/route.ts` | POST upload attachment file |
|
||
| Modify | `src/lib/email.ts` | Add `sendQuestionCreatedEmail`, `sendQuestionReplyEmail`, `sendHomeworkUpdatedEmail` |
|
||
| New | `src/app/(student)/questions/page.tsx` | Student questions list (server) |
|
||
| New | `src/app/(student)/questions/new/page.tsx` | New question form (client) |
|
||
| New | `src/app/(student)/questions/[id]/page.tsx` | Thread view — server fetch + client reply |
|
||
| New | `src/components/questions/QuestionThread.tsx` | Client component: messages + reply form |
|
||
| New | `src/app/admin/questions/page.tsx` | Admin questions page (renders QuestionSplitView) |
|
||
| New | `src/app/curator/questions/page.tsx` | Curator questions page (same) |
|
||
| New | `src/components/questions/QuestionSplitView.tsx` | Client split-view: list + selected thread + reply |
|
||
| Modify | `src/components/admin/admin-nav.tsx` | Add "Вопросы" link with unread badge |
|
||
| Modify | `src/components/admin/admin-shell.tsx` | Accept + forward `questionsBadge: number` prop |
|
||
| Modify | `src/app/admin/layout.tsx` | Fetch unread question count, pass to AdminShell |
|
||
| Modify | `src/app/curator/layout.tsx` | Add "Вопросы" link with badge to curator sidebar |
|
||
| Modify | `src/app/(student)/layout.tsx` | Add "Вопросы" link to student header nav |
|
||
| Modify | `src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts` | Send email when student updates existing submission |
|
||
|
||
---
|
||
|
||
## Task 1: Prisma Schema
|
||
|
||
**Files:**
|
||
- Modify: `prisma/schema.prisma`
|
||
|
||
- [ ] **Step 1: Add QuestionStatus enum and two new models**
|
||
|
||
Find the end of the schema (after `LessonComment` model) and add:
|
||
|
||
```prisma
|
||
// ─────────────────────────────────────────────
|
||
// Student Questions
|
||
// ─────────────────────────────────────────────
|
||
|
||
enum QuestionStatus {
|
||
OPEN
|
||
CLOSED
|
||
}
|
||
|
||
model StudentQuestion {
|
||
id String @id @default(cuid())
|
||
userId String
|
||
courseId String?
|
||
title String
|
||
status QuestionStatus @default(OPEN)
|
||
closedAt DateTime?
|
||
closedById String?
|
||
createdAt DateTime @default(now())
|
||
updatedAt DateTime @updatedAt
|
||
|
||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
|
||
closedBy User? @relation("QuestionClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
|
||
messages StudentQuestionMessage[]
|
||
}
|
||
|
||
model StudentQuestionMessage {
|
||
id String @id @default(cuid())
|
||
questionId String
|
||
authorId String
|
||
text String
|
||
files Json? // [{name, url, size}]
|
||
isRead Boolean @default(false)
|
||
createdAt DateTime @default(now())
|
||
|
||
question StudentQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
|
||
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Add back-relations to User model**
|
||
|
||
In `model User { ... }`, after the last relation line (e.g. after `feedbacks HomeworkFeedback[]`), add:
|
||
|
||
```prisma
|
||
questions StudentQuestion[]
|
||
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
|
||
questionMessages StudentQuestionMessage[]
|
||
```
|
||
|
||
- [ ] **Step 3: Add back-relation to Course model**
|
||
|
||
In `model Course { ... }`, after the existing relation lines, add:
|
||
|
||
```prisma
|
||
questions StudentQuestion[]
|
||
```
|
||
|
||
- [ ] **Step 4: Create and apply migration**
|
||
|
||
```bash
|
||
cd /path/to/lms-system
|
||
npx prisma migrate dev --name add_student_questions
|
||
```
|
||
|
||
Expected output: `Your database is now in sync with your schema.`
|
||
|
||
- [ ] **Step 5: Verify generated types exist**
|
||
|
||
```bash
|
||
grep -r "StudentQuestion\b" src/generated/prisma/models/ | head -3
|
||
```
|
||
|
||
Expected: at least 3 lines mentioning `StudentQuestion`.
|
||
|
||
- [ ] **Step 6: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add prisma/schema.prisma prisma/migrations/
|
||
git commit -m "Add StudentQuestion and StudentQuestionMessage models"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Email Helpers
|
||
|
||
**Files:**
|
||
- Modify: `src/lib/email.ts`
|
||
|
||
- [ ] **Step 1: Add three email helpers at the end of the file**
|
||
|
||
```typescript
|
||
export async function sendQuestionCreatedEmail(
|
||
to: string,
|
||
recipientName: string,
|
||
studentName: string,
|
||
questionTitle: string,
|
||
) {
|
||
const school = await getSchoolName();
|
||
await getResend().emails.send({
|
||
from: FROM,
|
||
to,
|
||
subject: `Новый вопрос от ${studentName}`,
|
||
html: base(`
|
||
<p ${p}>Привет, ${recipientName}!</p>
|
||
<p ${p}>Студент <strong>${studentName}</strong> задал новый вопрос:</p>
|
||
${quote(questionTitle)}
|
||
<p ${pLast}>Откройте панель, чтобы ответить:</p>
|
||
${btn(`${BASE_URL}/admin/questions`, "Перейти к вопросам")}
|
||
`, school),
|
||
}).catch((e) => console.error("[email] sendQuestionCreatedEmail:", e));
|
||
}
|
||
|
||
export async function sendQuestionReplyEmail(
|
||
to: string,
|
||
studentName: string,
|
||
questionTitle: string,
|
||
questionId: string,
|
||
) {
|
||
const school = await getSchoolName();
|
||
await getResend().emails.send({
|
||
from: FROM,
|
||
to,
|
||
subject: `Ответ на ваш вопрос`,
|
||
html: base(`
|
||
<p ${p}>Привет, ${studentName}!</p>
|
||
<p ${p}>Школа ответила на ваш вопрос:</p>
|
||
${quote(questionTitle)}
|
||
<p ${pLast}>Откройте тред чтобы прочитать ответ:</p>
|
||
${btn(`${BASE_URL}/questions/${questionId}`, "Читать ответ")}
|
||
`, school),
|
||
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
|
||
}
|
||
|
||
export async function sendHomeworkUpdatedEmail(
|
||
to: string,
|
||
recipientName: string,
|
||
studentName: string,
|
||
lessonTitle: string,
|
||
submissionId: string,
|
||
) {
|
||
const school = await getSchoolName();
|
||
await getResend().emails.send({
|
||
from: FROM,
|
||
to,
|
||
subject: `Студент обновил работу — ${lessonTitle}`,
|
||
html: base(`
|
||
<p ${p}>Привет, ${recipientName}!</p>
|
||
<p ${p}>Студент <strong>${studentName}</strong> обновил работу по уроку <strong>«${lessonTitle}»</strong>.</p>
|
||
<p ${pLast}>Откройте работу чтобы проверить изменения:</p>
|
||
${btn(`${BASE_URL}/curator/homework/${submissionId}`, "Открыть работу")}
|
||
`, school),
|
||
}).catch((e) => console.error("[email] sendHomeworkUpdatedEmail:", e));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/lib/email.ts
|
||
git commit -m "Add question and homework-update email helpers"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: API — Questions List + Create
|
||
|
||
**Files:**
|
||
- New: `src/app/api/questions/route.ts`
|
||
|
||
- [ ] **Step 1: Create the file**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { prisma } from "@/lib/prisma";
|
||
import { sendQuestionCreatedEmail } from "@/lib/email";
|
||
|
||
export async function GET() {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||
|
||
const isStaff = session.user.role === "admin" || session.user.role === "curator";
|
||
|
||
const questions = await prisma.studentQuestion.findMany({
|
||
where: isStaff ? undefined : { userId: session.user.id },
|
||
include: {
|
||
user: { select: { id: true, name: true, email: true } },
|
||
course: { select: { id: true, title: true } },
|
||
_count: {
|
||
select: {
|
||
messages: {
|
||
where: isStaff
|
||
? { isRead: false, author: { role: "student" } }
|
||
: { isRead: false, NOT: { authorId: session.user.id } },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
orderBy: { updatedAt: "desc" },
|
||
});
|
||
|
||
return NextResponse.json(
|
||
questions.map((q) => ({
|
||
id: q.id,
|
||
title: q.title,
|
||
status: q.status,
|
||
createdAt: q.createdAt,
|
||
updatedAt: q.updatedAt,
|
||
user: q.user,
|
||
course: q.course,
|
||
unreadCount: q._count.messages,
|
||
}))
|
||
);
|
||
}
|
||
|
||
export async function POST(req: NextRequest) {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||
if (session.user.role !== "student") {
|
||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||
}
|
||
|
||
const body = await req.json();
|
||
const { title, text, courseId } = body as {
|
||
title: string;
|
||
text: string;
|
||
courseId?: string;
|
||
};
|
||
|
||
if (!title?.trim() || !text?.trim()) {
|
||
return NextResponse.json({ error: "title and text are required" }, { status: 400 });
|
||
}
|
||
|
||
const question = await prisma.studentQuestion.create({
|
||
data: {
|
||
userId: session.user.id,
|
||
courseId: courseId ?? null,
|
||
title: title.trim(),
|
||
messages: {
|
||
create: {
|
||
authorId: session.user.id,
|
||
text: text.trim(),
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
const staff = await prisma.user.findMany({
|
||
where: { role: { in: ["admin", "curator"] } },
|
||
select: { email: true, name: true },
|
||
});
|
||
await Promise.all(
|
||
staff.map((s) =>
|
||
sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim())
|
||
)
|
||
);
|
||
|
||
return NextResponse.json(question, { status: 201 });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 3: Smoke test (manual)**
|
||
|
||
Start dev server: `npm run dev`
|
||
|
||
```bash
|
||
# Get session cookie first by logging in at http://localhost:3000/login, then:
|
||
curl -s http://localhost:3000/api/questions \
|
||
-H "Cookie: <paste session cookie>" | jq '.'
|
||
```
|
||
|
||
Expected: `[]` (empty array, no error).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/api/questions/route.ts
|
||
git commit -m "Add GET /api/questions and POST /api/questions"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: API — Question Detail
|
||
|
||
**Files:**
|
||
- New: `src/app/api/questions/[id]/route.ts`
|
||
|
||
- [ ] **Step 1: Create the file**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { prisma } from "@/lib/prisma";
|
||
|
||
export async function GET(
|
||
_req: NextRequest,
|
||
{ params }: { params: Promise<{ id: string }> }
|
||
) {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||
|
||
const { id } = await params;
|
||
const isStaff = session.user.role === "admin" || session.user.role === "curator";
|
||
|
||
const question = await prisma.studentQuestion.findUnique({
|
||
where: { id },
|
||
include: {
|
||
user: { select: { id: true, name: true } },
|
||
course: { select: { id: true, title: true } },
|
||
messages: {
|
||
include: { author: { select: { id: true, name: true, role: true } } },
|
||
orderBy: { createdAt: "asc" },
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||
if (!isStaff && question.userId !== session.user.id) {
|
||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||
}
|
||
|
||
// Mark unread messages as read
|
||
const unreadWhere = isStaff
|
||
? { questionId: id, isRead: false, author: { role: "student" } }
|
||
: { questionId: id, isRead: false, NOT: { authorId: session.user.id } };
|
||
|
||
await prisma.studentQuestionMessage.updateMany({
|
||
where: unreadWhere,
|
||
data: { isRead: true },
|
||
});
|
||
|
||
return NextResponse.json(question);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/app/api/questions/[id]/route.ts
|
||
git commit -m "Add GET /api/questions/[id] with read tracking"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: API — Add Message
|
||
|
||
**Files:**
|
||
- New: `src/app/api/questions/[id]/messages/route.ts`
|
||
|
||
- [ ] **Step 1: Create the file**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { prisma } from "@/lib/prisma";
|
||
import { sendQuestionCreatedEmail, sendQuestionReplyEmail } from "@/lib/email";
|
||
|
||
interface FileAttachment {
|
||
name: string;
|
||
url: string;
|
||
size: number;
|
||
}
|
||
|
||
export async function POST(
|
||
req: NextRequest,
|
||
{ params }: { params: Promise<{ id: string }> }
|
||
) {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||
|
||
const { id } = await params;
|
||
const isStaff = session.user.role === "admin" || session.user.role === "curator";
|
||
|
||
const question = await prisma.studentQuestion.findUnique({
|
||
where: { id },
|
||
include: { user: { select: { id: true, name: true, email: true } } },
|
||
});
|
||
|
||
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||
if (!isStaff && question.userId !== session.user.id) {
|
||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||
}
|
||
|
||
const body = await req.json();
|
||
const { text, files } = body as { text: string; files?: FileAttachment[] };
|
||
|
||
if (!text?.trim()) {
|
||
return NextResponse.json({ error: "text is required" }, { status: 400 });
|
||
}
|
||
|
||
const message = await prisma.studentQuestionMessage.create({
|
||
data: {
|
||
questionId: id,
|
||
authorId: session.user.id,
|
||
text: text.trim(),
|
||
files: files?.length ? (files as object[]) : undefined,
|
||
},
|
||
include: { author: { select: { id: true, name: true, role: true } } },
|
||
});
|
||
|
||
// Touch question.updatedAt so it surfaces in the sorted list
|
||
await prisma.studentQuestion.update({
|
||
where: { id },
|
||
data: { updatedAt: new Date() },
|
||
});
|
||
|
||
// Send notifications
|
||
if (isStaff) {
|
||
// Staff replied → notify student
|
||
await sendQuestionReplyEmail(
|
||
question.user.email,
|
||
question.user.name,
|
||
question.title,
|
||
id,
|
||
);
|
||
} else {
|
||
// Student added message → notify all staff
|
||
const staff = await prisma.user.findMany({
|
||
where: { role: { in: ["admin", "curator"] } },
|
||
select: { email: true, name: true },
|
||
});
|
||
await Promise.all(
|
||
staff.map((s) =>
|
||
sendQuestionCreatedEmail(s.email, s.name, session.user.name, question.title)
|
||
)
|
||
);
|
||
}
|
||
|
||
return NextResponse.json(message, { status: 201 });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/app/api/questions/[id]/messages/route.ts
|
||
git commit -m "Add POST /api/questions/[id]/messages with email notifications"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: API — Close Question
|
||
|
||
**Files:**
|
||
- New: `src/app/api/questions/[id]/close/route.ts`
|
||
|
||
- [ ] **Step 1: Create the file**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { prisma } from "@/lib/prisma";
|
||
|
||
export async function PATCH(
|
||
_req: NextRequest,
|
||
{ params }: { params: Promise<{ id: string }> }
|
||
) {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||
|
||
if (session.user.role !== "admin" && session.user.role !== "curator") {
|
||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||
}
|
||
|
||
const { id } = await params;
|
||
|
||
const question = await prisma.studentQuestion.findUnique({ where: { id } });
|
||
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||
if (question.status === "CLOSED") {
|
||
return NextResponse.json({ error: "Already closed" }, { status: 400 });
|
||
}
|
||
|
||
const updated = await prisma.studentQuestion.update({
|
||
where: { id },
|
||
data: { status: "CLOSED", closedAt: new Date(), closedById: session.user.id },
|
||
});
|
||
|
||
return NextResponse.json(updated);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/app/api/questions/[id]/close/route.ts
|
||
git commit -m "Add PATCH /api/questions/[id]/close"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: API — File Upload
|
||
|
||
**Files:**
|
||
- New: `src/app/api/student/question-upload/route.ts`
|
||
|
||
- [ ] **Step 1: Create the file**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from "next/server";
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { uploadFile } from "@/lib/s3";
|
||
import { randomUUID } from "crypto";
|
||
|
||
const ALLOWED_TYPES = new Set([
|
||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||
"application/pdf", "text/markdown", "text/plain",
|
||
]);
|
||
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
||
|
||
export async function POST(req: NextRequest) {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||
|
||
const form = await req.formData();
|
||
const file = form.get("file") as File | null;
|
||
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||
|
||
if (file.size > MAX_BYTES) {
|
||
return NextResponse.json({ error: "Файл слишком большой (макс. 10 МБ)" }, { status: 413 });
|
||
}
|
||
|
||
if (!ALLOWED_TYPES.has(file.type)) {
|
||
return NextResponse.json(
|
||
{ error: "Разрешены только jpg, png, pdf, md" },
|
||
{ status: 415 }
|
||
);
|
||
}
|
||
|
||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
|
||
const key = `questions/tmp/${session.user.id}/${randomUUID()}.${ext}`;
|
||
const buffer = Buffer.from(await file.arrayBuffer());
|
||
const url = await uploadFile(key, buffer, file.type);
|
||
|
||
return NextResponse.json({ name: file.name, url, size: file.size });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/app/api/student/question-upload/route.ts
|
||
git commit -m "Add question file upload endpoint"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Student — Questions List Page
|
||
|
||
**Files:**
|
||
- New: `src/app/(student)/questions/page.tsx`
|
||
|
||
- [ ] **Step 1: Create the page**
|
||
|
||
```typescript
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { redirect } from "next/navigation";
|
||
import { prisma } from "@/lib/prisma";
|
||
import Link from "next/link";
|
||
|
||
export default async function QuestionsPage() {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) redirect("/login");
|
||
|
||
const questions = await prisma.studentQuestion.findMany({
|
||
where: { userId: session.user.id },
|
||
include: {
|
||
_count: {
|
||
select: {
|
||
messages: {
|
||
where: { isRead: false, NOT: { authorId: session.user.id } },
|
||
},
|
||
},
|
||
},
|
||
messages: {
|
||
orderBy: { createdAt: "desc" },
|
||
take: 1,
|
||
include: { author: { select: { name: true } } },
|
||
},
|
||
},
|
||
orderBy: { updatedAt: "desc" },
|
||
});
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h1 className="text-xl font-bold" style={{ color: "var(--foreground)" }}>
|
||
Мои вопросы
|
||
</h1>
|
||
<Link
|
||
href="/questions/new"
|
||
className="text-sm font-bold px-4 py-2"
|
||
style={{
|
||
background: "var(--surface)",
|
||
border: "2px solid var(--border-strong)",
|
||
color: "var(--foreground)",
|
||
}}
|
||
>
|
||
+ Задать вопрос
|
||
</Link>
|
||
</div>
|
||
|
||
{questions.length === 0 && (
|
||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||
У вас ещё нет вопросов.
|
||
</p>
|
||
)}
|
||
|
||
<div className="flex flex-col gap-2">
|
||
{questions.map((q) => {
|
||
const unread = q._count.messages > 0;
|
||
const lastMsg = q.messages[0];
|
||
return (
|
||
<Link
|
||
key={q.id}
|
||
href={`/questions/${q.id}`}
|
||
className="block p-3 rounded-sm transition-colors"
|
||
style={{
|
||
border: unread ? "2px solid var(--border-strong)" : "1px solid var(--border)",
|
||
background: q.status === "CLOSED" ? "var(--surface-muted)" : "var(--surface)",
|
||
opacity: q.status === "CLOSED" ? 0.7 : 1,
|
||
}}
|
||
>
|
||
<div className="flex items-start justify-between gap-2 mb-1">
|
||
<span
|
||
className="text-sm"
|
||
style={{
|
||
fontWeight: unread ? 700 : 400,
|
||
color: q.status === "CLOSED" ? "var(--muted-foreground)" : "var(--foreground)",
|
||
}}
|
||
>
|
||
{q.title}
|
||
</span>
|
||
<span
|
||
className="text-xs shrink-0 px-1.5 py-0.5 rounded-sm"
|
||
style={
|
||
q.status === "OPEN"
|
||
? { background: "#E8F0D8", border: "1px solid var(--border)", color: "var(--foreground)" }
|
||
: { background: "var(--surface-muted)", color: "var(--muted-foreground)" }
|
||
}
|
||
>
|
||
{q.status === "OPEN" ? "● ОТКРЫТ" : "✓ ЗАКРЫТ"}
|
||
</span>
|
||
</div>
|
||
{unread && (
|
||
<div className="flex items-center gap-1.5 mb-1">
|
||
<span
|
||
className="inline-block w-2 h-2 rounded-full"
|
||
style={{ background: "var(--foreground)" }}
|
||
/>
|
||
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
|
||
Новый ответ от школы
|
||
</span>
|
||
</div>
|
||
)}
|
||
{lastMsg && !unread && (
|
||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||
{q.messages.length > 0
|
||
? `${q.messages.length > 1 ? "сообщений" : "сообщение"} · последнее от ${lastMsg.author.name}`
|
||
: ""}
|
||
</p>
|
||
)}
|
||
</Link>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 3: Verify page renders at http://localhost:3000/questions (logged in as student)**
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/(student)/questions/page.tsx
|
||
git commit -m "Add student questions list page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Student — New Question Form
|
||
|
||
**Files:**
|
||
- New: `src/app/(student)/questions/new/page.tsx`
|
||
|
||
- [ ] **Step 1: Create the page**
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
|
||
export default function NewQuestionPage() {
|
||
const router = useRouter();
|
||
const [title, setTitle] = useState("");
|
||
const [text, setText] = useState("");
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!title.trim() || !text.trim()) return;
|
||
setLoading(true);
|
||
setError("");
|
||
|
||
try {
|
||
const res = await fetch("/api/questions", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ title, text }),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json();
|
||
throw new Error(data.error ?? "Ошибка при создании вопроса");
|
||
}
|
||
const q = await res.json();
|
||
router.push(`/questions/${q.id}`);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Ошибка");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="max-w-xl mx-auto px-4 py-8">
|
||
<div className="flex items-center gap-3 mb-6">
|
||
<a
|
||
href="/questions"
|
||
className="text-sm"
|
||
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
|
||
>
|
||
← Все вопросы
|
||
</a>
|
||
</div>
|
||
|
||
<h1 className="text-xl font-bold mb-6" style={{ color: "var(--foreground)" }}>
|
||
Новый вопрос
|
||
</h1>
|
||
|
||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||
<div>
|
||
<label
|
||
className="block text-sm font-bold mb-1"
|
||
style={{ color: "var(--foreground)" }}
|
||
>
|
||
Тема вопроса
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
placeholder="Кратко опишите суть вопроса"
|
||
required
|
||
className="w-full text-sm px-3 py-2 outline-none"
|
||
style={{
|
||
border: "2px solid var(--border)",
|
||
background: "var(--surface)",
|
||
color: "var(--foreground)",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label
|
||
className="block text-sm font-bold mb-1"
|
||
style={{ color: "var(--foreground)" }}
|
||
>
|
||
Описание
|
||
</label>
|
||
<textarea
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
placeholder="Подробно опишите вопрос или проблему"
|
||
required
|
||
rows={6}
|
||
className="w-full text-sm px-3 py-2 outline-none resize-none"
|
||
style={{
|
||
border: "2px solid var(--border)",
|
||
background: "var(--surface)",
|
||
color: "var(--foreground)",
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="text-sm" style={{ color: "#c00" }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={loading || !title.trim() || !text.trim()}
|
||
className="self-end text-sm font-bold px-6 py-2"
|
||
style={{
|
||
background: "var(--foreground)",
|
||
color: "var(--background)",
|
||
border: "none",
|
||
opacity: loading ? 0.6 : 1,
|
||
}}
|
||
>
|
||
{loading ? "Отправка..." : "Отправить →"}
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 3: Test in browser — create a question, confirm redirect to thread**
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/(student)/questions/new/page.tsx
|
||
git commit -m "Add new question form page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Student — Thread Page
|
||
|
||
**Files:**
|
||
- New: `src/components/questions/QuestionThread.tsx`
|
||
- New: `src/app/(student)/questions/[id]/page.tsx`
|
||
|
||
- [ ] **Step 1: Create the thread client component**
|
||
|
||
Create `src/components/questions/QuestionThread.tsx`:
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useState, useRef } from "react";
|
||
|
||
interface FileAttachment {
|
||
name: string;
|
||
url: string;
|
||
size: number;
|
||
}
|
||
|
||
interface Message {
|
||
id: string;
|
||
authorId: string;
|
||
text: string;
|
||
files: FileAttachment[] | null;
|
||
isRead: boolean;
|
||
createdAt: string;
|
||
author: { id: string; name: string; role: string };
|
||
}
|
||
|
||
interface QuestionThreadProps {
|
||
questionId: string;
|
||
questionStatus: "OPEN" | "CLOSED";
|
||
currentUserId: string;
|
||
initialMessages: Message[];
|
||
}
|
||
|
||
function formatDate(iso: string) {
|
||
return new Date(iso).toLocaleString("ru", {
|
||
day: "numeric",
|
||
month: "long",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
function formatFileSize(bytes: number) {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
export function QuestionThread({
|
||
questionId,
|
||
questionStatus,
|
||
currentUserId,
|
||
initialMessages,
|
||
}: QuestionThreadProps) {
|
||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||
const [text, setText] = useState("");
|
||
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||
const [uploading, setUploading] = useState(false);
|
||
const [sending, setSending] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||
const selected = Array.from(e.target.files ?? []);
|
||
if (!selected.length) return;
|
||
setUploading(true);
|
||
setError("");
|
||
|
||
try {
|
||
const uploaded: FileAttachment[] = [];
|
||
for (const f of selected) {
|
||
const form = new FormData();
|
||
form.append("file", f);
|
||
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
|
||
if (!res.ok) {
|
||
const d = await res.json();
|
||
throw new Error(d.error ?? "Ошибка загрузки файла");
|
||
}
|
||
uploaded.push(await res.json());
|
||
}
|
||
setFiles((prev) => [...prev, ...uploaded]);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Ошибка загрузки");
|
||
} finally {
|
||
setUploading(false);
|
||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||
}
|
||
}
|
||
|
||
async function handleSend() {
|
||
if (!text.trim() && files.length === 0) return;
|
||
setSending(true);
|
||
setError("");
|
||
|
||
try {
|
||
const res = await fetch(`/api/questions/${questionId}/messages`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ text: text.trim(), files }),
|
||
});
|
||
if (!res.ok) {
|
||
const d = await res.json();
|
||
throw new Error(d.error ?? "Ошибка отправки");
|
||
}
|
||
const msg = await res.json();
|
||
setMessages((prev) => [...prev, msg]);
|
||
setText("");
|
||
setFiles([]);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Ошибка");
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2 w-full">
|
||
{/* Messages */}
|
||
<div className="flex flex-col gap-2 mb-4 max-h-96 overflow-y-auto pr-1">
|
||
{messages.map((msg) => {
|
||
const isMine = msg.authorId === currentUserId;
|
||
const isNew = !msg.isRead && !isMine;
|
||
return (
|
||
<div
|
||
key={msg.id}
|
||
className="max-w-[88%] px-3 py-2 rounded-sm text-sm"
|
||
style={{
|
||
alignSelf: isMine ? "flex-start" : "flex-end",
|
||
background: isMine ? "#E8E8E0" : "#F5F5F0",
|
||
border: isMine ? "none" : `2px solid #E8F0D8${isNew ? "" : ""}`,
|
||
borderLeft: !isMine && isNew ? "3px solid #323232" : undefined,
|
||
}}
|
||
>
|
||
<p
|
||
className="text-xs mb-1"
|
||
style={{ color: "var(--muted-foreground)" }}
|
||
>
|
||
{isMine ? "Ты" : msg.author.name} · {formatDate(msg.createdAt)}
|
||
{isNew && (
|
||
<strong style={{ color: "#323232" }}> · 🔵 новое</strong>
|
||
)}
|
||
</p>
|
||
<p style={{ color: "var(--foreground)" }}>{msg.text}</p>
|
||
{msg.files && msg.files.length > 0 && (
|
||
<div className="flex flex-col gap-1 mt-2">
|
||
{msg.files.map((f, i) => (
|
||
<a
|
||
key={i}
|
||
href={f.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
|
||
style={{
|
||
background: "#F5F5F0",
|
||
border: "1px solid #AAAAAA",
|
||
color: "var(--foreground)",
|
||
width: "fit-content",
|
||
}}
|
||
>
|
||
📎 <span>{f.name}</span>
|
||
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Queued files preview */}
|
||
{files.length > 0 && (
|
||
<div className="flex flex-wrap gap-1 mb-2">
|
||
{files.map((f, i) => (
|
||
<div
|
||
key={i}
|
||
className="flex items-center gap-1 text-xs px-2 py-1 rounded-sm"
|
||
style={{ background: "var(--surface)", border: "1px solid var(--border)" }}
|
||
>
|
||
📎 {f.name}
|
||
<button
|
||
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
|
||
className="ml-1 text-xs"
|
||
style={{ color: "var(--muted-foreground)" }}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Reply form */}
|
||
<div
|
||
className="rounded-sm p-2"
|
||
style={{
|
||
border: "2px solid #AAAAAA",
|
||
background: "var(--surface)",
|
||
opacity: questionStatus === "CLOSED" ? 0.5 : 1,
|
||
pointerEvents: questionStatus === "CLOSED" ? "none" : "auto",
|
||
}}
|
||
>
|
||
<textarea
|
||
value={text}
|
||
onChange={(e) => setText(e.target.value)}
|
||
placeholder={questionStatus === "CLOSED" ? "Вопрос закрыт" : "Написать сообщение..."}
|
||
rows={3}
|
||
disabled={questionStatus === "CLOSED"}
|
||
className="w-full text-sm outline-none resize-none bg-transparent"
|
||
style={{ border: "none", color: "var(--foreground)" }}
|
||
/>
|
||
<div
|
||
className="flex items-center justify-between pt-2 mt-1"
|
||
style={{ borderTop: "1px solid #E8E8E0" }}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
|
||
className="hidden"
|
||
onChange={handleFileSelect}
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={uploading}
|
||
className="text-xs px-2 py-1 rounded-sm"
|
||
style={{ background: "var(--surface)", border: "1px solid #AAAAAA" }}
|
||
>
|
||
{uploading ? "Загрузка..." : "📎 Прикрепить"}
|
||
</button>
|
||
<span className="text-xs" style={{ color: "#999" }}>
|
||
jpg, png, pdf, md · до 10 МБ
|
||
</span>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={handleSend}
|
||
disabled={sending || (!text.trim() && files.length === 0)}
|
||
className="text-xs font-bold px-4 py-1.5 rounded-sm"
|
||
style={{
|
||
background: "var(--foreground)",
|
||
color: "var(--background)",
|
||
border: "none",
|
||
opacity: sending ? 0.6 : 1,
|
||
}}
|
||
>
|
||
{sending ? "..." : "Отправить →"}
|
||
</button>
|
||
</div>
|
||
{error && (
|
||
<p className="text-xs mt-1" style={{ color: "#c00" }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create the thread page**
|
||
|
||
Create `src/app/(student)/questions/[id]/page.tsx`:
|
||
|
||
```typescript
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { redirect, notFound } from "next/navigation";
|
||
import { prisma } from "@/lib/prisma";
|
||
import Link from "next/link";
|
||
import { QuestionThread } from "@/components/questions/QuestionThread";
|
||
|
||
export default async function QuestionThreadPage({
|
||
params,
|
||
}: {
|
||
params: Promise<{ id: string }>;
|
||
}) {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) redirect("/login");
|
||
|
||
const { id } = await params;
|
||
|
||
const question = await prisma.studentQuestion.findUnique({
|
||
where: { id },
|
||
include: {
|
||
user: { select: { id: true, name: true } },
|
||
messages: {
|
||
include: { author: { select: { id: true, name: true, role: true } } },
|
||
orderBy: { createdAt: "asc" },
|
||
},
|
||
},
|
||
});
|
||
|
||
if (!question || question.userId !== session.user.id) notFound();
|
||
|
||
// Mark staff messages as read
|
||
await prisma.studentQuestionMessage.updateMany({
|
||
where: {
|
||
questionId: id,
|
||
isRead: false,
|
||
NOT: { authorId: session.user.id },
|
||
},
|
||
data: { isRead: true },
|
||
});
|
||
|
||
return (
|
||
<div className="max-w-2xl mx-auto px-4 py-8">
|
||
<div className="flex items-start justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-lg font-bold mb-1" style={{ color: "var(--foreground)" }}>
|
||
{question.title}
|
||
</h1>
|
||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||
Создан{" "}
|
||
{new Date(question.createdAt).toLocaleDateString("ru", {
|
||
day: "numeric",
|
||
month: "long",
|
||
})}{" "}
|
||
·{" "}
|
||
<span
|
||
style={{
|
||
color: question.status === "OPEN" ? "var(--foreground)" : "var(--muted-foreground)",
|
||
fontWeight: 700,
|
||
}}
|
||
>
|
||
{question.status === "OPEN" ? "● Открыт" : "✓ Закрыт"}
|
||
</span>
|
||
</p>
|
||
</div>
|
||
<Link
|
||
href="/questions"
|
||
className="text-xs"
|
||
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
|
||
>
|
||
← Все вопросы
|
||
</Link>
|
||
</div>
|
||
|
||
<QuestionThread
|
||
questionId={id}
|
||
questionStatus={question.status}
|
||
currentUserId={session.user.id}
|
||
initialMessages={question.messages.map((m) => ({
|
||
...m,
|
||
files: m.files as Array<{ name: string; url: string; size: number }> | null,
|
||
createdAt: m.createdAt.toISOString(),
|
||
}))}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 4: Test in browser — open a question, send a reply, verify message appears**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/components/questions/QuestionThread.tsx src/app/(student)/questions/[id]/page.tsx
|
||
git commit -m "Add student question thread page with reply form"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Admin/Curator — Split-View
|
||
|
||
**Files:**
|
||
- New: `src/components/questions/QuestionSplitView.tsx`
|
||
- New: `src/app/admin/questions/page.tsx`
|
||
- New: `src/app/curator/questions/page.tsx`
|
||
|
||
- [ ] **Step 1: Create the split-view component**
|
||
|
||
Create `src/components/questions/QuestionSplitView.tsx`:
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
|
||
interface FileAttachment {
|
||
name: string;
|
||
url: string;
|
||
size: number;
|
||
}
|
||
|
||
interface Message {
|
||
id: string;
|
||
authorId: string;
|
||
text: string;
|
||
files: FileAttachment[] | null;
|
||
isRead: boolean;
|
||
createdAt: string;
|
||
author: { id: string; name: string; role: string };
|
||
}
|
||
|
||
interface QuestionSummary {
|
||
id: string;
|
||
title: string;
|
||
status: "OPEN" | "CLOSED";
|
||
unreadCount: number;
|
||
updatedAt: string;
|
||
user: { id: string; name: string };
|
||
course: { id: string; title: string } | null;
|
||
}
|
||
|
||
interface QuestionDetail {
|
||
id: string;
|
||
title: string;
|
||
status: "OPEN" | "CLOSED";
|
||
createdAt: string;
|
||
user: { id: string; name: string };
|
||
messages: Message[];
|
||
}
|
||
|
||
function formatDate(iso: string) {
|
||
return new Date(iso).toLocaleString("ru", {
|
||
day: "numeric",
|
||
month: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
}
|
||
|
||
function formatFileSize(bytes: number) {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
export function QuestionSplitView({ currentUserId }: { currentUserId: string }) {
|
||
const [questions, setQuestions] = useState<QuestionSummary[]>([]);
|
||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||
const [detail, setDetail] = useState<QuestionDetail | null>(null);
|
||
const [tab, setTab] = useState<"open" | "closed">("open");
|
||
const [replyText, setReplyText] = useState("");
|
||
const [sending, setSending] = useState(false);
|
||
const [closing, setClosing] = useState(false);
|
||
const [error, setError] = useState("");
|
||
|
||
const fetchList = useCallback(async () => {
|
||
const res = await fetch("/api/questions");
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setQuestions(data);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchList();
|
||
}, [fetchList]);
|
||
|
||
async function selectQuestion(id: string) {
|
||
setSelectedId(id);
|
||
setReplyText("");
|
||
setError("");
|
||
const res = await fetch(`/api/questions/${id}`);
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setDetail({
|
||
...data,
|
||
createdAt: data.createdAt,
|
||
messages: data.messages.map((m: Message & { createdAt: string }) => ({
|
||
...m,
|
||
files: m.files as FileAttachment[] | null,
|
||
})),
|
||
});
|
||
// Refresh list to clear unread badge
|
||
fetchList();
|
||
}
|
||
}
|
||
|
||
async function handleReply() {
|
||
if (!replyText.trim() || !selectedId) return;
|
||
setSending(true);
|
||
setError("");
|
||
try {
|
||
const res = await fetch(`/api/questions/${selectedId}/messages`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ text: replyText.trim() }),
|
||
});
|
||
if (!res.ok) throw new Error("Ошибка отправки");
|
||
const msg = await res.json();
|
||
setDetail((prev) => prev ? { ...prev, messages: [...prev.messages, msg] } : null);
|
||
setReplyText("");
|
||
fetchList();
|
||
} catch {
|
||
setError("Не удалось отправить ответ");
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
async function handleClose() {
|
||
if (!selectedId) return;
|
||
setClosing(true);
|
||
try {
|
||
const res = await fetch(`/api/questions/${selectedId}/close`, { method: "PATCH" });
|
||
if (!res.ok) throw new Error("Ошибка закрытия");
|
||
setDetail((prev) => prev ? { ...prev, status: "CLOSED" } : null);
|
||
fetchList();
|
||
} catch {
|
||
setError("Не удалось закрыть вопрос");
|
||
} finally {
|
||
setClosing(false);
|
||
}
|
||
}
|
||
|
||
const filtered = questions.filter((q) =>
|
||
tab === "open" ? q.status === "OPEN" : q.status === "CLOSED"
|
||
);
|
||
|
||
return (
|
||
<div className="flex h-[calc(100vh-56px)]">
|
||
{/* Left panel */}
|
||
<div
|
||
className="w-72 shrink-0 flex flex-col"
|
||
style={{ borderRight: "2px solid var(--border)" }}
|
||
>
|
||
{/* Tabs */}
|
||
<div className="flex p-2 gap-1" style={{ borderBottom: "1px solid var(--border)" }}>
|
||
{(["open", "closed"] as const).map((t) => (
|
||
<button
|
||
key={t}
|
||
onClick={() => setTab(t)}
|
||
className="text-xs px-3 py-1 rounded-sm font-medium"
|
||
style={
|
||
tab === t
|
||
? { background: "var(--foreground)", color: "var(--background)" }
|
||
: { background: "var(--surface-muted)", color: "var(--muted-foreground)" }
|
||
}
|
||
>
|
||
{t === "open"
|
||
? `Открытые ${questions.filter((q) => q.status === "OPEN").length}`
|
||
: "Закрытые"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* Question list */}
|
||
<div className="flex-1 overflow-y-auto">
|
||
{filtered.length === 0 && (
|
||
<p className="text-xs p-4" style={{ color: "var(--muted-foreground)" }}>
|
||
Нет вопросов
|
||
</p>
|
||
)}
|
||
{filtered.map((q) => (
|
||
<button
|
||
key={q.id}
|
||
onClick={() => selectQuestion(q.id)}
|
||
className="w-full text-left p-3 block"
|
||
style={{
|
||
borderLeft: selectedId === q.id ? "3px solid var(--foreground)" : "3px solid transparent",
|
||
borderBottom: "1px solid var(--border)",
|
||
background:
|
||
q.unreadCount > 0
|
||
? "#E8F0D8"
|
||
: selectedId === q.id
|
||
? "var(--surface)"
|
||
: "transparent",
|
||
}}
|
||
>
|
||
<p className="text-sm font-bold truncate" style={{ color: "var(--foreground)" }}>
|
||
{q.user.name}
|
||
</p>
|
||
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
|
||
{q.title}
|
||
</p>
|
||
<p className="text-xs mt-0.5" style={{ color: "#999" }}>
|
||
{formatDate(q.updatedAt)}
|
||
{q.unreadCount > 0 && (
|
||
<span style={{ color: "#c00", marginLeft: 4 }}>🔴 новое</span>
|
||
)}
|
||
</p>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right panel */}
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
{!detail ? (
|
||
<div className="flex-1 flex items-center justify-center">
|
||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||
Выберите вопрос
|
||
</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Thread header */}
|
||
<div
|
||
className="flex items-start justify-between p-4"
|
||
style={{ borderBottom: "1px solid var(--border)" }}
|
||
>
|
||
<div>
|
||
<p className="text-sm font-bold" style={{ color: "var(--foreground)" }}>
|
||
{detail.title}
|
||
</p>
|
||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||
{detail.user.name} ·{" "}
|
||
{new Date(detail.createdAt).toLocaleDateString("ru", {
|
||
day: "numeric",
|
||
month: "long",
|
||
})}
|
||
</p>
|
||
</div>
|
||
{detail.status === "OPEN" && (
|
||
<button
|
||
onClick={handleClose}
|
||
disabled={closing}
|
||
className="text-xs px-3 py-1.5 rounded-sm"
|
||
style={{
|
||
background: "var(--surface-muted)",
|
||
border: "1px solid var(--border)",
|
||
color: "var(--foreground)",
|
||
}}
|
||
>
|
||
{closing ? "..." : "✓ Закрыть вопрос"}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Messages */}
|
||
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3">
|
||
{detail.messages.map((msg) => {
|
||
const isMine = msg.authorId === currentUserId;
|
||
return (
|
||
<div
|
||
key={msg.id}
|
||
className="max-w-[85%] px-3 py-2 rounded-sm text-sm"
|
||
style={{
|
||
alignSelf: isMine ? "flex-end" : "flex-start",
|
||
background: isMine ? "#F5F5F0" : "#E8E8E0",
|
||
border: isMine ? "2px solid #E8F0D8" : "none",
|
||
}}
|
||
>
|
||
<p className="text-xs mb-1" style={{ color: "#666" }}>
|
||
{msg.author.name} · {formatDate(msg.createdAt)}
|
||
</p>
|
||
<p>{msg.text}</p>
|
||
{msg.files && msg.files.length > 0 && (
|
||
<div className="flex flex-col gap-1 mt-2">
|
||
{msg.files.map((f, i) => (
|
||
<a
|
||
key={i}
|
||
href={f.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
|
||
style={{
|
||
background: "#F5F5F0",
|
||
border: "1px solid #AAAAAA",
|
||
color: "var(--foreground)",
|
||
width: "fit-content",
|
||
}}
|
||
>
|
||
📎 {f.name}
|
||
<span style={{ color: "#999" }}>
|
||
· {formatFileSize(f.size)}
|
||
</span>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Reply form */}
|
||
<div className="p-3" style={{ borderTop: "1px solid var(--border)" }}>
|
||
<div
|
||
className="flex gap-2"
|
||
style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }}
|
||
>
|
||
<input
|
||
type="text"
|
||
value={replyText}
|
||
onChange={(e) => setReplyText(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleReply();
|
||
}
|
||
}}
|
||
disabled={detail.status === "CLOSED"}
|
||
placeholder={detail.status === "CLOSED" ? "Вопрос закрыт" : "Написать ответ..."}
|
||
className="flex-1 text-sm px-3 py-2 outline-none"
|
||
style={{
|
||
border: "1px solid var(--border)",
|
||
background: "var(--surface)",
|
||
color: "var(--foreground)",
|
||
}}
|
||
/>
|
||
<button
|
||
onClick={handleReply}
|
||
disabled={sending || !replyText.trim() || detail.status === "CLOSED"}
|
||
className="text-xs font-bold px-4 py-2"
|
||
style={{
|
||
background: "var(--foreground)",
|
||
color: "var(--background)",
|
||
border: "none",
|
||
opacity: sending ? 0.6 : 1,
|
||
}}
|
||
>
|
||
{sending ? "..." : "Отправить"}
|
||
</button>
|
||
</div>
|
||
{error && (
|
||
<p className="text-xs mt-1" style={{ color: "#c00" }}>
|
||
{error}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create admin questions page**
|
||
|
||
Create `src/app/admin/questions/page.tsx`:
|
||
|
||
```typescript
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { redirect } from "next/navigation";
|
||
import { QuestionSplitView } from "@/components/questions/QuestionSplitView";
|
||
|
||
export default async function AdminQuestionsPage() {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session || session.user.role !== "admin") redirect("/login");
|
||
|
||
return <QuestionSplitView currentUserId={session.user.id} />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create curator questions page**
|
||
|
||
Create `src/app/curator/questions/page.tsx`:
|
||
|
||
```typescript
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { redirect } from "next/navigation";
|
||
import { QuestionSplitView } from "@/components/questions/QuestionSplitView";
|
||
|
||
export default async function CuratorQuestionsPage() {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) redirect("/login");
|
||
if (session.user.role !== "admin" && session.user.role !== "curator") redirect("/dashboard");
|
||
|
||
return <QuestionSplitView currentUserId={session.user.id} />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 5: Test in browser as admin — visit /admin/questions, select a question, reply, close**
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/components/questions/QuestionSplitView.tsx \
|
||
src/app/admin/questions/page.tsx \
|
||
src/app/curator/questions/page.tsx
|
||
git commit -m "Add admin/curator split-view questions page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Navigation Updates + Badge
|
||
|
||
**Files:**
|
||
- Modify: `src/components/admin/admin-nav.tsx`
|
||
- Modify: `src/components/admin/admin-shell.tsx`
|
||
- Modify: `src/app/admin/layout.tsx`
|
||
- Modify: `src/app/curator/layout.tsx`
|
||
- Modify: `src/app/(student)/layout.tsx`
|
||
|
||
- [ ] **Step 1: Update AdminNav to accept and display badge**
|
||
|
||
In `src/components/admin/admin-nav.tsx`, replace the entire file with:
|
||
|
||
```typescript
|
||
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { usePathname } from "next/navigation";
|
||
|
||
const links = [
|
||
{ href: "/admin/dashboard", label: "Обзор" },
|
||
{ href: "/admin/courses", label: "Курсы" },
|
||
{ href: "/admin/categories", label: "Категории" },
|
||
{ href: "/admin/users", label: "Пользователи" },
|
||
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
||
{ href: "/admin/questions", label: "Вопросы", badgeKey: "questions" },
|
||
{ href: "/admin/quizzes", label: "Тесты" },
|
||
{ href: "/admin/comments", label: "Комментарии" },
|
||
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
|
||
{ href: "/admin/settings", label: "Настройки" },
|
||
];
|
||
|
||
export function AdminNav({ questionsBadge = 0 }: { questionsBadge?: number }) {
|
||
const pathname = usePathname();
|
||
|
||
return (
|
||
<>
|
||
{links.map(({ href, label, badgeKey }) => {
|
||
const active =
|
||
pathname === href ||
|
||
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
|
||
(href === "/curator/homework" && pathname.startsWith("/curator"));
|
||
return (
|
||
<Link
|
||
key={href}
|
||
href={href}
|
||
className="admin-sidebar-nav-link flex items-center justify-between"
|
||
style={
|
||
active
|
||
? {
|
||
color: "#E8F0D8",
|
||
borderLeftColor: "#E8F0D8",
|
||
backgroundColor: "var(--sidebar-surface)",
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
<span>{label}</span>
|
||
{badgeKey === "questions" && questionsBadge > 0 && (
|
||
<span
|
||
className="text-xs font-bold px-1.5 py-0.5 rounded-sm"
|
||
style={{ background: "#c00", color: "#fff", fontSize: "10px" }}
|
||
>
|
||
{questionsBadge}
|
||
</span>
|
||
)}
|
||
</Link>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Update AdminShell to forward badge**
|
||
|
||
In `src/components/admin/admin-shell.tsx`, change the component signature and `<AdminNav />` usage:
|
||
|
||
```typescript
|
||
// Change signature:
|
||
export function AdminShell({
|
||
children,
|
||
userName,
|
||
questionsBadge = 0,
|
||
}: {
|
||
children: React.ReactNode;
|
||
userName: string;
|
||
questionsBadge?: number;
|
||
}) {
|
||
// ...existing code...
|
||
// Change <AdminNav /> to:
|
||
// <AdminNav questionsBadge={questionsBadge} />
|
||
```
|
||
|
||
- [ ] **Step 3: Update admin/layout.tsx to fetch and pass badge**
|
||
|
||
In `src/app/admin/layout.tsx`, add badge fetch and pass to AdminShell:
|
||
|
||
```typescript
|
||
import { headers } from "next/headers";
|
||
import { auth } from "@/lib/auth";
|
||
import { redirect } from "next/navigation";
|
||
import { prisma } from "@/lib/prisma";
|
||
import { AdminShell } from "@/components/admin/admin-shell";
|
||
|
||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||
const session = await auth.api.getSession({ headers: await headers() });
|
||
if (!session) redirect("/login");
|
||
if (session.user.role !== "admin") redirect("/dashboard");
|
||
|
||
const questionsBadge = await prisma.studentQuestion.count({
|
||
where: {
|
||
status: "OPEN",
|
||
messages: {
|
||
some: { isRead: false, author: { role: "student" } },
|
||
},
|
||
},
|
||
});
|
||
|
||
return (
|
||
<AdminShell userName={session.user.name} questionsBadge={questionsBadge}>
|
||
{children}
|
||
</AdminShell>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Add Вопросы link to curator sidebar**
|
||
|
||
In `src/app/curator/layout.tsx`, inside the curator `<nav>` block, add after the homework link:
|
||
|
||
```typescript
|
||
<NavLink href="/curator/questions">Вопросы</NavLink>
|
||
```
|
||
|
||
- [ ] **Step 5: Add Вопросы link to student header**
|
||
|
||
In `src/app/(student)/layout.tsx`, in the `<header>` element, add a nav link between the logo and the right-side profile links. Find the `<div className="flex items-center gap-4">` that contains the profile link and logout, and add before it:
|
||
|
||
```typescript
|
||
<nav className="hidden md:flex items-center gap-4">
|
||
<Link
|
||
href="/questions"
|
||
className="text-sm hover:underline"
|
||
style={{ color: "var(--muted-foreground)" }}
|
||
>
|
||
Вопросы
|
||
</Link>
|
||
</nav>
|
||
```
|
||
|
||
- [ ] **Step 6: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 7: Test — admin sidebar shows "Вопросы" with badge when there are unread questions; student header shows "Вопросы" link**
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/components/admin/admin-nav.tsx \
|
||
src/components/admin/admin-shell.tsx \
|
||
src/app/admin/layout.tsx \
|
||
src/app/curator/layout.tsx \
|
||
src/app/(student)/layout.tsx
|
||
git commit -m "Add Вопросы nav links and unread badge for admin/curator/student"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Homework Update Notifications
|
||
|
||
**Files:**
|
||
- Modify: `src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts`
|
||
|
||
- [ ] **Step 1: Add sendHomeworkUpdatedEmail import and call**
|
||
|
||
In `src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts`, change the import line:
|
||
|
||
```typescript
|
||
import { sendHomeworkSubmittedEmail, sendHomeworkUpdatedEmail } from "@/lib/email";
|
||
```
|
||
|
||
Then in the `if (existing)` branch (update path), after the `prisma.homeworkSubmission.update(...)` call, add:
|
||
|
||
```typescript
|
||
if (existing) {
|
||
const updated = await prisma.homeworkSubmission.update({
|
||
where: { id: existing.id },
|
||
data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
|
||
});
|
||
submissionId = updated.id;
|
||
|
||
// Notify staff that the student updated their submission
|
||
const [lesson, admins] = await Promise.all([
|
||
prisma.homework.findUnique({
|
||
where: { id: homeworkId },
|
||
include: { lesson: { select: { title: true } } },
|
||
}),
|
||
prisma.user.findMany({
|
||
where: { role: { in: ["admin", "curator"] } },
|
||
select: { email: true, name: true },
|
||
}),
|
||
]);
|
||
if (lesson) {
|
||
await Promise.all(
|
||
admins.map((a) =>
|
||
sendHomeworkUpdatedEmail(a.email, a.name, session.user.name, lesson.lesson.title, submissionId)
|
||
)
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Type-check**
|
||
|
||
```bash
|
||
npm run type-check
|
||
```
|
||
|
||
- [ ] **Step 3: Build check**
|
||
|
||
```bash
|
||
npm run build
|
||
```
|
||
|
||
Expected: successful build, no type errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts
|
||
git commit -m "Send email to staff when student updates existing homework submission"
|
||
```
|
||
|
||
---
|
||
|
||
## Self-Review
|
||
|
||
**Spec coverage:**
|
||
- ✅ StudentQuestion + StudentQuestionMessage models (Task 1)
|
||
- ✅ Routes: /questions, /questions/new, /questions/[id] for student (Tasks 8, 9, 10)
|
||
- ✅ Admin/curator split-view at /admin/questions, /curator/questions (Task 11)
|
||
- ✅ File attachments up to 10 MB (Task 7, Task 10)
|
||
- ✅ Open/closed status, only staff can close (Task 6, Task 11)
|
||
- ✅ Email notifications: school on new question / new student message (Tasks 5, 3)
|
||
- ✅ Email notifications: student when staff replies (Task 5)
|
||
- ✅ Admin sidebar badge (Task 12)
|
||
- ✅ Homework update notifications (Task 13)
|
||
- ✅ Read tracking (Tasks 4, 10)
|
||
- ✅ "Вопросы" in student header nav (Task 12)
|
||
|
||
**Type consistency:**
|
||
- `FileAttachment` interface: `{name, url, size}` — consistent across Tasks 7, 10, 11
|
||
- `QuestionStatus`: `"OPEN" | "CLOSED"` string — used consistently (Prisma enum maps to string in TS)
|
||
- `params: Promise<{ id: string }>` — Next.js 16 async params pattern, used in Tasks 4, 5, 6
|
||
- `auth.api.getSession({ headers: await headers() })` — consistent auth pattern throughout
|