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
+7
View File
@@ -3,11 +3,18 @@ import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button";
import { getSetting } from "@/lib/settings";
export default async function StudentLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
// Maintenance mode: non-admin users see the maintenance page
if (session.user.role !== "admin") {
const maintenance = await getSetting("maintenanceMode");
if (maintenance === "true") redirect("/maintenance");
}
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}>
<header
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { SETTINGS_DEFAULTS, type SettingsKey } from "@/lib/settings";
export async function saveSettings(data: Record<string, string>) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
const validKeys = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[];
const ops = validKeys
.filter((key) => key in data)
.map((key) =>
prisma.settings.upsert({
where: { key },
update: { value: data[key] },
create: { key, value: data[key] },
})
);
await Promise.all(ops);
revalidatePath("/admin/settings");
revalidatePath("/", "layout");
}
+22
View File
@@ -0,0 +1,22 @@
import { getSettings } from "@/lib/settings";
import { SettingsForm } from "@/components/admin/settings-form";
export const metadata = { title: "Настройки платформы" };
export default async function SettingsPage() {
const settings = await getSettings();
return (
<div className="p-8 max-w-2xl">
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Настройки платформы
</h1>
</div>
<SettingsForm initial={settings} />
</div>
);
}
+7
View File
@@ -4,12 +4,19 @@ import { redirect } from "next/navigation";
import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button";
import { AdminShell } from "@/components/admin/admin-shell";
import { getSetting } from "@/lib/settings";
export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "curator" && session.user.role !== "admin") redirect("/dashboard");
// Maintenance mode: curators (non-admin) see the maintenance page
if (session.user.role === "curator") {
const maintenance = await getSetting("maintenanceMode");
if (maintenance === "true") redirect("/maintenance");
}
// Admin uses the admin shell with sidebar
if (session.user.role === "admin") {
return <AdminShell userName={session.user.name}>{children}</AdminShell>;
+21 -6
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Fira_Mono } from "next/font/google";
import "./globals.css";
import { getSettings } from "@/lib/settings";
const firaMono = Fira_Mono({
weight: ["400", "500", "700"],
@@ -9,19 +10,33 @@ const firaMono = Fira_Mono({
display: "swap",
});
export const metadata: Metadata = {
title: "Second Brain — Обучение",
description: "Образовательная платформа Second Brain",
};
export async function generateMetadata(): Promise<Metadata> {
const settings = await getSettings();
return {
title: `${settings.schoolName} — Обучение`,
description: settings.schoolDescription || "Образовательная платформа",
keywords: settings.schoolKeywords || undefined,
};
}
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const settings = await getSettings();
return (
<html lang="ru" className={`${firaMono.variable} h-full antialiased`}>
<body className="min-h-full flex flex-col">{children}</body>
{settings.headCode ? (
<head dangerouslySetInnerHTML={{ __html: settings.headCode }} />
) : null}
<body className="min-h-full flex flex-col">
{children}
{settings.bodyCode ? (
<div dangerouslySetInnerHTML={{ __html: settings.bodyCode }} />
) : null}
</body>
</html>
);
}
+23
View File
@@ -0,0 +1,23 @@
export default function MaintenancePage() {
return (
<div
className="min-h-screen flex items-center justify-center p-8"
style={{ backgroundColor: "var(--background)" }}
>
<div className="card-aubade p-10 max-w-md w-full text-center space-y-4">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Технические работы
</p>
<h1 className="text-2xl font-bold" style={{ color: "var(--foreground)" }}>
Скоро вернёмся
</h1>
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Платформа временно недоступна. Мы проводим обновление пожалуйста, зайдите позже.
</p>
</div>
</div>
);
}
+1
View File
@@ -9,6 +9,7 @@ const links = [
{ href: "/admin/categories", label: "Категории" },
{ href: "/admin/users", label: "Пользователи" },
{ href: "/curator/homework", label: "ДЗ на проверку" },
{ href: "/admin/settings", label: "Настройки" },
];
export function AdminNav() {
+450
View File
@@ -0,0 +1,450 @@
"use client";
import { useState, useTransition } from "react";
import { Save } from "lucide-react";
import { saveSettings } from "@/app/admin/settings/actions";
import type { Settings } from "@/lib/settings";
// ── Small primitives ──────────────────────────────────────────────────────────
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
} as React.CSSProperties;
const focusHandlers = {
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(e.currentTarget.style.borderColor = "var(--foreground)"),
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(e.currentTarget.style.borderColor = "var(--border)"),
};
function Section({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="card-aubade p-6 space-y-5">
<div>
<h2
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
{title}
</h2>
{hint && (
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
{hint}
</p>
)}
</div>
{children}
</div>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
{label}
</label>
{hint && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{hint}
</p>
)}
{children}
</div>
);
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-start gap-3">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="mt-0.5 flex-shrink-0"
>
<span
className="relative inline-block w-10 h-6 transition-colors"
style={{
background: checked ? "var(--accent)" : "var(--border)",
border: "2px solid var(--foreground)",
}}
>
<span
className="absolute top-0.5 w-4 h-4 transition-transform"
style={{
background: "var(--foreground)",
left: "2px",
transform: checked ? "translateX(16px)" : "translateX(0)",
}}
/>
</span>
</button>
<div>
<p className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
{label}
</p>
{hint && (
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>
{hint}
</p>
)}
</div>
</div>
);
}
function SelectField({
label,
hint,
value,
onChange,
options,
}: {
label: string;
hint?: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<Field label={label} hint={hint}>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
...inputStyle,
appearance: "none",
cursor: "pointer",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
);
}
// ── Main form ─────────────────────────────────────────────────────────────────
export function SettingsForm({ initial }: { initial: Settings }) {
const [s, setS] = useState(initial);
const [saved, setSaved] = useState(false);
const [pending, startTransition] = useTransition();
function set(key: keyof Settings, value: string) {
setS((prev) => ({ ...prev, [key]: value }));
}
function bool(key: keyof Settings) {
return s[key] === "true";
}
function handleSave() {
startTransition(async () => {
await saveSettings(s);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
});
}
return (
<div className="space-y-5">
{/* Save button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={pending}
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
style={{ opacity: pending ? 0.6 : 1 }}
>
<Save size={14} />
{pending ? "Сохранение..." : saved ? "Сохранено ✓" : "Сохранить настройки"}
</button>
</div>
{/* ── 1. Основное ── */}
<Section title="Основное">
<Field label="Название школы" hint="Отображается в заголовке браузера, письмах и подписях">
<input
value={s.schoolName}
onChange={(e) => set("schoolName", e.target.value)}
style={inputStyle}
{...focusHandlers}
/>
</Field>
<Field label="Описание школы" hint="Мета-тег description для поисковых систем">
<textarea
value={s.schoolDescription}
onChange={(e) => set("schoolDescription", e.target.value)}
rows={2}
style={{ ...inputStyle, resize: "vertical" }}
{...focusHandlers}
/>
</Field>
<Field label="Ключевые слова" hint="Мета-тег keywords, через запятую">
<input
value={s.schoolKeywords}
onChange={(e) => set("schoolKeywords", e.target.value)}
placeholder="obsidian, pkm, second brain"
style={inputStyle}
{...focusHandlers}
/>
</Field>
<div className="space-y-3 pt-1">
<Toggle
label="Режим технических работ"
hint="Ученики увидят страницу-заглушку. Администраторы входят в обычном режиме."
checked={bool("maintenanceMode")}
onChange={(v) => set("maintenanceMode", v ? "true" : "false")}
/>
<Toggle
label="Открытая регистрация"
hint="Если выключено — форма регистрации недоступна, новые аккаунты создаёт только администратор."
checked={bool("registrationEnabled")}
onChange={(v) => set("registrationEnabled", v ? "true" : "false")}
/>
</div>
</Section>
{/* ── 2. Уведомления ── */}
<Section
title="Уведомления"
hint="Кому отправлять системные письма о новых ДЗ, регистрациях и вопросах учеников."
>
<Field
label="Email(ы) для уведомлений"
hint="По одному адресу на строку. Если пусто — письма не отправляются."
>
<textarea
value={s.notificationEmails}
onChange={(e) => set("notificationEmails", e.target.value)}
rows={3}
placeholder={"admin@school.ru\ncurator@school.ru"}
style={{ ...inputStyle, resize: "vertical", fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<div className="space-y-3 pt-1">
<Toggle
label="Уведомлять о новом домашнем задании"
checked={bool("notifyOnHomework")}
onChange={(v) => set("notifyOnHomework", v ? "true" : "false")}
/>
<Toggle
label="Уведомлять о новой регистрации ученика"
checked={bool("notifyOnRegistration")}
onChange={(v) => set("notifyOnRegistration", v ? "true" : "false")}
/>
<Toggle
label="Уведомлять ученика о полученном фидбеке"
checked={bool("notifyStudentOnFeedback")}
onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")}
/>
</div>
</Section>
{/* ── 3. Данные ученика ── */}
<Section title="Данные ученика" hint="Поля при регистрации и требования к аккаунту.">
<Toggle
label="Требовать подтверждение email"
hint="Пока email не подтверждён — ученик не может войти в личный кабинет."
checked={bool("requireEmailVerification")}
onChange={(v) => set("requireEmailVerification", v ? "true" : "false")}
/>
<SelectField
label="Фамилия при регистрации"
value={s.lastNameField}
onChange={(v) => set("lastNameField", v)}
options={[
{ value: "required", label: "Обязательная" },
{ value: "optional", label: "Необязательная" },
{ value: "hidden", label: "Не показывать" },
]}
/>
<SelectField
label="Телефон при регистрации"
value={s.phoneField}
onChange={(v) => set("phoneField", v)}
options={[
{ value: "required", label: "Обязательный" },
{ value: "optional", label: "Необязательный" },
{ value: "hidden", label: "Не показывать" },
]}
/>
</Section>
{/* ── 4. Юридические документы ── */}
<Section
title="Юридические документы"
hint="Ссылки на внешние документы (Google Docs, Notion и т.п.)."
>
<Field label="Политика конфиденциальности (URL)">
<input
value={s.privacyPolicyUrl}
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
placeholder="https://..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Field label="Согласие на обработку персональных данных (URL)">
<input
value={s.termsUrl}
onChange={(e) => set("termsUrl", e.target.value)}
placeholder="https://..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Field label="Договор-оферта (URL)">
<input
value={s.offerUrl}
onChange={(e) => set("offerUrl", e.target.value)}
placeholder="https://..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Toggle
label="Чекбокс «Я принимаю условия» на форме регистрации"
hint="Ученик обязан поставить галочку перед отправкой формы."
checked={bool("showTermsCheckbox")}
onChange={(v) => set("showTermsCheckbox", v ? "true" : "false")}
/>
<Field
label="Реквизиты организации"
hint="Отображаются в подвале личного кабинета ученика."
>
<textarea
value={s.orgRequisites}
onChange={(e) => set("orgRequisites", e.target.value)}
rows={3}
placeholder={"ИП Иванов Иван Иванович\nИНН 123456789012\nОГРНИП 123456789012345"}
style={{ ...inputStyle, resize: "vertical" }}
{...focusHandlers}
/>
</Field>
</Section>
{/* ── 5. Права куратора ── */}
<Section title="Права куратора">
<SelectField
label="Куратор видит домашние задания"
value={s.curatorHomeworkScope}
onChange={(v) => set("curatorHomeworkScope", v)}
options={[
{ value: "all", label: "По всем курсам" },
{ value: "assigned", label: "Только по назначенным курсам" },
]}
/>
<div className="space-y-3 pt-1">
<Toggle
label="Куратор может отвечать на вопросы учеников"
checked={bool("curatorCanAnswerQuestions")}
onChange={(v) => set("curatorCanAnswerQuestions", v ? "true" : "false")}
/>
<Toggle
label="Куратор видит список всех студентов"
checked={bool("curatorCanSeeStudents")}
onChange={(v) => set("curatorCanSeeStudents", v ? "true" : "false")}
/>
</div>
</Section>
{/* ── 6. Вставка кода ── */}
<Section
title="Вставка кода"
hint="Произвольный HTML/JS — Яндекс.Метрика, Google Analytics, виджеты. Код добавляется на каждую страницу."
>
<Field label="Код в <head>" hint="Счётчики, пиксели, мета-теги">
<textarea
value={s.headCode}
onChange={(e) => set("headCode", e.target.value)}
rows={4}
placeholder={"<!-- Яндекс.Метрика -->\n<script>...</script>"}
style={{
...inputStyle,
resize: "vertical",
fontFamily: "var(--font-mono)",
fontSize: "0.8rem",
}}
{...focusHandlers}
/>
</Field>
<Field label="Код в <body>" hint="Чаты поддержки, виджеты">
<textarea
value={s.bodyCode}
onChange={(e) => set("bodyCode", e.target.value)}
rows={4}
placeholder={"<!-- JivoSite / Crisp / etc -->\n<script>...</script>"}
style={{
...inputStyle,
resize: "vertical",
fontFamily: "var(--font-mono)",
fontSize: "0.8rem",
}}
{...focusHandlers}
/>
</Field>
</Section>
{/* Bottom save button */}
<div className="flex justify-end pb-4">
<button
type="button"
onClick={handleSave}
disabled={pending}
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
style={{ opacity: pending ? 0.6 : 1 }}
>
<Save size={14} />
{pending ? "Сохранение..." : saved ? "Сохранено ✓" : "Сохранить настройки"}
</button>
</div>
</div>
);
}
+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);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/api/auth"];
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/api/auth", "/maintenance"];
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;