Files
lms-sb/CLAUDE.md
T
adminsandClaude Opus 5 02443f0c5c docs(уклад): правила по уведомлениям и письмам
Выяснилось при выкате писем об ответах под уроками: отправка глотает ошибки,
уведомления висят на серверном действии (а не на таблице), в проекте были две
расходящиеся копии addComment, дефолты настроек живут в коде.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 12:57:53 +05:00

284 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```
---
## Команды
```bash
# Разработка
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`)
```env
# База данных
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 5987 `filename*`). Их читают 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` — нет ошибок ESLint
- [ ] `npm 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)
```