Выяснилось при выкате писем об ответах под уроками: отправка глотает ошибки, уведомления висят на серверном действии (а не на таблице), в проекте были две расходящиеся копии addComment, дефолты настроек живут в коде. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
23 KiB
CLAUDE.md — LMS Second Brain
Читай AGENTS.md перед тем как писать любой Next.js код — версия отличается от обучающих данных.
Стек и версии
| Технология | Версия | Назначение |
|---|---|---|
| Next.js | 16.2.2 (App Router) | Full-stack фреймворк |
| Node.js | 20 LTS | Runtime |
| TypeScript | 5.x | Язык |
| React | 19 | UI |
| PostgreSQL | 16 | База данных |
| Prisma | 7.x | ORM + миграции |
| Better Auth | latest | Аутентификация и сессии |
| Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) |
| shadcn/ui | latest | UI-компоненты |
| TipTap | 2.x | WYSIWYG-редактор уроков |
| @kinescope/react-kinescope-player | latest | Видеоплеер |
| Resend | latest | Email-уведомления |
| AWS SDK (S3) | 3.x | Backblaze B2 (раздача через Bunny CDN) |
| Zod | 3.x | Валидация данных |
| Docker Compose | 2.x | Локальная разработка и деплой |
Важно по Tailwind v4: конфиг — только в CSS через @import "tailwindcss" и @theme. Нет tailwind.config.ts. Кастомизация — через CSS переменные в globals.css.
Важно по Better Auth: не NextAuth. Сессии — cookie-based. Роли через плагин admin. Подробнее: https://www.better-auth.com/docs
Структура каталогов
lms-system/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # Вход, регистрация, подтверждение email
│ │ │ ├── login/
│ │ │ ├── register/
│ │ │ └── verify-email/
│ │ ├── (student)/ # Личный кабинет ученика
│ │ │ ├── dashboard/
│ │ │ ├── courses/[slug]/
│ │ │ └── courses/[slug]/lessons/[lessonId]/
│ │ ├── curator/ # Панель куратора
│ │ │ ├── dashboard/
│ │ │ └── homework/
│ │ ├── admin/ # Панель администратора
│ │ │ ├── courses/
│ │ │ ├── users/
│ │ │ └── settings/
│ │ └── api/ # API-маршруты
│ │ ├── auth/ # Better Auth handler
│ │ ├── courses/
│ │ ├── lessons/
│ │ ├── progress/
│ │ ├── homework/
│ │ └── upload/
│ ├── components/
│ │ ├── ui/ # shadcn/ui (не редактировать вручную)
│ │ ├── editor/ # TipTap WYSIWYG
│ │ ├── player/ # Kinescope Player wrapper
│ │ ├── course/ # Компоненты курса
│ │ └── layout/ # Header, Sidebar, Footer
│ ├── lib/
│ │ ├── auth.ts # Better Auth config (сервер)
│ │ ├── auth-client.ts # Better Auth client (браузер)
│ │ ├── prisma.ts # Prisma singleton client
│ │ ├── s3.ts # Backblaze B2 (S3-совместимый) клиент
│ │ ├── email.ts # Resend email helpers
│ │ └── utils.ts # cn() и прочие утилиты
│ ├── types/
│ │ └── index.ts # Общие TypeScript-типы
│ └── middleware.ts # Auth middleware (защита маршрутов)
├── prisma/
│ ├── schema.prisma # Схема БД
│ ├── seed.ts # Seed-скрипт
│ └── migrations/ # НИКОГДА не редактировать вручную
├── public/
│ └── images/
├── docker-compose.yml # Локальная разработка
├── docker-compose.prod.yml # Production
├── Dockerfile
├── .env.example # Шаблон переменных (без секретов)
├── .env.local # Локальные секреты (в .gitignore)
├── CLAUDE.md
├── ROADMAP.md
└── PROJECT_BRIEF.md
Команды
# Разработка
npm run dev # Запустить dev-сервер (localhost:3000)
docker compose up -d # Поднять PostgreSQL локально
# Сборка и проверка
npm run build # Production build
npm run lint # ESLint
npm run type-check # TypeScript без сборки (tsc --noEmit)
# База данных (Prisma)
npx prisma migrate dev --name <название> # Создать и применить миграцию
npx prisma migrate deploy # Применить миграции в production
npx prisma studio # GUI для просмотра БД
npx prisma generate # Пересоздать клиент после изменений schema
npx prisma db seed # Запустить seed-скрипт
# Docker (production)
docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml logs -f app
Правила работы
Деплой и окружение
- Production-домен: school.second-brain.ru
- Git-сервер: Gitea — https://git.second-brain.ru/admins/lms-sb (токен: в
.env.localне хранить, спросить у пользователя) - После завершения каждого этапа из ROADMAP.md — делать
git pushв Gitea
Коммиты
- Один коммит = одна логически завершённая единица работы (маршрут, компонент, миграция)
- Сообщения коммитов — на английском, в повелительном наклонении:
Add lesson progress tracking,Fix auth redirect - Перед коммитом всегда запускать
npm run lint && npm run type-check
Миграции базы данных
- НИКОГДА не редактировать файлы в
prisma/migrations/вручную - ВСЕГДА спрашивать пользователя перед созданием миграции, меняющей или удаляющей существующие поля
- Называть миграции по-английски, snake_case:
add_lesson_progress,add_user_roles - Перед
prisma migrate deployна production — делать бэкап БД
Файлы и контент
- Загружаемые файлы (ДЗ, PDF, картинки комментариев) — только в Backblaze B2, никогда на диск VPS
- Секреты (API-ключи, токены, строки подключения) — только в
.env.local, в коде запрещено .env.exampleвсегда обновлять при добавлении новых переменных (без реальных значений)
Код
- UI-строки (заголовки, кнопки, сообщения) — на русском
- Имена переменных, функций, файлов, комментарии в коде — на английском
- Компоненты shadcn/ui — добавлять через
npx shadcn@latest add <component>, не копировать вручную - Не добавлять абстракции "на будущее" — только то, что нужно для текущего этапа
- Server Actions — использовать для форм и мутаций (auth, прогресс, ДЗ)
Маршруты и роли
- Защита маршрутов — через
middleware.ts(Better Auth), не в каждом компоненте отдельно - Роли:
student,curator,admin— проверять черезauth()илиauthClient.useSession() - Admin-маршруты (
/admin/*) доступны только ролиadmin - Curator-маршруты (
/curator/*) доступны ролямcuratorиadmin
Переменные окружения (.env.example)
# База данных
DATABASE_URL="postgresql://lms_user:password@localhost:5432/lms_db"
# Better Auth
BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32"
BETTER_AUTH_URL="http://localhost:3000"
# Email (Resend)
RESEND_API_KEY=""
EMAIL_FROM="noreply@school.second-brain.ru"
# Backblaze B2 (S3-совместимый), раздача через Bunny CDN
S3_ENDPOINT="https://s3.eu-central-003.backblazeb2.com"
S3_CDN_URL="https://files.second-brain.ru"
S3_BUCKET="lms-uploads"
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
S3_REGION="eu-central"
# Kinescope (добавить при получении платного плана)
# KINESCOPE_API_KEY=""
Грабли уклада (проверено 20260912)
Неочевидные соглашения, уже действующие в коде. Сломать их легко «улучшением».
Тесты
vitest.config.tsищет тесты только поsrc/lib/**/__tests__/**/*.test.ts. Тест рядом с исходником или вsrc/app/**молча не подбирается — зелёныйnpm testне значит, что твой тест выполнился. Проверяй, что файл попал в прогон.- Интеграционный тест генерации PDF выключен по умолчанию:
describe.runIf(process.env.RUN_PDF_INTEGRATION === "1")+ нужен живойBROWSER_WS_URL. Гейт не снимать — тест ходит в интернет и в browserless. - Решения выносим в чистую функцию с внедряемым IO, обёртка с IO остаётся тонкой (
decidePaidAccessрядом сhasPaidAccess;assertPublicUrl(raw, resolve)). Prisma и dns в тестах не мокаем.
Маршруты
src/proxy.tsне существует — вся защита маршрутов вsrc/middleware.ts, экспортmiddleware. Не заводить второй файл.- Новый API-роут, который авторизуется сам (Bearer-ключ, внутренний секрет), обязан попасть в
PUBLIC_ROUTES— иначе middleware отдаст редирект на/loginраньше роута, и в браузере с кукой это не воспроизведётся. Обратная сторона: сверка идёт поstartsWith, поэтому запись/api/pdfуже открывает любой будущий/api/pdf-*— такой роут обязан проверять доступ внутри себя.
Уведомления и письма
- Отправка глотает ошибки. Каждый
send*Emailзаканчивается.catch((e) => console.error(...)), поэтому «серверное действие выполнилось» ничего не говорит о письме. Проверять боем только по Resend:GET https://api.resend.com/emails?limit=…и полеlast_event(delivered/bounced). Ключ —~/.config/secrets/resend.env. - Уведомления висят на серверном действии, а не на таблице. Строка, вставленная в базу напрямую (
INSERT INTO "LessonComment" …), писем не шлёт — это удобно, чтобы подготовить сцену, но проверять нужно через интерфейс. При автоматизации браузером легко промахнуться мимо формы ответа и попасть в корневое поле: тогдаparentIdпустой, код-путь не вызывается и письма правомерно нет. Помечать поля до клика «Ответить» и братьtextarea:not([data-pre]). - Перед правкой серверного действия проверить, кто его импортирует. В проекте жили две расходящиеся копии
addComment/deleteComment/editComment: живая вsrc/lib/actions/student-actions.ts(лимит 10 000) и мёртвая вsrc/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions.ts(лимит 2000, никем не импортировалась, удалена 20260915). Правка в неподключённой копии выглядит применённой и не даёт эффекта. - Дефолты настроек живут в коде. В прод-таблице
Settingsстрокnotify*нет вообще — значения берутся изSETTINGS_DEFAULTS(src/lib/settings.ts), строка появляется только при первом сохранении формы в админке. Новый ключ с дефолтом"true"включается на проде сам, без миграции и без действий в интерфейсе. - Лимиты превью в письмах разные по смыслу:
REPLY_PREVIEW_LIMIT = 2000у тредов вопросов,COMMENT_REPLY_PREVIEW_LIMIT = 4000у ответов под уроками (ввод разрешает 10 000, разборы под уроком длиннее реплик в треде). Не сводить в одну константу.
Чистый PDF (/tools/clean-pdf, /api/pdf)
- Два разных значения
ToolUsage.tool:clean-pdf-generate(PDF_TOOL_ID) — только успешные генерации, по ним считаются месячный лимит и burst;clean-pdf— аналитика копирований изCopyButton. Не объединять: иначе каждое нажатие «Скопировать» списывает генерацию.clean-pdf-generateнамеренно отсутствует вTOOL_IDS, чтобы публичный Server Action не мог списать квоту. - Квота списывается только после успешной генерации, месячное окно — от начала месяца по UTC (
monthStartUtc). Рефакторинг «сначала резервируем квоту, потом рендерим» начнёт списывать за каждую неоткрывшуюся страницу. page.pdf()в playwright-core не принимаетtimeout— оборачивать своимwithTimeout(Promise.race), иначе зависшая печать держит слотCONCURRENTу browserless.context.route("**/*")не видит WebSocket — нужен отдельныйcontext.routeWebSocket. Два похожих блока подряд — не дублирование, это вторая линия SSRF-обороны; удалять только осознанно.isPrivateAddress(ip)— fail-closed гейт, а не предикат: на всё, что не разбирается как IP, возвращаетtrue. Не переиспользовать как «адрес из приватного диапазона».- В
resolveUserIdветка Bearer терминальная: если заголовокAuthorizationесть, фолбэка на сессию нет ни при каком исходе. Не добавлять «если ключ не подошёл — попробуем куку»: запрос с чужим ключом из залогиненного браузера начнёт выполняться от имени владельца сессии. - Заголовки ответа — публичный контракт с уже розданными ключами:
X-Uses-Count,X-Max-Usesи двойнойContent-Disposition(ASCII-фолбэк + RFC 5987filename*). Их читают Zotero-скрипт и форма на сайте. - Карта ошибок:
BlockedUrlError/EmptyContentError→ 422,RenderError→ 504, остальное → 504, никогда 500. Текст{error}показывается студенту дословно — только по-русски, без внутренних деталей. - HTML от Defuddle санируется собственным проходом в
template.prepareContent(JSDOM:script/style/iframe/object/embed, всеon*,javascript:) — намеренно не черезrehype-sanitize, который заточен под Markdown-пайплайн комментариев. zotero-script.tsгенерирует JS шаблонной строкой: учетверённые бэкслеши (\\\\в исходнике = один\в выданном скрипте). Ошибка экранирования не падает, а тихо ломает регулярки в выданном студенту скрипте — правил строку, прогони тест на валидность (new Function(script)).
Env
NEXT_PUBLIC_*инлайнятся на сборке.NEXT_PUBLIC_APP_URLзадан только как runtime-env вdocker-compose.prod.yml, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с прод-адресом. Правишь — передавай через--build-arg, какNEXT_PUBLIC_TURNSTILE_SITE_KEY.
Тариф — это отдельный курс со своей копией уроков
obsidian и obsidian-full (как и zotero / zotero-full) — две разные записи Course, у каждой свой полный комплект Module и Lesson. Это не представления одного курса.
- Правка контента применяется в обе копии. Починил текст в «Всё включено» — в базовом тарифе он остался прежним. Копии уже расходились в проде: блок с благодарностью автору был только в дешёвом тарифе.
- Комментарии сыплются под уроки обеих копий. Ответ под уроком
zotero-fullстудентыzoteroне увидят; выборка «неотвеченные» должна идти по всем курсам сразу. - Перед массовой правкой —
SELECTпо обоим slug, потомUPDATE ... WHERE id IN (...).
Markdown рендерится не везде
| Где | Что рендерится |
|---|---|
Комментарии к урокам (lesson-comments.tsx) |
Markdown + GFM: разметка, таблицы, голый адрес становится ссылкой |
Отзыв куратора на ДЗ (homework-section.tsx) |
простой текст (whitespace-pre-wrap + linkify) |
Раздел вопросов (QuestionThread.tsx, QuestionSplitView.tsx) |
простой текст (whitespace-pre-wrap + linkify) |
**жирный** в отзыве на ДЗ студент увидит звёздочками. Ссылку там же писать голым адресом можно — linkify её поднимет.
Безопасность рендера комментариев (не ослаблять «улучшением»): rehype-sanitize режет сырой HTML и javascript:; картинки с чужих доменов не грузятся — компонент img отдаёт их ссылкой, чтобы адрес читателя не утекал на сторонний сервер. Свои картинки — через /api/student/comment-upload.
Файлы курсов живут на двух разных доменах
files.second-brain.ru— Bunny CDN → Backblaze B2. Вложения уроков, ДЗ, картинки комментариев. Кеш короткий, правка «на месте» доезжает за час.filez.second-brain.ru— Selectel CDN → origin на Hoster.kz/root/filez-static/. Бонусы, архивы, шаблоны и CSS курсов. ⚠️ Кеш до 30 дней, query-параметры игнорируются: чтобы обновление дошло сразу, публиковать под новым путём или именем, а не?v=2.
Домены различаются одной буквой — легко перепутать при диагностике.
Чек-лист перед каждым коммитом
npm run lint— нет ошибок ESLintnpm run type-check— нет ошибок TypeScript- Новые
.envпеременные добавлены в.env.example(без значений) - Миграция БД одобрена пользователем (если есть)
- Нет
console.logв production-коде (толькоconsole.errorдля реальных ошибок) - Нет захардкоженных секретов, URL-ов, ID
Модель данных (основные сущности)
User (id, email, name, role, emailVerified) — управляется Better Auth
Session (id, userId, expiresAt, ...) — управляется Better Auth
Course (id, slug, title, description, coverImage, published)
Module (id, courseId, title, order)
Lesson (id, moduleId, title, order, content, kinescopeId, published)
CourseEnrollment (userId, courseId, enrolledAt)
LessonProgress (userId, lessonId, completedAt)
Quiz (id, lessonId)
QuizQuestion (id, quizId, text, type)
QuizOption (id, questionId, text, isCorrect)
QuizAttempt (id, userId, quizId, score, completedAt)
Homework (id, lessonId, description)
HomeworkSubmission (id, homeworkId, userId, text, files[], submittedAt)
HomeworkFeedback (id, submissionId, curatorId, text, createdAt)
LessonComment (id, lessonId, userId, text, createdAt)