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}) +
++ Пока нет комментариев. Будьте первым! +
+ )} + {comments.map((comment) => ( ++ [Комментарий удалён] +
+ ) : ( ++ {comment.text} +
+ )} + + {!comment.deleted && (comment.user.id === currentUserId || canModerate) && ( + + )} +