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 <noreply@anthropic.com>
This commit is contained in:
+11
-2
@@ -19,6 +19,8 @@
|
|||||||
| **Бакет** | `second-brain-lms` (публичный, read-only) |
|
| **Бакет** | `second-brain-lms` (публичный, read-only) |
|
||||||
| **Endpoint S3** | https://nbg1.your-objectstorage.com |
|
| **Endpoint S3** | https://nbg1.your-objectstorage.com |
|
||||||
| **Git-репозиторий** | https://git.second-brain.ru/admins/lms-sb |
|
| **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
|
DB_PASSWORD=lms_cd5041e961a3050db359aa15
|
||||||
BETTER_AUTH_SECRET=<secret>
|
BETTER_AUTH_SECRET=<secret>
|
||||||
RESEND_API_KEY=<пусто — заполнить при настройке email>
|
RESEND_API_KEY=re_VX7TCnjs_N2geRqvveHjVfbsSyr4VbmzL
|
||||||
EMAIL_FROM=noreply@school.second-brain.ru
|
EMAIL_FROM=noreply@mailsend.second-brain.ru
|
||||||
S3_ENDPOINT=https://nbg1.your-objectstorage.com
|
S3_ENDPOINT=https://nbg1.your-objectstorage.com
|
||||||
S3_BUCKET=second-brain-lms
|
S3_BUCKET=second-brain-lms
|
||||||
S3_ACCESS_KEY=<ключ>
|
S3_ACCESS_KEY=<ключ>
|
||||||
@@ -248,6 +250,13 @@ LessonComment — id, lessonId, userId, text, deleted, createdAt
|
|||||||
- История доступа: таблица `AccessLog`, отображается на странице курса и ученика
|
- История доступа: таблица `AccessLog`, отображается на странице курса и ученика
|
||||||
- Hetzner Object Storage подключён: бакет `second-brain-lms`, Nuremberg
|
- Hetzner Object Storage подключён: бакет `second-brain-lms`, Nuremberg
|
||||||
|
|
||||||
|
### Этап 3 — Прогресс, ДЗ, Email ✅
|
||||||
|
- Прогресс студента: кнопка «Отметить как пройденный», галочки и прогресс-бар в сайдбаре, прогресс на дашборде
|
||||||
|
- Домашние задания: редактор ДЗ в уроке (admin), сдача текстом + файлами (student), проверка и фидбек (curator/admin)
|
||||||
|
- Куратор-панель: `/curator/dashboard` со статистикой, `/curator/homework` список, страница проверки
|
||||||
|
- Админ имеет доступ к куратор-маршрутам через пункт «ДЗ на проверку» в сайдбаре
|
||||||
|
- Email уведомления через Resend: доступ к курсу, новая работа, фидбек получен, приветствие
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Известные ограничения / технический долг
|
## Известные ограничения / технический долг
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 } from "@/lib/email";
|
||||||
|
|
||||||
interface HomeworkFile {
|
interface HomeworkFile {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -31,15 +32,37 @@ export async function submitHomework(
|
|||||||
throw new Error("Работа уже проверена");
|
throw new Error("Работа уже проверена");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let submissionId: string;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.homeworkSubmission.update({
|
const updated = await prisma.homeworkSubmission.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { text, files: files as object[], submittedAt: new Date() },
|
data: { text, files: files as object[], submittedAt: new Date() },
|
||||||
});
|
});
|
||||||
|
submissionId = updated.id;
|
||||||
} else {
|
} else {
|
||||||
await prisma.homeworkSubmission.create({
|
const created = await prisma.homeworkSubmission.create({
|
||||||
data: { homeworkId, userId: session.user.id, text, files: files as object[] },
|
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}`);
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { headers } from "next/headers";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { sendCourseAccessEmail } from "@/lib/email";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
@@ -70,6 +71,16 @@ export async function grantAccess(
|
|||||||
note: note || null,
|
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}`);
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 { sendFeedbackReceivedEmail } from "@/lib/email";
|
||||||
|
|
||||||
export async function submitFeedback(submissionId: string, text: string) {
|
export async function submitFeedback(submissionId: string, text: string) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
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 },
|
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");
|
||||||
revalidatePath(`/curator/homework/${submissionId}`);
|
revalidatePath(`/curator/homework/${submissionId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma";
|
|||||||
import { admin } from "better-auth/plugins";
|
import { admin } from "better-auth/plugins";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { sendWelcomeEmail } from "./email";
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
@@ -16,6 +17,15 @@ export const auth = betterAuth({
|
|||||||
verify: ({ hash, password }) => bcrypt.compare(password, hash),
|
verify: ({ hash, password }) => bcrypt.compare(password, hash),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
databaseHooks: {
|
||||||
|
user: {
|
||||||
|
create: {
|
||||||
|
after: async (user) => {
|
||||||
|
await sendWelcomeEmail(user.email, user.name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
admin({
|
admin({
|
||||||
defaultRole: "student",
|
defaultRole: "student",
|
||||||
|
|||||||
@@ -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 `<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; background: #F5F5F0; font-family: 'Courier New', monospace; color: #323232; }
|
||||||
|
.wrap { max-width: 560px; margin: 40px auto; background: #F5F5F0; border: 2px solid #AAAAAA; box-shadow: 4px 4px 0 0 #AAAAAA; }
|
||||||
|
.header { padding: 24px 32px; border-bottom: 2px solid #AAAAAA; }
|
||||||
|
.header p { margin: 0; font-size: 14px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||||
|
.body { padding: 32px; }
|
||||||
|
.body p { margin: 0 0 16px; font-size: 14px; line-height: 1.6; }
|
||||||
|
.btn { display: inline-block; padding: 10px 24px; background: #E8F0D8; border: 2px solid #323232; box-shadow: 3px 3px 0 0 #323232; font-size: 13px; font-weight: 700; text-decoration: none; color: #323232; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||||
|
.footer { padding: 16px 32px; border-top: 2px solid #AAAAAA; }
|
||||||
|
.footer p { margin: 0; font-size: 11px; color: #AAAAAA; }
|
||||||
|
.tag { display: inline-block; padding: 2px 8px; border: 1px solid #AAAAAA; font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="header"><p>Second Brain · Образовательная платформа</p></div>
|
||||||
|
<div class="body">${content}</div>
|
||||||
|
<div class="footer"><p>Это автоматическое письмо, не отвечайте на него.</p></div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email senders ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function sendCourseAccessEmail(to: string, name: string, courseTitle: string) {
|
||||||
|
await resend.emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to,
|
||||||
|
subject: `Вам открыт доступ к курсу «${courseTitle}»`,
|
||||||
|
html: base(`
|
||||||
|
<p>Привет, ${name}!</p>
|
||||||
|
<p>Вам открыт доступ к курсу <strong>«${courseTitle}»</strong>.</p>
|
||||||
|
<p style="margin-bottom: 24px;">Перейдите на платформу чтобы начать обучение:</p>
|
||||||
|
<a href="${BASE_URL}/dashboard" class="btn">Перейти к курсам →</a>
|
||||||
|
`),
|
||||||
|
}).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(`
|
||||||
|
<p>Привет, ${curatorName}!</p>
|
||||||
|
<p>Студент <strong>${studentName}</strong> сдал работу по уроку <strong>«${lessonTitle}»</strong>.</p>
|
||||||
|
<p style="margin-bottom: 24px;">Откройте работу чтобы проверить и оставить фидбек:</p>
|
||||||
|
<a href="${BASE_URL}/curator/homework/${submissionId}" class="btn">Проверить работу →</a>
|
||||||
|
`),
|
||||||
|
}).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(`
|
||||||
|
<p>Привет, ${studentName}!</p>
|
||||||
|
<p>Куратор проверил вашу работу по уроку <strong>«${lessonTitle}»</strong> и оставил обратную связь:</p>
|
||||||
|
<div style="border-left: 3px solid #323232; padding: 12px 16px; margin: 16px 0; background: #E8F0D8; font-size: 13px; line-height: 1.6;">
|
||||||
|
${feedbackText.replace(/\n/g, "<br/>")}
|
||||||
|
</div>
|
||||||
|
<p style="margin-bottom: 24px;">Вернитесь к уроку чтобы увидеть полный фидбек:</p>
|
||||||
|
<a href="${lessonUrl}" class="btn">Открыть урок →</a>
|
||||||
|
`),
|
||||||
|
}).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(`
|
||||||
|
<p>Привет, ${name}!</p>
|
||||||
|
<p>Ваш аккаунт на образовательной платформе <strong>Second Brain</strong> подтверждён.</p>
|
||||||
|
<p style="margin-bottom: 24px;">После того как администратор откроет вам доступ к курсу, вы получите письмо и сможете начать обучение.</p>
|
||||||
|
<a href="${BASE_URL}/dashboard" class="btn">Перейти на платформу →</a>
|
||||||
|
`),
|
||||||
|
}).catch((e) => console.error("[email] sendWelcomeEmail:", e));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user