From 6d93a7b40651cbe3b317ae80361939a7064bd685 Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 7 Apr 2026 15:33:47 +0500 Subject: [PATCH] Add lesson comments (Stage 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LessonComment CRUD: addComment / deleteComment server actions - LessonComments client component with form, avatar, delete - Comments section at bottom of lesson page (enrolled users only) - Soft-delete support, moderation for curator/admin - ROADMAP: moved Квизы to end (Stage 11), marked Stage 7 done Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 36 ++-- .../lessons/[lessonId]/comment-actions.ts | 62 +++++++ .../[slug]/lessons/[lessonId]/page.tsx | 27 ++- src/components/student/lesson-comments.tsx | 156 ++++++++++++++++++ 4 files changed, 262 insertions(+), 19 deletions(-) create mode 100644 src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts create mode 100644 src/components/student/lesson-comments.tsx diff --git a/ROADMAP.md b/ROADMAP.md index 90309ac..35647cb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -82,21 +82,6 @@ --- -## Этап 4 — Тесты и квизы -**Цель:** можно добавить тест к уроку, ученик проходит и получает результат. - -- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt -- [ ] Admin: создание теста к уроку (добавить вопрос → варианты ответов → отметить правильный) -- [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст -- [ ] Рендер теста в уроке для ученика -- [ ] Авто-проверка (single/multiple choice), результат сразу -- [ ] Настройка: показывать правильные ответы после прохождения (да/нет) -- [ ] Интеграция с прогрессом: урок с тестом засчитан только после прохождения теста - -**Критерий готовности:** добавляю тест из 3 вопросов к уроку, ученик проходит, видит результат, урок засчитывается. - ---- - ## Этап 5 — Домашние задания и обратная связь куратора **Цель:** ученик сдаёт ДЗ, куратор оставляет комментарий. @@ -113,7 +98,7 @@ --- -## Этап 6 — Обсуждения под уроками +## Этап 6 — Обсуждения под уроками ← ТЕКУЩИЙ **Цель:** ученики могут общаться под каждым уроком. - [ ] Prisma: LessonComment (с поддержкой вложенных ответов — опционально) @@ -126,7 +111,7 @@ --- -## Этап 7 — Email-уведомления +## Этап 7 — Email-уведомления ✅ ЗАВЕРШЁН (07.04.2026) **Цель:** все участники получают нужные письма через Resend. - [ ] Базовый email-шаблон (HTML, фирменный стиль) @@ -142,7 +127,7 @@ --- -## Этап 8 — Импорт уроков из Markdown +## Этап 8 — Импорт уроков из Markdown (Obsidian) **Цель:** могу импортировать урок из .md-файла Obsidian одним действием. - [ ] API: `POST /api/admin/lessons/import-md` — принимает .md-файл @@ -194,6 +179,21 @@ --- +## Этап 11 — Тесты и квизы +**Цель:** можно добавить тест к уроку, ученик проходит и получает результат. + +- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt (схема уже есть) +- [ ] Admin: создание теста к уроку (добавить вопрос → варианты ответов → отметить правильный) +- [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст +- [ ] Рендер теста в уроке для ученика +- [ ] Авто-проверка (single/multiple choice), результат сразу +- [ ] Настройка: показывать правильные ответы после прохождения (да/нет) +- [ ] Интеграция с прогрессом: урок с тестом засчитан только после прохождения теста + +**Критерий готовности:** добавляю тест из 3 вопросов к уроку, ученик проходит, видит результат, урок засчитывается. + +--- + ## Бэклог (после MVP) - Сертификаты по окончании курса diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts b/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts new file mode 100644 index 0000000..478e0a0 --- /dev/null +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts @@ -0,0 +1,62 @@ +"use server"; + +import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; + +export async function addComment(lessonId: string, slug: string, text: string) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error("Unauthorized"); + + const trimmed = text.trim(); + if (!trimmed || trimmed.length > 2000) throw new Error("Invalid text"); + + // Verify user has access to this lesson's course + const lesson = await prisma.lesson.findUnique({ + where: { id: lessonId }, + select: { module: { select: { course: { select: { id: true } } } } }, + }); + if (!lesson) throw new Error("Lesson not found"); + + const isAdmin = session.user.role === "admin"; + if (!isAdmin) { + const enrollment = await prisma.courseEnrollment.findUnique({ + where: { + userId_courseId: { + userId: session.user.id, + courseId: lesson.module.course.id, + }, + }, + }); + if (!enrollment) throw new Error("Not enrolled"); + if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired"); + } + + await prisma.lessonComment.create({ + data: { lessonId, userId: session.user.id, text: trimmed }, + }); + + revalidatePath(`/courses/${slug}/lessons/${lessonId}`); +} + +export async function deleteComment(commentId: string, lessonId: string, slug: string) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) throw new Error("Unauthorized"); + + const comment = await prisma.lessonComment.findUnique({ where: { id: commentId } }); + if (!comment) throw new Error("Not found"); + + const canDelete = + comment.userId === session.user.id || + session.user.role === "curator" || + session.user.role === "admin"; + if (!canDelete) throw new Error("Forbidden"); + + await prisma.lessonComment.update({ + where: { id: commentId }, + data: { deleted: true }, + }); + + revalidatePath(`/courses/${slug}/lessons/${lessonId}`); +} diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx index 64575a1..6c2ecd1 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx @@ -7,6 +7,7 @@ import { KinescopePlayer } from "@/components/player/kinescope-player"; import { LessonContent } from "@/components/student/lesson-content"; import { LessonCompleteButton } from "@/components/student/lesson-complete-button"; import { HomeworkSection } from "@/components/student/homework-section"; +import { LessonComments } from "@/components/student/lesson-comments"; interface Props { params: Promise<{ slug: string; lessonId: string }>; @@ -18,7 +19,7 @@ export default async function LessonPage({ params }: Props) { const session = await auth.api.getSession({ headers: await headers() }); const isAdmin = session?.user.role === "admin"; - const [lesson, progress] = await Promise.all([ + const [lesson, progress, comments] = await Promise.all([ prisma.lesson.findUnique({ where: { id: lessonId, ...(isAdmin ? {} : { published: true }) }, include: { @@ -49,6 +50,11 @@ export default async function LessonPage({ params }: Props) { where: { userId_lessonId: { userId: session.user.id, lessonId } }, }) : null, + prisma.lessonComment.findMany({ + where: { lessonId }, + orderBy: { createdAt: "asc" }, + include: { user: { select: { id: true, name: true } } }, + }), ]); // Fetch homework submission for this student @@ -179,6 +185,25 @@ export default async function LessonPage({ params }: Props) { )} + + {/* Comments */} + {session && ( +
+

+ Обсуждение ({comments.filter((c) => !c.deleted).length}) +

+ +
+ )} ); } diff --git a/src/components/student/lesson-comments.tsx b/src/components/student/lesson-comments.tsx new file mode 100644 index 0000000..742749f --- /dev/null +++ b/src/components/student/lesson-comments.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { addComment, deleteComment } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions"; + +type Comment = { + id: string; + text: string; + deleted: boolean; + createdAt: Date; + user: { id: string; name: string }; +}; + +interface Props { + lessonId: string; + slug: string; + comments: Comment[]; + currentUserId: string; + currentUserRole: string; +} + +export function LessonComments({ lessonId, slug, comments, currentUserId, currentUserRole }: Props) { + const [text, setText] = useState(""); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + + const canModerate = currentUserRole === "curator" || currentUserRole === "admin"; + + function handleAdd(e: React.FormEvent) { + e.preventDefault(); + if (!text.trim()) return; + setError(null); + startTransition(async () => { + try { + await addComment(lessonId, slug, text.trim()); + setText(""); + } catch { + setError("Не удалось отправить комментарий. Попробуйте ещё раз."); + } + }); + } + + function handleDelete(commentId: string) { + startTransition(async () => { + try { + await deleteComment(commentId, lessonId, slug); + } catch { + // ignore + } + }); + } + + return ( +
+ {/* Comment list */} +
+ {comments.length === 0 && ( +

+ Пока нет комментариев. Будьте первым! +

+ )} + {comments.map((comment) => ( +
+ {/* Avatar */} +
+ {comment.user.name[0]?.toUpperCase() ?? "?"} +
+ + {/* Body */} +
+
+ {comment.user.name} + + {new Date(comment.createdAt).toLocaleDateString("ru-RU", { + day: "numeric", + month: "long", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + })} + +
+ + {comment.deleted ? ( +

+ [Комментарий удалён] +

+ ) : ( +

+ {comment.text} +

+ )} + + {!comment.deleted && (comment.user.id === currentUserId || canModerate) && ( + + )} +
+
+ ))} +
+ + {/* Add comment form */} +
+