Author SHA1 Message Date
adminsandClaude Opus 5.5 e2311facf4 Vault: цена 2990 → 4990 ₽ с 20261001
На прод выкатывается таймером vault-price-4990 на Hoster.kz
(/root/price-switch-20261001/switch.py) в 00:00 МСК.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 16:10:06 +05:00
adminsandClaude Opus 5 30d3523aeb docs(CLAUDE): грабли Markdown при ответе студенту в комментариях
Комментарии к урокам рендерятся через react-markdown + remark-gfm, и
это меняет то, как надо писать сами ответы. Проверено на уроке
Zotero 2.3: четыре пункта строками через «—» в ответе от 21.09
склеились в один абзац (GFM не считает тире маркером списка), а
длинный адрес с параметрами запроса автолинк обрывает на «&».

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:11:10 +05:00
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
2 changed files with 14 additions and 1 deletions
+13
View File
@@ -197,6 +197,14 @@ S3_REGION="eu-central"
- **`src/proxy.ts` не существует** — вся защита маршрутов в `src/middleware.ts`, экспорт `middleware`. Не заводить второй файл. - **`src/proxy.ts` не существует** — вся защита маршрутов в `src/middleware.ts`, экспорт `middleware`. Не заводить второй файл.
- Новый API-роут, который авторизуется сам (Bearer-ключ, внутренний секрет), обязан попасть в `PUBLIC_ROUTES` — иначе middleware отдаст редирект на `/login` раньше роута, и в браузере с кукой это не воспроизведётся. Обратная сторона: сверка идёт по `startsWith`, поэтому запись `/api/pdf` уже открывает любой будущий `/api/pdf-*` — такой роут обязан проверять доступ внутри себя. - Новый 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`) ### Чистый 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 не мог списать квоту. - **Два разных значения `ToolUsage.tool`**: `clean-pdf-generate` (`PDF_TOOL_ID`) — только успешные генерации, по ним считаются месячный лимит и burst; `clean-pdf` — аналитика копирований из `CopyButton`. Не объединять: иначе каждое нажатие «Скопировать» списывает генерацию. `clean-pdf-generate` намеренно отсутствует в `TOOL_IDS`, чтобы публичный Server Action не мог списать квоту.
@@ -232,6 +240,11 @@ S3_REGION="eu-central"
`**жирный**` в отзыве на ДЗ студент увидит звёздочками. Ссылку там же писать голым адресом можно — `linkify` её поднимет. `**жирный**` в отзыве на ДЗ студент увидит звёздочками. Ссылку там же писать голым адресом можно — `linkify` её поднимет.
Когда сами пишем ответ студенту под уроком, две граблю рендера (проверено боем 20260922, урок Zotero 2.3):
- **Перечисление строками через «—» склеивается в один абзац.** GFM не считает тире маркером списка: четыре пункта подряд, каждый с новой строки, отрендерились одной сплошной строкой в ответе от 21.09. Список — только `- ` в начале строки.
- **Длинный адрес с `&` оборачивать в `<…>`.** Автолинк GFM на параметрах запроса обрывается; `<https://host/?a=1&b=2>` даёт кликабельную ссылку целиком. Подчёркивания внутри слова (`url_selector`) курсивом не становятся — их экранировать не нужно.
Безопасность рендера комментариев (не ослаблять «улучшением»): `rehype-sanitize` режет сырой HTML и `javascript:`; картинки с чужих доменов **не грузятся** — компонент `img` отдаёт их ссылкой, чтобы адрес читателя не утекал на сторонний сервер. Свои картинки — через `/api/student/comment-upload`. Безопасность рендера комментариев (не ослаблять «улучшением»): `rehype-sanitize` режет сырой HTML и `javascript:`; картинки с чужих доменов **не грузятся** — компонент `img` отдаёт их ссылкой, чтобы адрес читателя не утекал на сторонний сервер. Свои картинки — через `/api/student/comment-upload`.
### Файлы курсов живут на двух разных доменах ### Файлы курсов живут на двух разных доменах
+1 -1
View File
@@ -26,7 +26,7 @@ export const PAY_METHODS_URL = "https://obsidian.second-brain.ru/pay/methods";
export const STORE_CATALOG: Record<string, StoreCatalogEntry> = { export const STORE_CATALOG: Record<string, StoreCatalogEntry> = {
"second-brain-vault": { "second-brain-vault": {
landingUrl: "https://second-brain.ru/sb-vault", landingUrl: "https://second-brain.ru/sb-vault",
tariffs: [{ sku: "sb-vault", label: "Second Brain Vault", priceRub: 2990 }], tariffs: [{ sku: "sb-vault", label: "Second Brain Vault", priceRub: 4990 }],
}, },
"claude-obsidian": { "claude-obsidian": {
landingUrl: "https://second-brain.ru/claude-obsidian", landingUrl: "https://second-brain.ru/claude-obsidian",