From 6975a9f97e6a7d1244aee71ea69984737e80508c Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Tue, 7 Apr 2026 14:46:46 +0500 Subject: [PATCH] Add email notifications via Resend - src/lib/email.ts: HTML templates for 4 email types (Second Brain design) - Welcome email on user registration (Better Auth databaseHooks) - Course access email when admin grants enrollment - Homework submitted email to all admins/curators (first submission only) - Feedback received email to student with feedback text and lesson link - Update TECHNICAL.md: Resend domain, from-address, email vars, Stage 3 summary Co-Authored-By: Claude Sonnet 4.6 --- TECHNICAL.md | 13 ++- .../lessons/[lessonId]/homework-actions.ts | 27 ++++- src/app/admin/courses/[courseId]/actions.ts | 11 ++ .../homework/[submissionId]/actions.ts | 31 +++++ src/lib/auth.ts | 10 ++ src/lib/email.ts | 107 ++++++++++++++++++ 6 files changed, 195 insertions(+), 4 deletions(-) create mode 100644 src/lib/email.ts diff --git a/TECHNICAL.md b/TECHNICAL.md index 1a8e39c..a8d90cf 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -19,6 +19,8 @@ | **Бакет** | `second-brain-lms` (публичный, read-only) | | **Endpoint S3** | https://nbg1.your-objectstorage.com | | **Git-репозиторий** | https://git.second-brain.ru/admins/lms-sb | +| **Email-сервис** | Resend, домен `mailsend.second-brain.ru` (verified) | +| **From-адрес** | noreply@mailsend.second-brain.ru | ### Деплой @@ -37,8 +39,8 @@ docker compose -f docker-compose.prod.yml up -d --build ``` DB_PASSWORD=lms_cd5041e961a3050db359aa15 BETTER_AUTH_SECRET= -RESEND_API_KEY=<пусто — заполнить при настройке email> -EMAIL_FROM=noreply@school.second-brain.ru +RESEND_API_KEY=re_VX7TCnjs_N2geRqvveHjVfbsSyr4VbmzL +EMAIL_FROM=noreply@mailsend.second-brain.ru S3_ENDPOINT=https://nbg1.your-objectstorage.com S3_BUCKET=second-brain-lms S3_ACCESS_KEY=<ключ> @@ -248,6 +250,13 @@ LessonComment — id, lessonId, userId, text, deleted, createdAt - История доступа: таблица `AccessLog`, отображается на странице курса и ученика - Hetzner Object Storage подключён: бакет `second-brain-lms`, Nuremberg +### Этап 3 — Прогресс, ДЗ, Email ✅ +- Прогресс студента: кнопка «Отметить как пройденный», галочки и прогресс-бар в сайдбаре, прогресс на дашборде +- Домашние задания: редактор ДЗ в уроке (admin), сдача текстом + файлами (student), проверка и фидбек (curator/admin) +- Куратор-панель: `/curator/dashboard` со статистикой, `/curator/homework` список, страница проверки +- Админ имеет доступ к куратор-маршрутам через пункт «ДЗ на проверку» в сайдбаре +- Email уведомления через Resend: доступ к курсу, новая работа, фидбек получен, приветствие + --- ## Известные ограничения / технический долг diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts b/src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts index 67b44e0..8f326d6 100644 --- a/src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts +++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; import { revalidatePath } from "next/cache"; +import { sendHomeworkSubmittedEmail } from "@/lib/email"; interface HomeworkFile { name: string; @@ -31,15 +32,37 @@ export async function submitHomework( throw new Error("Работа уже проверена"); } + let submissionId: string; if (existing) { - await prisma.homeworkSubmission.update({ + const updated = await prisma.homeworkSubmission.update({ where: { id: existing.id }, data: { text, files: files as object[], submittedAt: new Date() }, }); + submissionId = updated.id; } else { - await prisma.homeworkSubmission.create({ + const created = await prisma.homeworkSubmission.create({ data: { homeworkId, userId: session.user.id, text, files: files as object[] }, }); + submissionId = created.id; + + // Notify admins/curators on first submission only + const [lesson, admins] = await Promise.all([ + prisma.homework.findUnique({ + where: { id: homeworkId }, + include: { lesson: { select: { title: true } } }, + }), + prisma.user.findMany({ + where: { role: { in: ["admin", "curator"] } }, + select: { email: true, name: true }, + }), + ]); + if (lesson) { + await Promise.all( + admins.map((a) => + sendHomeworkSubmittedEmail(a.email, a.name, session.user.name, lesson.lesson.title, submissionId) + ) + ); + } } revalidatePath(`/courses/${slug}/lessons/${lessonId}`); diff --git a/src/app/admin/courses/[courseId]/actions.ts b/src/app/admin/courses/[courseId]/actions.ts index ea0a4b6..c6e4b23 100644 --- a/src/app/admin/courses/[courseId]/actions.ts +++ b/src/app/admin/courses/[courseId]/actions.ts @@ -5,6 +5,7 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; +import { sendCourseAccessEmail } from "@/lib/email"; async function requireAdmin() { const session = await auth.api.getSession({ headers: await headers() }); @@ -70,6 +71,16 @@ export async function grantAccess( note: note || null, }, }); + + // Send email notification + const [user, course] = await Promise.all([ + prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }), + prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }), + ]); + if (user && course) { + await sendCourseAccessEmail(user.email, user.name, course.title); + } + revalidatePath(`/admin/courses/${courseId}`); } diff --git a/src/app/curator/homework/[submissionId]/actions.ts b/src/app/curator/homework/[submissionId]/actions.ts index 078d518..bd5d442 100644 --- a/src/app/curator/homework/[submissionId]/actions.ts +++ b/src/app/curator/homework/[submissionId]/actions.ts @@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma"; import { auth } from "@/lib/auth"; import { headers } from "next/headers"; import { revalidatePath } from "next/cache"; +import { sendFeedbackReceivedEmail } from "@/lib/email"; export async function submitFeedback(submissionId: string, text: string) { const session = await auth.api.getSession({ headers: await headers() }); @@ -15,6 +16,36 @@ export async function submitFeedback(submissionId: string, text: string) { data: { submissionId, curatorId: session.user.id, text }, }); + // Send email to student + const submission = await prisma.homeworkSubmission.findUnique({ + where: { id: submissionId }, + include: { + user: { select: { email: true, name: true } }, + homework: { + include: { + lesson: { + select: { + title: true, + module: { select: { course: { select: { slug: true } } } }, + }, + }, + }, + }, + }, + }); + + if (submission) { + const { lesson } = submission.homework; + const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${submission.homework.lessonId}`; + await sendFeedbackReceivedEmail( + submission.user.email, + submission.user.name, + lesson.title, + text, + lessonUrl + ); + } + revalidatePath("/curator/homework"); revalidatePath(`/curator/homework/${submissionId}`); } diff --git a/src/lib/auth.ts b/src/lib/auth.ts index fa01c5a..5c86f60 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -3,6 +3,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma"; import { admin } from "better-auth/plugins"; import { prisma } from "./prisma"; import bcrypt from "bcryptjs"; +import { sendWelcomeEmail } from "./email"; export const auth = betterAuth({ database: prismaAdapter(prisma, { @@ -16,6 +17,15 @@ export const auth = betterAuth({ verify: ({ hash, password }) => bcrypt.compare(password, hash), }, }, + databaseHooks: { + user: { + create: { + after: async (user) => { + await sendWelcomeEmail(user.email, user.name); + }, + }, + }, + }, plugins: [ admin({ defaultRole: "student", diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..944b09a --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,107 @@ +import { Resend } from "resend"; + +const resend = new Resend(process.env.RESEND_API_KEY); +const FROM = process.env.EMAIL_FROM ?? "noreply@mailsend.second-brain.ru"; +const BASE_URL = process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"; + +function base(content: string) { + return ` + + + + + + + +
+

Second Brain · Образовательная платформа

+
${content}
+ +
+ +`; +} + +// ── Email senders ───────────────────────────────────────────────────────────── + +export async function sendCourseAccessEmail(to: string, name: string, courseTitle: string) { + await resend.emails.send({ + from: FROM, + to, + subject: `Вам открыт доступ к курсу «${courseTitle}»`, + html: base(` +

Привет, ${name}!

+

Вам открыт доступ к курсу «${courseTitle}».

+

Перейдите на платформу чтобы начать обучение:

+ Перейти к курсам → + `), + }).catch((e) => console.error("[email] sendCourseAccessEmail:", e)); +} + +export async function sendHomeworkSubmittedEmail( + to: string, + curatorName: string, + studentName: string, + lessonTitle: string, + submissionId: string +) { + await resend.emails.send({ + from: FROM, + to, + subject: `Новая работа на проверку — ${lessonTitle}`, + html: base(` +

Привет, ${curatorName}!

+

Студент ${studentName} сдал работу по уроку «${lessonTitle}».

+

Откройте работу чтобы проверить и оставить фидбек:

+ Проверить работу → + `), + }).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e)); +} + +export async function sendFeedbackReceivedEmail( + to: string, + studentName: string, + lessonTitle: string, + feedbackText: string, + lessonUrl: string +) { + await resend.emails.send({ + from: FROM, + to, + subject: `Получен фидбек по уроку «${lessonTitle}»`, + html: base(` +

Привет, ${studentName}!

+

Куратор проверил вашу работу по уроку «${lessonTitle}» и оставил обратную связь:

+
+ ${feedbackText.replace(/\n/g, "
")} +
+

Вернитесь к уроку чтобы увидеть полный фидбек:

+ Открыть урок → + `), + }).catch((e) => console.error("[email] sendFeedbackReceivedEmail:", e)); +} + +export async function sendWelcomeEmail(to: string, name: string) { + await resend.emails.send({ + from: FROM, + to, + subject: "Добро пожаловать в Second Brain", + html: base(` +

Привет, ${name}!

+

Ваш аккаунт на образовательной платформе Second Brain подтверждён.

+

После того как администратор откроет вам доступ к курсу, вы получите письмо и сможете начать обучение.

+ Перейти на платформу → + `), + }).catch((e) => console.error("[email] sendWelcomeEmail:", e)); +}