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:
+21
-11
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user