From c25369b766591383778d8390d5b4088e6ba8899d Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Wed, 29 Apr 2026 13:17:30 +0500 Subject: [PATCH] Add threaded comment replies for admin and curator - Add parentId field to LessonComment (self-referential FK, SetNull on delete) - Show replies indented under parent comment with left border visual - Add reply button (visible to admin/curator only) with inline textarea - Only root comments shown in main list; replies nested below their parent - Update comment count to include replies - Server-side validation: only admin/curator can reply, parent must belong to same lesson Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 4 + prisma/schema.prisma | 7 +- .../lessons/[lessonId]/comment-actions.ts | 13 +- .../[slug]/lessons/[lessonId]/page.tsx | 15 +- src/components/student/lesson-comments.tsx | 221 ++++++++++++++---- src/lib/actions/student-actions.ts | 13 +- 6 files changed, 215 insertions(+), 58 deletions(-) create mode 100644 prisma/migrations/20260429100000_add_comment_replies/migration.sql diff --git a/prisma/migrations/20260429100000_add_comment_replies/migration.sql b/prisma/migrations/20260429100000_add_comment_replies/migration.sql new file mode 100644 index 0000000..d57a86b --- /dev/null +++ b/prisma/migrations/20260429100000_add_comment_replies/migration.sql @@ -0,0 +1,4 @@ +ALTER TABLE "LessonComment" ADD COLUMN "parentId" TEXT; + +ALTER TABLE "LessonComment" ADD CONSTRAINT "LessonComment_parentId_fkey" + FOREIGN KEY ("parentId") REFERENCES "LessonComment"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 957f854..26ae41e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -301,9 +301,12 @@ model LessonComment { text String deleted Boolean @default(false) createdAt DateTime @default(now()) + parentId String? - lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + parent LessonComment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull) + replies LessonComment[] @relation("CommentReplies") } // ───────────────────────────────────────────── diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts b/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts index 478e0a0..2f23ffc 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts @@ -5,7 +5,7 @@ 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) { +export async function addComment(lessonId: string, slug: string, text: string, parentId?: string) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) throw new Error("Unauthorized"); @@ -20,7 +20,8 @@ export async function addComment(lessonId: string, slug: string, text: string) { if (!lesson) throw new Error("Lesson not found"); const isAdmin = session.user.role === "admin"; - if (!isAdmin) { + const isCurator = session.user.role === "curator"; + if (!isAdmin && !isCurator) { const enrollment = await prisma.courseEnrollment.findUnique({ where: { userId_courseId: { @@ -33,8 +34,14 @@ export async function addComment(lessonId: string, slug: string, text: string) { if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired"); } + if (parentId) { + if (!isAdmin && !isCurator) throw new Error("Forbidden"); + const parent = await prisma.lessonComment.findUnique({ where: { id: parentId } }); + if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent"); + } + await prisma.lessonComment.create({ - data: { lessonId, userId: session.user.id, text: trimmed }, + data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) }, }); 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 ef769b5..f836f7a 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx @@ -56,9 +56,15 @@ export default async function LessonPage({ params }: Props) { }) : null, prisma.lessonComment.findMany({ - where: { lessonId }, + where: { lessonId, parentId: null }, orderBy: { createdAt: "asc" }, - include: { user: { select: { id: true, name: true } } }, + include: { + user: { select: { id: true, name: true } }, + replies: { + orderBy: { createdAt: "asc" }, + include: { user: { select: { id: true, name: true } } }, + }, + }, }), ]); @@ -232,7 +238,10 @@ export default async function LessonPage({ params }: Props) { style={{ borderTop: "2px solid var(--border)" }} >

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

(null); + const [replyText, setReplyText] = useState(""); const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); @@ -40,6 +61,19 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren }); } + function handleReply(parentId: string) { + if (!replyText.trim()) return; + startTransition(async () => { + try { + await addComment(lessonId, slug, replyText.trim(), parentId); + setReplyText(""); + setReplyToId(null); + } catch { + // ignore + } + }); + } + function handleDelete(commentId: string) { startTransition(async () => { try { @@ -52,68 +86,161 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren 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", - })} - + {comments.map((comment) => ( +
+ {/* Root comment */} +
+
+ {comment.user.name[0]?.toUpperCase() ?? "?"}
- {comment.deleted ? ( -

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

- ) : ( -

- {comment.text} -

- )} +
+
+ {comment.user.name} + + {formatDate(comment.createdAt)} + +
- {!comment.deleted && (comment.user.id === currentUserId || canModerate) && ( - - )} + {comment.deleted ? ( +

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

+ ) : ( +

+ {comment.text} +

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