feat(комментарии): письмо ученику об ответе под уроком

Ответы персонала в ветке комментариев никуда не уведомляли: ученик видел
ответ, только если сам возвращался в урок. У тредов вопросов письмо есть,
у комментариев не было — из-за этого ответ под уроком 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-15 11:49:45 +05:00
co-authored by Claude Opus 5
parent 273cb2d1b4
commit 8223cbb963
8 changed files with 321 additions and 72 deletions
+109
View File
@@ -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: "" });
});
});
@@ -0,0 +1,86 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const sent: Array<Record<string, string>> = [];
vi.mock("resend", () => ({
Resend: class {
emails = {
send: async (args: Record<string, string>) => {
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("Отвечает <strong>Дмитрий</strong>:");
expect(sent[0].html).toContain(`href="${LESSON_URL}"`);
});
it("экранирует разметку в тексте ответа и в названии урока", async () => {
await sendCommentReplyEmail(
"student@example.com",
"Владимир",
"Урок <b>про холст</b>",
'Смотрите тег <script>alert("x")</script>',
LESSON_URL
);
const html = sent[0].html;
expect(html).not.toContain("<script>");
expect(html).toContain("&lt;script&gt;");
expect(html).toContain("Урок &lt;b&gt;про холст&lt;/b&gt;");
});
it("обрезает длинный ответ и говорит, что целиком он под уроком", async () => {
await sendCommentReplyEmail(
"student@example.com",
"Владимир",
"6.3. Холст",
"я".repeat(4500),
LESSON_URL
);
const html = sent[0].html;
expect(html).toContain("Ответ длинный — целиком он под уроком.");
expect(html).toContain("…");
expect(html).not.toContain("я".repeat(4100));
});
it("без имени отвечающего пишет просто «Школа ответила»", async () => {
await sendCommentReplyEmail("student@example.com", "", "6.3. Холст", "Коротко", LESSON_URL);
const html = sent[0].html;
expect(html).toContain("Школа ответила на ваш комментарий");
expect(html).not.toContain("Отвечает <strong>");
expect(html).toContain("Привет!");
});
});