Compare commits
16 Commits
a27089bc0c
...
e5ba94cb33
| Author | SHA1 | Date | |
|---|---|---|---|
| e5ba94cb33 | |||
| 12e1785ff2 | |||
| bd1e77c2a3 | |||
| d2362a3f1e | |||
| 3a2f64d47d | |||
| d32186c101 | |||
| f7d428180b | |||
| c5d2caa345 | |||
| 89d614fa00 | |||
| a9e6272d2d | |||
| f2946db57a | |||
| 9cb56b9b04 | |||
| 6fa49d4113 | |||
| 90f155d334 | |||
| d47f79be1a | |||
| ec128f670a |
@@ -0,0 +1,159 @@
|
|||||||
|
# Student Questions — Design Spec
|
||||||
|
_Created: 20260519_
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A support-chat feature inside the LMS. Students ask questions, school staff (admin/curator) answers. Each question is a threaded conversation with open/closed status. Includes file attachments and email notifications for all parties.
|
||||||
|
|
||||||
|
Also in scope: email notifications for homework submissions (new + student updates).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### StudentQuestion
|
||||||
|
```
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String -- student who created it
|
||||||
|
courseId String? -- optional course context
|
||||||
|
title String
|
||||||
|
status QuestionStatus @default(OPEN) -- OPEN | CLOSED
|
||||||
|
closedAt DateTime?
|
||||||
|
closedById String? -- admin/curator who closed
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User
|
||||||
|
course Course?
|
||||||
|
messages StudentQuestionMessage[]
|
||||||
|
```
|
||||||
|
|
||||||
|
### StudentQuestionMessage
|
||||||
|
```
|
||||||
|
id String @id @default(cuid())
|
||||||
|
questionId String
|
||||||
|
authorId String
|
||||||
|
text String
|
||||||
|
files String[] -- S3 paths: questions/{questionId}/{messageId}/{filename}
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
question StudentQuestion
|
||||||
|
author User
|
||||||
|
```
|
||||||
|
|
||||||
|
### QuestionStatus (enum)
|
||||||
|
```
|
||||||
|
OPEN
|
||||||
|
CLOSED
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### Student
|
||||||
|
| Route | Description |
|
||||||
|
|---|---|
|
||||||
|
| `(student)/questions` | List of own questions with unread indicators |
|
||||||
|
| `(student)/questions/new` | Form to create a new question |
|
||||||
|
| `(student)/questions/[id]` | Thread view — read messages + reply |
|
||||||
|
|
||||||
|
### Admin / Curator
|
||||||
|
| Route | Description |
|
||||||
|
|---|---|
|
||||||
|
| `admin/questions` | Split-view: question list left, thread right |
|
||||||
|
| `curator/questions` | Same split-view (curators have same access as admins here) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Behaviour
|
||||||
|
|
||||||
|
### Student — Questions List (`/questions`)
|
||||||
|
- Header: "Мои вопросы" + "+ Задать вопрос" button (→ `/questions/new`)
|
||||||
|
- Each row: title, message count, last activity, status badge (ОТКРЫТ / ЗАКРЫТ)
|
||||||
|
- Unread indicator: black dot + "Новый ответ от школы" when school replied since last student visit
|
||||||
|
- Closed questions: dimmed (opacity 0.7), grey badge
|
||||||
|
- Active (unread) questions: bold border, bold title
|
||||||
|
|
||||||
|
### Student — Thread (`/questions/[id]`)
|
||||||
|
- Header: question title, created date, status, "← Все вопросы" link
|
||||||
|
- Message bubbles: student messages left (#E8E8E0), school messages right (#F5F5F0 + green border #E8F0D8)
|
||||||
|
- New school message: bold label "🔵 новое" in timestamp
|
||||||
|
- File attachments shown inline under message text (📎 filename · size)
|
||||||
|
- Reply form at bottom: textarea + attach button + send button
|
||||||
|
- Attachment types: jpg, png, pdf, md · max 10 MB
|
||||||
|
- Student CAN reply to closed questions (creates new message, does NOT reopen question)
|
||||||
|
|
||||||
|
### Admin/Curator — Split View (`/admin/questions`, `/curator/questions`)
|
||||||
|
- Left panel (45%): tab filter "Открытые / Закрытые", question list sorted by last activity
|
||||||
|
- Unread: red dot, green-tinted background, bold student name
|
||||||
|
- Right panel (55%): selected thread with full message history + reply form + "✓ Закрыть вопрос" button
|
||||||
|
- Only admin/curator can close a question
|
||||||
|
- Closing a question does NOT prevent further messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
### → School (admin + all curators)
|
||||||
|
| Trigger | Channel |
|
||||||
|
|---|---|
|
||||||
|
| New question created | Email + admin sidebar badge (count of unread questions) |
|
||||||
|
| Student adds message to existing question | Email |
|
||||||
|
|
||||||
|
### → Student
|
||||||
|
| Trigger | Channel |
|
||||||
|
|---|---|
|
||||||
|
| Admin/curator replies to question | Email |
|
||||||
|
|
||||||
|
### Admin Badge
|
||||||
|
- Sidebar badge shows count of questions with unread messages (school hasn't seen yet)
|
||||||
|
- Separate from homework badge
|
||||||
|
|
||||||
|
### Email for Homework (added to scope)
|
||||||
|
| Trigger | Recipient |
|
||||||
|
|---|---|
|
||||||
|
| New HomeworkSubmission created | Admin + all curators |
|
||||||
|
| Student updates existing submission (adds text/file) | Admin + all curators |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Storage
|
||||||
|
|
||||||
|
- Path pattern: `questions/{questionId}/{messageId}/{filename}`
|
||||||
|
- Reuse existing S3 upload infrastructure (`src/lib/s3.ts`)
|
||||||
|
- Allowed: jpg, png, pdf, md
|
||||||
|
- Max size: 10 MB per file
|
||||||
|
- No limit on number of files per message (reasonable: 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read Tracking
|
||||||
|
|
||||||
|
- `isRead` flag per message, set to `true` when the OTHER party opens the thread
|
||||||
|
- Student opens `/questions/[id]` → all school messages in that thread marked `isRead = true`
|
||||||
|
- Admin/curator opens a question in split-view → all student messages marked `isRead = true`
|
||||||
|
- Admin badge recalculates on each page load (count questions where latest student message is unread)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/questions -- create question
|
||||||
|
GET /api/questions -- list own questions (student) or all (admin/curator)
|
||||||
|
GET /api/questions/[id] -- get question + messages
|
||||||
|
POST /api/questions/[id]/messages -- add message + files
|
||||||
|
PATCH /api/questions/[id]/close -- close question (admin/curator only)
|
||||||
|
POST /api/upload/question-file -- upload attachment, returns S3 path
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (this iteration)
|
||||||
|
- Question categories / tags
|
||||||
|
- Assigning question to a specific curator
|
||||||
|
- Email threading (reply-to email to answer)
|
||||||
|
- Push/browser notifications
|
||||||
|
- Question templates
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "QuestionStatus" AS ENUM ('OPEN', 'CLOSED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StudentQuestion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"courseId" TEXT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"status" "QuestionStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
|
"closedAt" TIMESTAMP(3),
|
||||||
|
"closedById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "StudentQuestion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StudentQuestionMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"questionId" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"files" JSONB,
|
||||||
|
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "StudentQuestionMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestionMessage" ADD CONSTRAINT "StudentQuestionMessage_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "StudentQuestion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestionMessage" ADD CONSTRAINT "StudentQuestionMessage_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StudentQuestion_userId_idx" ON "StudentQuestion"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StudentQuestion_status_idx" ON "StudentQuestion"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StudentQuestionMessage_questionId_idx" ON "StudentQuestionMessage"("questionId");
|
||||||
@@ -37,6 +37,9 @@ model User {
|
|||||||
accessLogs AccessLog[] @relation("AccessLogUser")
|
accessLogs AccessLog[] @relation("AccessLogUser")
|
||||||
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
|
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
|
||||||
balanceTransactions BalanceTransaction[]
|
balanceTransactions BalanceTransaction[]
|
||||||
|
questions StudentQuestion[]
|
||||||
|
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
|
||||||
|
questionMessages StudentQuestionMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
@@ -111,6 +114,7 @@ model Course {
|
|||||||
modules Module[]
|
modules Module[]
|
||||||
enrollments CourseEnrollment[]
|
enrollments CourseEnrollment[]
|
||||||
accessLogs AccessLog[]
|
accessLogs AccessLog[]
|
||||||
|
questions StudentQuestion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Module {
|
model Module {
|
||||||
@@ -312,6 +316,50 @@ model LessonComment {
|
|||||||
replies LessonComment[] @relation("CommentReplies")
|
replies LessonComment[] @relation("CommentReplies")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// 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[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
@@index([questionId])
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
// Balance
|
// Balance
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { sendHomeworkSubmittedEmail } from "@/lib/email";
|
import { sendHomeworkSubmittedEmail, sendHomeworkUpdatedEmail } from "@/lib/email";
|
||||||
|
|
||||||
interface HomeworkFile {
|
interface HomeworkFile {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -40,6 +40,27 @@ export async function submitHomework(
|
|||||||
data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
|
data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
|
||||||
});
|
});
|
||||||
submissionId = updated.id;
|
submissionId = updated.id;
|
||||||
|
|
||||||
|
// Notify admins/curators when student edits an existing submission
|
||||||
|
const [lessonRecord, staff] = 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 (lessonRecord) {
|
||||||
|
await Promise.all(
|
||||||
|
staff.map((s) =>
|
||||||
|
sendHomeworkUpdatedEmail(s.email, s.name, session.user.name, lessonRecord.lesson.title, submissionId).catch(
|
||||||
|
(e) => console.error("[email] homework-updated:", e)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const created = await prisma.homeworkSubmission.create({
|
const created = await prisma.homeworkSubmission.create({
|
||||||
data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
|
data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export default async function StudentLayout({ children }: { children: React.Reac
|
|||||||
{schoolName}
|
{schoolName}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Вопросы
|
||||||
|
</Link>
|
||||||
<Link href="/profile" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
<Link href="/profile" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
{session.user.name}
|
{session.user.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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 as "OPEN" | "CLOSED"}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
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">
|
||||||
|
<Link
|
||||||
|
href="/questions"
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
← Все вопросы
|
||||||
|
</Link>
|
||||||
|
</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(--color-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(--color-surface)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--destructive)" }}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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(--color-surface)",
|
||||||
|
border: "2px solid var(--foreground)",
|
||||||
|
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(--foreground)" : "1px solid var(--border)",
|
||||||
|
background: q.status === "CLOSED" ? "var(--background)" : "var(--color-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(--background)", 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)" }}>
|
||||||
|
последнее от {lastMsg.author.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,11 +2,23 @@ import { headers } from "next/headers";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AdminShell } from "@/components/admin/admin-shell";
|
import { AdminShell } from "@/components/admin/admin-shell";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
if (session.user.role !== "admin") redirect("/dashboard");
|
if (session.user.role !== "admin") redirect("/dashboard");
|
||||||
|
|
||||||
return <AdminShell userName={session.user.name}>{children}</AdminShell>;
|
const questionsBadge = await prisma.studentQuestion.count({
|
||||||
|
where: {
|
||||||
|
messages: {
|
||||||
|
some: {
|
||||||
|
isRead: false,
|
||||||
|
author: { role: "student" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return <AdminShell userName={session.user.name} questionsBadge={questionsBadge}>{children}</AdminShell>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
interface FileAttachment {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildS3Prefix(): string {
|
||||||
|
const endpoint = process.env.S3_ENDPOINT ?? "";
|
||||||
|
const bucket = process.env.S3_BUCKET ?? "";
|
||||||
|
// e.g. https://fsn1.your-objectstorage.com/lms-uploads/
|
||||||
|
return `${endpoint}/${bucket}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
if (question.status === "CLOSED") {
|
||||||
|
return NextResponse.json({ error: "Question is closed" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { text, files } = body as { text: string; files?: FileAttachment[] };
|
||||||
|
|
||||||
|
if (!text?.trim()) {
|
||||||
|
return NextResponse.json({ error: "text is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Prefix = buildS3Prefix();
|
||||||
|
const safeFiles = files
|
||||||
|
?.filter(
|
||||||
|
(f) =>
|
||||||
|
typeof f.name === "string" &&
|
||||||
|
typeof f.url === "string" &&
|
||||||
|
f.url.startsWith("https://") &&
|
||||||
|
f.url.startsWith(s3Prefix) &&
|
||||||
|
typeof f.size === "number"
|
||||||
|
)
|
||||||
|
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
|
||||||
|
|
||||||
|
const [msg] = await prisma.$transaction([
|
||||||
|
prisma.studentQuestionMessage.create({
|
||||||
|
data: {
|
||||||
|
questionId: id,
|
||||||
|
authorId: session.user.id,
|
||||||
|
text: text.trim(),
|
||||||
|
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
|
||||||
|
},
|
||||||
|
include: { author: { select: { id: true, name: true, role: true } } },
|
||||||
|
}),
|
||||||
|
prisma.studentQuestion.update({
|
||||||
|
where: { id },
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send notifications (fire-and-forget, outside transaction)
|
||||||
|
if (isStaff) {
|
||||||
|
void sendQuestionReplyEmail(
|
||||||
|
question.user.email,
|
||||||
|
question.user.name,
|
||||||
|
question.title,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const staff = await prisma.user.findMany({
|
||||||
|
where: { role: { in: ["admin", "curator"] } },
|
||||||
|
select: { email: true, name: true },
|
||||||
|
});
|
||||||
|
void Promise.all(
|
||||||
|
staff.map((s) =>
|
||||||
|
sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(msg, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
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 userSelect = isStaff
|
||||||
|
? { id: true as const, name: true as const, email: true as const }
|
||||||
|
: { id: true as const, name: true as const };
|
||||||
|
|
||||||
|
const questions = await prisma.studentQuestion.findMany({
|
||||||
|
where: isStaff ? undefined : { userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
user: { select: userSelect },
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
void Promise.all(
|
||||||
|
staff.map((s) =>
|
||||||
|
sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(question, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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/x-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 ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf", "md", "txt"]);
|
||||||
|
if (!ALLOWED_EXTS.has(ext)) {
|
||||||
|
return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `questions/${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 });
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { LogoutButton } from "@/components/layout/logout-button";
|
import { LogoutButton } from "@/components/layout/logout-button";
|
||||||
import { AdminShell } from "@/components/admin/admin-shell";
|
import { AdminShell } from "@/components/admin/admin-shell";
|
||||||
import { getSetting } from "@/lib/settings";
|
import { getSetting } from "@/lib/settings";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
|
export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
@@ -19,7 +20,17 @@ export default async function CuratorLayout({ children }: { children: React.Reac
|
|||||||
|
|
||||||
// Admin uses the admin shell with sidebar
|
// Admin uses the admin shell with sidebar
|
||||||
if (session.user.role === "admin") {
|
if (session.user.role === "admin") {
|
||||||
return <AdminShell userName={session.user.name}>{children}</AdminShell>;
|
const questionsBadge = await prisma.studentQuestion.count({
|
||||||
|
where: {
|
||||||
|
messages: {
|
||||||
|
some: {
|
||||||
|
isRead: false,
|
||||||
|
author: { role: "student" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return <AdminShell userName={session.user.name} questionsBadge={questionsBadge}>{children}</AdminShell>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,6 +44,7 @@ export default async function CuratorLayout({ children }: { children: React.Reac
|
|||||||
<nav className="flex-1 py-3 space-y-0.5 px-2">
|
<nav className="flex-1 py-3 space-y-0.5 px-2">
|
||||||
<NavLink href="/curator/dashboard">Обзор</NavLink>
|
<NavLink href="/curator/dashboard">Обзор</NavLink>
|
||||||
<NavLink href="/curator/homework">ДЗ на проверку</NavLink>
|
<NavLink href="/curator/homework">ДЗ на проверку</NavLink>
|
||||||
|
<NavLink href="/curator/questions">Вопросы</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="px-4 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
<div className="px-4 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
||||||
<p className="text-xs mb-2 truncate" style={{ color: "#888" }}>{session.user.name}</p>
|
<p className="text-xs mb-2 truncate" style={{ color: "#888" }}>{session.user.name}</p>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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} />;
|
||||||
|
}
|
||||||
@@ -9,13 +9,14 @@ const links = [
|
|||||||
{ href: "/admin/categories", label: "Категории" },
|
{ href: "/admin/categories", label: "Категории" },
|
||||||
{ href: "/admin/users", label: "Пользователи" },
|
{ href: "/admin/users", label: "Пользователи" },
|
||||||
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
||||||
|
{ href: "/admin/questions", label: "Вопросы" },
|
||||||
{ href: "/admin/quizzes", label: "Тесты" },
|
{ href: "/admin/quizzes", label: "Тесты" },
|
||||||
{ href: "/admin/comments", label: "Комментарии" },
|
{ href: "/admin/comments", label: "Комментарии" },
|
||||||
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
|
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
|
||||||
{ href: "/admin/settings", label: "Настройки" },
|
{ href: "/admin/settings", label: "Настройки" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AdminNav() {
|
export function AdminNav({ questionsBadge = 0 }: { questionsBadge?: number }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -24,7 +25,7 @@ export function AdminNav() {
|
|||||||
const active =
|
const active =
|
||||||
pathname === href ||
|
pathname === href ||
|
||||||
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
|
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
|
||||||
(href === "/curator/homework" && pathname.startsWith("/curator"));
|
(href === "/curator/homework" && pathname.startsWith("/curator/homework"));
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={href}
|
key={href}
|
||||||
@@ -40,7 +41,23 @@ export function AdminNav() {
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<span className="flex items-center justify-between w-full">
|
||||||
{label}
|
{label}
|
||||||
|
{href === "/admin/questions" && questionsBadge > 0 && (
|
||||||
|
<span
|
||||||
|
className="ml-2 inline-flex items-center justify-center rounded-full text-xs font-bold leading-none"
|
||||||
|
style={{
|
||||||
|
minWidth: "1.25rem",
|
||||||
|
height: "1.25rem",
|
||||||
|
padding: "0 0.3rem",
|
||||||
|
backgroundColor: "var(--destructive)",
|
||||||
|
color: "#fff",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{questionsBadge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { LogoutButton } from "@/components/layout/logout-button";
|
|||||||
export function AdminShell({
|
export function AdminShell({
|
||||||
children,
|
children,
|
||||||
userName,
|
userName,
|
||||||
|
questionsBadge = 0,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
userName: string;
|
userName: string;
|
||||||
|
questionsBadge?: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex">
|
<div className="min-h-screen flex">
|
||||||
@@ -23,7 +25,7 @@ export function AdminShell({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 px-2 py-4 space-y-0.5 overflow-y-auto">
|
<nav className="flex-1 px-2 py-4 space-y-0.5 overflow-y-auto">
|
||||||
<AdminNav />
|
<AdminNav questionsBadge={questionsBadge} />
|
||||||
</nav>
|
</nav>
|
||||||
<div className="px-4 py-4" style={{ borderTop: "2px solid var(--sidebar-border)" }}>
|
<div className="px-4 py-4" style={{ borderTop: "2px solid var(--sidebar-border)" }}>
|
||||||
<p className="text-xs mb-3 truncate" style={{ color: "var(--sidebar-text)" }}>
|
<p className="text-xs mb-3 truncate" style={{ color: "var(--sidebar-text)" }}>
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
"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} Б`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [listError, setListError] = useState("");
|
||||||
|
const [listLoading, setListLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchList = useCallback(async () => {
|
||||||
|
setListError("");
|
||||||
|
setListLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/questions");
|
||||||
|
if (!res.ok) {
|
||||||
|
setListError("Не удалось загрузить вопросы");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
setQuestions(data);
|
||||||
|
} catch {
|
||||||
|
setListError("Не удалось загрузить вопросы");
|
||||||
|
} finally {
|
||||||
|
setListLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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,
|
||||||
|
messages: data.messages.map((m: Message & { createdAt: string }) => ({
|
||||||
|
...m,
|
||||||
|
files: m.files as FileAttachment[] | null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
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="flex flex-col"
|
||||||
|
style={{ width: "45%", 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(--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">
|
||||||
|
{listError && (
|
||||||
|
<p className="text-xs p-4" style={{ color: "var(--destructive)" }}>
|
||||||
|
{listError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{listLoading && (
|
||||||
|
<p className="text-xs p-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Загрузка...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!listLoading && 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(--muted)"
|
||||||
|
: "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 flex-col overflow-hidden" style={{ width: "55%" }}>
|
||||||
|
{!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 shrink-0"
|
||||||
|
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 shrink-0"
|
||||||
|
style={{
|
||||||
|
background: "var(--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 ? "var(--muted)" : "var(--background)",
|
||||||
|
border: isMine ? "none" : "2px solid #E8F0D8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{msg.author.name} · {formatDate(msg.createdAt)}
|
||||||
|
</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) => (
|
||||||
|
<a
|
||||||
|
key={f.url}
|
||||||
|
href={f.url.startsWith("https://") ? f.url : "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--background)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
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 shrink-0"
|
||||||
|
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(--background)",
|
||||||
|
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: "var(--destructive)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } 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);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
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 (uploading) return;
|
||||||
|
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-end" : "flex-start",
|
||||||
|
background: isMine ? "var(--muted)" : "var(--background)",
|
||||||
|
border: isMine ? "none" : `2px solid #E8F0D8`,
|
||||||
|
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.startsWith("https://") ? f.url : "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
|
||||||
|
style={{
|
||||||
|
background: "var(--background)",
|
||||||
|
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 ref={messagesEndRef} />
|
||||||
|
</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(--color-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(--color-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 var(--muted)" }}
|
||||||
|
>
|
||||||
|
<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(--color-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: "var(--destructive)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -190,6 +190,29 @@ export async function sendTestEmail(to: string) {
|
|||||||
}).catch((e) => console.error("[email] sendTestEmail:", e));
|
}).catch((e) => console.error("[email] sendTestEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function sendAccessEmail(
|
||||||
|
to: string,
|
||||||
|
name: string,
|
||||||
|
courseTitle: string,
|
||||||
|
tempPassword: string
|
||||||
|
) {
|
||||||
|
const school = await getSchoolName();
|
||||||
|
await getResend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to,
|
||||||
|
subject: `Доступ к курсу «${courseTitle}» открыт`,
|
||||||
|
html: base(`
|
||||||
|
<p ${p}>Привет!</p>
|
||||||
|
<p ${p}>Вам открыт доступ к курсу <strong>«${courseTitle}»</strong> на образовательной платформе <strong>${school}</strong>.</p>
|
||||||
|
<p ${p}>Ваши данные для входа:</p>
|
||||||
|
${quote(`Логин: ${to}\nПароль: ${tempPassword}`)}
|
||||||
|
<p ${p}>После входа рекомендуем сразу сменить пароль в разделе «Профиль».</p>
|
||||||
|
<p ${pLast}>Если пароль не подходит — воспользуйтесь ссылкой «Забыли пароль?» на странице входа.</p>
|
||||||
|
${btn(`${BASE_URL}/login`, "Войти на платформу")}
|
||||||
|
`, school),
|
||||||
|
}).catch((e) => console.error("[email] sendAccessEmail:", e));
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendPasswordResetEmail(to: string, name: string, resetUrl: string) {
|
export async function sendPasswordResetEmail(to: string, name: string, resetUrl: string) {
|
||||||
const school = await getSchoolName();
|
const school = await getSchoolName();
|
||||||
await getResend().emails.send({
|
await getResend().emails.send({
|
||||||
@@ -205,3 +228,87 @@ export async function sendPasswordResetEmail(to: string, name: string, resetUrl:
|
|||||||
`, school),
|
`, school),
|
||||||
}).catch((e) => console.error("[email] sendPasswordResetEmail:", e));
|
}).catch((e) => console.error("[email] sendPasswordResetEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 sendQuestionFollowUpEmail(
|
||||||
|
to: string,
|
||||||
|
recipientName: string,
|
||||||
|
studentName: string,
|
||||||
|
questionTitle: string,
|
||||||
|
) {
|
||||||
|
const school = await getSchoolName();
|
||||||
|
await getResend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to,
|
||||||
|
subject: `Новый ответ от студента — ${questionTitle}`,
|
||||||
|
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] sendQuestionFollowUpEmail:", 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));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user