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:
@@ -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}`);
|
|
||||||
}
|
|
||||||
@@ -289,6 +289,11 @@ export function SettingsForm({ initial }: { initial: Settings }) {
|
|||||||
checked={bool("notifyStudentOnFeedback")}
|
checked={bool("notifyStudentOnFeedback")}
|
||||||
onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")}
|
onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")}
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Уведомлять ученика об ответе на его комментарий"
|
||||||
|
checked={bool("notifyStudentOnCommentReply")}
|
||||||
|
onChange={(v) => set("notifyStudentOnCommentReply", v ? "true" : "false")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -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("<script>");
|
||||||
|
expect(html).toContain("Урок <b>про холст</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
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("Привет!");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,12 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { sendHomeworkSubmittedEmail, sendCommentNotificationEmail } from "@/lib/email";
|
import {
|
||||||
|
sendHomeworkSubmittedEmail,
|
||||||
|
sendCommentNotificationEmail,
|
||||||
|
sendCommentReplyEmail,
|
||||||
|
} from "@/lib/email";
|
||||||
|
import { commentReplyRecipient } from "@/lib/comment-notify";
|
||||||
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
||||||
|
|
||||||
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
||||||
@@ -175,9 +180,20 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
|
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Родителя читаем один раз: он нужен и для проверки, и для письма автору ветки.
|
||||||
|
const parent = parentId
|
||||||
|
? await prisma.lessonComment.findUnique({
|
||||||
|
where: { id: parentId },
|
||||||
|
select: {
|
||||||
|
lessonId: true,
|
||||||
|
userId: true,
|
||||||
|
deleted: true,
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
if (!isAdmin && !isCurator) throw new Error("Forbidden");
|
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");
|
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +201,8 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${slug}/lessons/${lessonId}`;
|
||||||
|
|
||||||
// Уведомление админам/кураторам о новом комментарии студента (не о своих ответах)
|
// Уведомление админам/кураторам о новом комментарии студента (не о своих ответах)
|
||||||
if (!isAdmin && !isCurator) {
|
if (!isAdmin && !isCurator) {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
@@ -196,7 +214,6 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
where: { role: { in: ["admin", "curator"] } },
|
where: { role: { in: ["admin", "curator"] } },
|
||||||
select: { email: true, name: true },
|
select: { email: true, name: true },
|
||||||
});
|
});
|
||||||
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${slug}/lessons/${lessonId}`;
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
recipients.map((r) =>
|
recipients.map((r) =>
|
||||||
sendCommentNotificationEmail(r.email, r.name, session.user.name, lesson.title, trimmed, lessonUrl)
|
sendCommentNotificationEmail(r.email, r.name, session.user.name, lesson.title, trimmed, lessonUrl)
|
||||||
@@ -205,6 +222,28 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обратное направление: автор ветки узнаёт письмом, что школа ответила. Без него
|
||||||
|
// ответ виден только тому, кто сам вернётся в урок.
|
||||||
|
if (parentId) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const recipient = commentReplyRecipient({
|
||||||
|
notifyEnabled: asBool(settings.notifyStudentOnCommentReply),
|
||||||
|
replierId: session.user.id,
|
||||||
|
replierRole: session.user.role,
|
||||||
|
parent,
|
||||||
|
});
|
||||||
|
if (recipient) {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
recipient.email,
|
||||||
|
recipient.name,
|
||||||
|
lesson.title,
|
||||||
|
trimmed,
|
||||||
|
lessonUrl,
|
||||||
|
session.user.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Кому уходит письмо об ответе на комментарий под уроком.
|
||||||
|
*
|
||||||
|
* Решение вынесено из серверного действия отдельной чистой функцией: у действия
|
||||||
|
* на входе сессия и Prisma, и покрыть тестами сами правила «шлём / не шлём» там
|
||||||
|
* нельзя. Ответы в ветке умеют писать только админ и куратор (см. addComment),
|
||||||
|
* поэтому адресат всегда один — автор родительского комментария.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CommentReplyParent {
|
||||||
|
userId: string;
|
||||||
|
deleted: boolean;
|
||||||
|
user: { email: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommentReplyRecipient {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commentReplyRecipient(params: {
|
||||||
|
notifyEnabled: boolean;
|
||||||
|
replierId: string;
|
||||||
|
replierRole: string | null | undefined;
|
||||||
|
parent: CommentReplyParent | null;
|
||||||
|
}): CommentReplyRecipient | null {
|
||||||
|
const { notifyEnabled, replierId, replierRole, parent } = params;
|
||||||
|
|
||||||
|
if (!notifyEnabled) return null;
|
||||||
|
if (replierRole !== "admin" && replierRole !== "curator") return null;
|
||||||
|
if (!parent) return null;
|
||||||
|
// Ветку удалённого комментария ученик в уроке не увидит — письмо вело бы в пустоту.
|
||||||
|
if (parent.deleted) return null;
|
||||||
|
// Свой же комментарий: staff отвечает сам себе или уточняет собственную мысль.
|
||||||
|
if (parent.userId === replierId) return null;
|
||||||
|
|
||||||
|
const email = parent.user?.email?.trim();
|
||||||
|
if (!email) return null;
|
||||||
|
|
||||||
|
return { email, name: parent.user?.name?.trim() ?? "" };
|
||||||
|
}
|
||||||
@@ -380,6 +380,43 @@ export async function sendQuestionReplyEmail(
|
|||||||
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
|
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ответ под уроком — это развёрнутый разбор, а не реплика в чате: ввод разрешает
|
||||||
|
// 10 000 символов, и порог вопросов (2000) резал бы каждый второй такой ответ.
|
||||||
|
// Письму нужен только предохранитель от мегабайтов, отсюда 4000.
|
||||||
|
const COMMENT_REPLY_PREVIEW_LIMIT = 4000;
|
||||||
|
|
||||||
|
export async function sendCommentReplyEmail(
|
||||||
|
to: string,
|
||||||
|
studentName: string,
|
||||||
|
lessonTitle: string,
|
||||||
|
replyText: string,
|
||||||
|
lessonUrl: string,
|
||||||
|
replierName?: string,
|
||||||
|
) {
|
||||||
|
const school = await getSchoolName();
|
||||||
|
const trimmed = replyText.trim();
|
||||||
|
const preview =
|
||||||
|
trimmed.length > COMMENT_REPLY_PREVIEW_LIMIT
|
||||||
|
? `${trimmed.slice(0, COMMENT_REPLY_PREVIEW_LIMIT)}…`
|
||||||
|
: trimmed;
|
||||||
|
const truncated = trimmed.length > COMMENT_REPLY_PREVIEW_LIMIT;
|
||||||
|
const title = escapeHtml(lessonTitle);
|
||||||
|
|
||||||
|
await getResend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to,
|
||||||
|
subject: `Ответ на ваш комментарий к уроку «${lessonTitle}»`,
|
||||||
|
html: base(`
|
||||||
|
<p ${p}>Привет${studentName ? `, ${escapeHtml(studentName)}` : ""}!</p>
|
||||||
|
<p ${p}>Школа ответила на ваш комментарий под уроком <strong>«${title}»</strong>.${replierName ? ` Отвечает <strong>${escapeHtml(replierName)}</strong>:` : ""}</p>
|
||||||
|
${quote(preview)}
|
||||||
|
${truncated ? `<p ${p}>Ответ длинный — целиком он под уроком.</p>` : ""}
|
||||||
|
<p ${pLast}>Посмотреть ветку и ответить:</p>
|
||||||
|
${btn(lessonUrl, "Открыть урок")}
|
||||||
|
`, school),
|
||||||
|
}).catch((e) => console.error("[email] sendCommentReplyEmail:", e));
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendQuestionFollowUpEmail(
|
export async function sendQuestionFollowUpEmail(
|
||||||
to: string,
|
to: string,
|
||||||
recipientName: string,
|
recipientName: string,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const SETTINGS_DEFAULTS = {
|
|||||||
notifyOnComment: "true",
|
notifyOnComment: "true",
|
||||||
notifyOnRegistration: "true",
|
notifyOnRegistration: "true",
|
||||||
notifyStudentOnFeedback: "true",
|
notifyStudentOnFeedback: "true",
|
||||||
|
notifyStudentOnCommentReply: "true",
|
||||||
|
|
||||||
// Student profile
|
// Student profile
|
||||||
requireEmailVerification: "true",
|
requireEmailVerification: "true",
|
||||||
|
|||||||
Reference in New Issue
Block a user