From 8223cbb963231febce1f76293bd2cd9a40bd3add Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 15 Sep 2026 11:49:45 +0500 Subject: [PATCH] =?UTF-8?q?feat(=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D0=B8=D0=B8):=20=D0=BF=D0=B8=D1=81=D1=8C=D0=BC?= =?UTF-8?q?=D0=BE=20=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=BA=D1=83=20=D0=BE?= =?UTF-8?q?=D0=B1=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D0=B5=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=B4=20=D1=83=D1=80=D0=BE=D0=BA=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ответы персонала в ветке комментариев никуда не уведомляли: ученик видел ответ, только если сам возвращался в урок. У тредов вопросов письмо есть, у комментариев не было — из-за этого ответ под уроком 6.3 «Холст» висел непрочитанным, пока его не продублировали руками. - sendCommentReplyEmail: тот же шаблон ДС-2, что у ответов в тредах, с текстом ответа внутри письма и кнопкой на урок. Свой лимит превью 4000 символов: ввод разрешает 10 000, а порог вопросов (2000) резал бы развёрнутые разборы. - commentReplyRecipient: правила «шлём / не шлём» вынесены чистой функцией и покрыты тестами — не шлём при выключенной настройке, не-staff, удалённом родителе, ответе самому себе и пустой почте. - notifyStudentOnCommentReply: тумблер в админке рядом с уведомлением о фидбеке, по умолчанию включён. - Удалён неподключённый дубль comment-actions.ts: компонент комментариев ходит в lib/actions/student-actions.ts, а в дубле жил свой лимит 2000 — правка в нём не давала эффекта. Проверено: type-check и lint чистые (3 ошибки линтера — существующий долг в quick-enroll-modal и kinescope-player), vitest 13/13, письмо отправлено боем и доставлено (Resend, last_event=delivered). Co-Authored-By: Claude Opus 5 (1M context) --- .../lessons/[lessonId]/comment-actions.ts | 69 ----------- src/components/admin/settings-form.tsx | 5 + src/lib/__tests__/comment-notify.test.ts | 109 ++++++++++++++++++ src/lib/__tests__/comment-reply-email.test.ts | 86 ++++++++++++++ src/lib/actions/student-actions.ts | 45 +++++++- src/lib/comment-notify.ts | 41 +++++++ src/lib/email.ts | 37 ++++++ src/lib/settings.ts | 1 + 8 files changed, 321 insertions(+), 72 deletions(-) delete mode 100644 src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts create mode 100644 src/lib/__tests__/comment-notify.test.ts create mode 100644 src/lib/__tests__/comment-reply-email.test.ts create mode 100644 src/lib/comment-notify.ts diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts b/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts deleted file mode 100644 index 2f23ffc..0000000 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts +++ /dev/null @@ -1,69 +0,0 @@ -"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, parentId?: 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"; - const isCurator = session.user.role === "curator"; - if (!isAdmin && !isCurator) { - 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"); - } - - 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, ...(parentId ? { parentId } : {}) }, - }); - - 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/components/admin/settings-form.tsx b/src/components/admin/settings-form.tsx index 62df6ae..c6e2db7 100644 --- a/src/components/admin/settings-form.tsx +++ b/src/components/admin/settings-form.tsx @@ -289,6 +289,11 @@ export function SettingsForm({ initial }: { initial: Settings }) { checked={bool("notifyStudentOnFeedback")} onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")} /> + set("notifyStudentOnCommentReply", v ? "true" : "false")} + /> diff --git a/src/lib/__tests__/comment-notify.test.ts b/src/lib/__tests__/comment-notify.test.ts new file mode 100644 index 0000000..ce5f3c6 --- /dev/null +++ b/src/lib/__tests__/comment-notify.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect } from "vitest"; +import { commentReplyRecipient } from "../comment-notify"; + +const parent = { + userId: "student-1", + deleted: false, + user: { email: "student@example.com", name: "Владимир" }, +}; + +describe("commentReplyRecipient", () => { + it("отдаёт автора родительского комментария, когда админ ответил ученику", () => { + expect( + commentReplyRecipient({ + notifyEnabled: true, + replierId: "admin-1", + replierRole: "admin", + parent, + }) + ).toEqual({ email: "student@example.com", name: "Владимир" }); + }); + + it("работает и для куратора", () => { + expect( + commentReplyRecipient({ + notifyEnabled: true, + replierId: "curator-1", + replierRole: "curator", + parent, + }) + ).toEqual({ email: "student@example.com", name: "Владимир" }); + }); + + it("молчит при выключенной настройке", () => { + expect( + commentReplyRecipient({ + notifyEnabled: false, + replierId: "admin-1", + replierRole: "admin", + parent, + }) + ).toBeNull(); + }); + + it("молчит, когда отвечает не staff", () => { + expect( + commentReplyRecipient({ + notifyEnabled: true, + replierId: "student-2", + replierRole: "student", + parent, + }) + ).toBeNull(); + }); + + it("молчит без родительского комментария — это корневая запись, а не ответ", () => { + expect( + commentReplyRecipient({ + notifyEnabled: true, + replierId: "admin-1", + replierRole: "admin", + parent: null, + }) + ).toBeNull(); + }); + + it("молчит, если родительский комментарий удалён", () => { + expect( + commentReplyRecipient({ + notifyEnabled: true, + replierId: "admin-1", + replierRole: "admin", + parent: { ...parent, deleted: true }, + }) + ).toBeNull(); + }); + + it("не пишет письмо самому себе", () => { + expect( + commentReplyRecipient({ + notifyEnabled: true, + replierId: "student-1", + replierRole: "admin", + parent, + }) + ).toBeNull(); + }); + + it("молчит, когда у автора нет почты", () => { + expect( + commentReplyRecipient({ + notifyEnabled: true, + replierId: "admin-1", + replierRole: "admin", + parent: { ...parent, user: { email: " ", name: "Без почты" } }, + }) + ).toBeNull(); + }); + + it("отдаёт пустое имя, если в карточке его нет", () => { + expect( + commentReplyRecipient({ + notifyEnabled: true, + replierId: "admin-1", + replierRole: "admin", + parent: { ...parent, user: { email: "student@example.com", name: "" } }, + }) + ).toEqual({ email: "student@example.com", name: "" }); + }); +}); diff --git a/src/lib/__tests__/comment-reply-email.test.ts b/src/lib/__tests__/comment-reply-email.test.ts new file mode 100644 index 0000000..bf12309 --- /dev/null +++ b/src/lib/__tests__/comment-reply-email.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const sent: Array> = []; + +vi.mock("resend", () => ({ + Resend: class { + emails = { + send: async (args: Record) => { + sent.push(args); + return { data: { id: "test" }, error: null }; + }, + }; + }, +})); + +vi.mock("../settings", () => ({ + getSetting: async () => "Second Brain", +})); + +const { sendCommentReplyEmail } = await import("../email"); + +const LESSON_URL = "https://school.second-brain.ru/courses/obsidian-full/lessons/lesson-1"; + +describe("sendCommentReplyEmail", () => { + beforeEach(() => { + sent.length = 0; + }); + + it("кладёт текст ответа в письмо и ведёт кнопкой на урок", async () => { + await sendCommentReplyEmail( + "student@example.com", + "Владимир", + "6.3. Холст", + "Прямого импорта нет, но есть два пути.", + LESSON_URL, + "Дмитрий" + ); + + expect(sent).toHaveLength(1); + expect(sent[0].to).toBe("student@example.com"); + expect(sent[0].subject).toBe("Ответ на ваш комментарий к уроку «6.3. Холст»"); + expect(sent[0].html).toContain("Прямого импорта нет, но есть два пути."); + expect(sent[0].html).toContain("Привет, Владимир!"); + expect(sent[0].html).toContain("Отвечает Дмитрий:"); + expect(sent[0].html).toContain(`href="${LESSON_URL}"`); + }); + + it("экранирует разметку в тексте ответа и в названии урока", async () => { + await sendCommentReplyEmail( + "student@example.com", + "Владимир", + "Урок про холст", + 'Смотрите тег ', + LESSON_URL + ); + + const html = sent[0].html; + expect(html).not.toContain("