Add platform settings (Stage 9)

- Settings key-value table in Prisma with migration
- getSettings() / getSetting() helpers in lib/settings.ts
- Admin UI at /admin/settings with 6 sections: General, Notifications,
  Student profile, Legal docs, Curator permissions, Code injection
- saveSettings() server action with admin-only guard
- Maintenance mode: non-admin users redirected to /maintenance page
- schoolName propagated to page metadata and all email templates
- headCode / bodyCode injected into root layout <head> and <body>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 11:18:37 +05:00
parent 093e403f5f
commit e77588deb8
14 changed files with 834 additions and 29 deletions
+21 -11
View File
@@ -1,4 +1,5 @@
import { Resend } from "resend";
import { getSetting } from "./settings";
function getResend() {
return new Resend(process.env.RESEND_API_KEY);
@@ -6,9 +7,13 @@ function getResend() {
const FROM = process.env.EMAIL_FROM ?? "noreply@mailsend.second-brain.ru";
const BASE_URL = process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru";
async function getSchoolName() {
return getSetting("schoolName");
}
// ── HTML template (inline styles for maximum email client compatibility) ───────
function base(content: string) {
function base(content: string, schoolName = "Second Brain") {
return `<!DOCTYPE html>
<html lang="ru" xmlns="http://www.w3.org/1999/xhtml">
<head>
@@ -36,7 +41,7 @@ function base(content: string) {
<!-- Header -->
<tr>
<td style="padding:20px 28px;border-bottom:2px solid #AAAAAA;background-color:#F5F5F0;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#323232;">Second Brain · Образовательная платформа</p>
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#323232;">${schoolName} · Образовательная платформа</p>
</td>
</tr>
@@ -97,6 +102,7 @@ function quote(text: string) {
// ── Email senders ─────────────────────────────────────────────────────────────
export async function sendCourseAccessEmail(to: string, name: string, courseTitle: string) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
@@ -106,7 +112,7 @@ export async function sendCourseAccessEmail(to: string, name: string, courseTitl
<p ${p}>Вам открыт доступ к курсу <strong>«${courseTitle}»</strong>.</p>
<p ${pLast}>Перейдите на платформу чтобы начать обучение:</p>
${btn(`${BASE_URL}/dashboard`, "Перейти к курсам")}
`),
`, school),
}).catch((e) => console.error("[email] sendCourseAccessEmail:", e));
}
@@ -117,6 +123,7 @@ export async function sendHomeworkSubmittedEmail(
lessonTitle: string,
submissionId: string
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
@@ -126,7 +133,7 @@ export async function sendHomeworkSubmittedEmail(
<p ${p}>Студент <strong>${studentName}</strong> сдал работу по уроку <strong>«${lessonTitle}»</strong>.</p>
<p ${pLast}>Откройте работу чтобы проверить и оставить фидбек:</p>
${btn(`${BASE_URL}/curator/homework/${submissionId}`, "Проверить работу")}
`),
`, school),
}).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e));
}
@@ -137,6 +144,7 @@ export async function sendFeedbackReceivedEmail(
feedbackText: string,
lessonUrl: string
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
@@ -147,35 +155,37 @@ export async function sendFeedbackReceivedEmail(
${quote(feedbackText)}
<p ${pLast}>Вернитесь к уроку чтобы увидеть полный фидбек:</p>
${btn(lessonUrl, "Открыть урок")}
`),
`, school),
}).catch((e) => console.error("[email] sendFeedbackReceivedEmail:", e));
}
export async function sendWelcomeEmail(to: string, name: string) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: "Добро пожаловать в Second Brain",
subject: `Добро пожаловать в ${school}`,
html: base(`
<p ${p}>Привет, ${name}!</p>
<p ${p}>Ваш аккаунт на образовательной платформе <strong>Second Brain</strong> подтверждён.</p>
<p ${p}>Ваш аккаунт на образовательной платформе <strong>${school}</strong> подтверждён.</p>
<p ${pLast}>После того как администратор откроет вам доступ к курсу, вы получите письмо и сможете начать обучение.</p>
${btn(`${BASE_URL}/dashboard`, "Перейти на платформу")}
`),
`, school),
}).catch((e) => console.error("[email] sendWelcomeEmail:", e));
}
export async function sendTestEmail(to: string) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: "Тест — Second Brain LMS",
subject: `Тест — ${school} LMS`,
html: base(`
<p ${p}>Привет!</p>
<p ${p}>Это тестовое письмо от платформы <strong>Second Brain LMS</strong>.</p>
<p ${p}>Это тестовое письмо от платформы <strong>${school} LMS</strong>.</p>
<p ${p}>Так будут выглядеть все уведомления — доступ к курсу, фидбек по ДЗ и другие.</p>
<p ${pLast}>Если письмо отображается корректно, значит email-уведомления настроены и работают.</p>
${btn(`${BASE_URL}/dashboard`, "Открыть платформу")}
`),
`, school),
}).catch((e) => console.error("[email] sendTestEmail:", e));
}
+73
View File
@@ -0,0 +1,73 @@
import { prisma } from "./prisma";
// ── Defaults ──────────────────────────────────────────────────────────────────
export const SETTINGS_DEFAULTS = {
// Basic
schoolName: "Second Brain",
schoolDescription: "Образовательная платформа Second Brain",
schoolKeywords: "",
maintenanceMode: "false",
registrationEnabled: "true",
// Notifications
notificationEmails: "", // newline-separated list
notifyOnHomework: "true",
notifyOnRegistration: "true",
notifyStudentOnFeedback: "true",
// Student profile
requireEmailVerification: "true",
lastNameField: "optional", // required | optional | hidden
phoneField: "hidden", // required | optional | hidden
// Legal
privacyPolicyUrl: "",
termsUrl: "",
offerUrl: "",
showTermsCheckbox: "false",
orgRequisites: "",
// Curator permissions
curatorHomeworkScope: "all", // all | assigned
curatorCanAnswerQuestions: "true",
curatorCanSeeStudents: "true",
// Code injection
headCode: "",
bodyCode: "",
} as const;
export type SettingsKey = keyof typeof SETTINGS_DEFAULTS;
export type Settings = Record<SettingsKey, string>;
// ── Getters ───────────────────────────────────────────────────────────────────
export async function getSettings(): Promise<Settings> {
const rows = await prisma.settings.findMany();
const stored: Record<string, string> = {};
for (const row of rows) stored[row.key] = row.value;
return Object.fromEntries(
Object.entries(SETTINGS_DEFAULTS).map(([k, v]) => [k, stored[k] ?? v])
) as Settings;
}
export async function getSetting(key: SettingsKey): Promise<string> {
const row = await prisma.settings.findUnique({ where: { key } });
return row?.value ?? SETTINGS_DEFAULTS[key];
}
// ── Convenience helpers ───────────────────────────────────────────────────────
/** Parse a boolean setting ("true" / "false") */
export function asBool(value: string): boolean {
return value === "true";
}
/** Parse notification emails (newline-separated string → array) */
export function parseNotificationEmails(value: string): string[] {
return value
.split("\n")
.map((e) => e.trim())
.filter(Boolean);
}