Ответы персонала в ветке комментариев никуда не уведомляли: ученик видел ответ, только если сам возвращался в урок. У тредов вопросов письмо есть, у комментариев не было — из-за этого ответ под уроком 6.3 «Холст» висел непрочитанным, пока его не продублировали руками. - sendCommentReplyEmail: тот же шаблон ДС-2, что у ответов в тредах, с текстом ответа внутри письма и кнопкой на урок. Свой лимит превью 4000 символов: ввод разрешает 10 000, а порог вопросов (2000) резал бы развёрнутые разборы. - commentReplyRecipient: правила «шлём / не шлём» вынесены чистой функцией и покрыты тестами — не шлём при выключенной настройке, не-staff, удалённом родителе, ответе самому себе и пустой почте. - notifyStudentOnCommentReply: тумблер в админке рядом с уведомлением о фидбеке, по умолчанию включён. - Удалён неподключённый дубль comment-actions.ts: компонент комментариев ходит в lib/actions/student-actions.ts, а в дубле жил свой лимит 2000 — правка в нём не давала эффекта. Проверено: type-check и lint чистые (3 ошибки линтера — существующий долг в quick-enroll-modal и kinescope-player), vitest 13/13, письмо отправлено боем и доставлено (Resend, last_event=delivered). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
95 lines
2.9 KiB
TypeScript
95 lines
2.9 KiB
TypeScript
import { cache } from "react";
|
|
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",
|
|
notifyOnComment: "true",
|
|
notifyOnRegistration: "true",
|
|
notifyStudentOnFeedback: "true",
|
|
notifyStudentOnCommentReply: "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",
|
|
|
|
// Logo
|
|
logoUrl: "",
|
|
showLogo: "true",
|
|
|
|
// Social networks
|
|
socialYoutube: "",
|
|
socialVk: "",
|
|
socialTelegram: "",
|
|
|
|
// Code injection
|
|
headCode: "",
|
|
bodyCode: "",
|
|
} as const;
|
|
|
|
export type SettingsKey = keyof typeof SETTINGS_DEFAULTS;
|
|
export type Settings = Record<SettingsKey, string>;
|
|
|
|
// ── Getters ───────────────────────────────────────────────────────────────────
|
|
|
|
export const getSettings = cache(async (): Promise<Settings> => {
|
|
try {
|
|
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;
|
|
} catch {
|
|
// DB unavailable at build time — return defaults
|
|
return { ...SETTINGS_DEFAULTS } as Settings;
|
|
}
|
|
});
|
|
|
|
export async function getSetting(key: SettingsKey): Promise<string> {
|
|
try {
|
|
const row = await prisma.settings.findUnique({ where: { key } });
|
|
return row?.value ?? SETTINGS_DEFAULTS[key];
|
|
} catch {
|
|
return 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);
|
|
}
|