PUT /api/subscribers/{id} with lists=[quiz] replaced all lists and attribs:
a Press reader taking the quiz dropped out of Press, buyers lost course and
updates_until, status "enabled" lifted blocklisting. Verified on Listmonk 6.2.
Now: add to the quiz list via PUT /api/subscribers/lists (action add), write
attribs with current lists and status and preconfirm_subscriptions=false,
leave blocklisted subscribers untouched, keep first-touch utm_*.
Bot protection: constant-time secret check, quizzes faster than 20 s are
dropped silently, global hourly cap and per-email cap, strict email check
(the address goes into a Listmonk SQL filter), result email at most once a
day per address. Quiz v2 fields: quiz_type, quiz_date, quiz_answers,
quiz_utm, optional result email through LMS (send_result). v1 bot payload
keeps working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Кнопки лендингов ведут на /register без меток, поэтому utmCampaign не был
записан ни у одного пользователя. Caddy кладёт строку запроса с utm_* в cookie
sb_utm на .second-brain.ru; страница регистрации берёт метки из адреса, а если
их нет — из cookie. Значения: [A-Za-z0-9._-], до 100 символов.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Куратор видел адрес в своём фидбеке мёртвым текстом — linkify там не было.
В письмах (фидбек, ответы на вопросы и комментарии) quote() только экранировал:
кликабельность зависела от почтовика. Теперь ссылки оформлены <a>.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Для бота выдачи SB Access (payment-router): старый покупатель Second Brain
Vault оставляет почту на /tg/link, ключ входа в бота выдаётся, только если
доступ к курсу действительно есть. Тот же общий секрет, что у grant;
ответ только да/нет, почта без учёта регистра, истёкший доступ не считается.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ответы персонала в ветке комментариев никуда не уведомляли: ученик видел
ответ, только если сам возвращался в урок. У тредов вопросов письмо есть,
у комментариев не было — из-за этого ответ под уроком 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>
GET /api/planner/start проверяет сессию и доступ к курсу planner,
подписывает короткоживущий токен (HMAC-SHA256, 15 минут) и отправляет
в planner.second-brain.ru. Токен уходит во фрагменте, а не в строке
запроса: так он не попадает ни в логи, ни в Referer.
Алгоритм подписи — зеркало packages/web/src/auth.mjs в репозитории
планнера; совместимость проверена.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1UnMjwLLs22j7rymn9vUq
The notification only said that the school had answered and linked into
the LMS, so a student had to log in to read two lines. Delivered mail
plus an unread thread was the common outcome — one such thread was a
pre-sale question that never converted.
The mail now carries the answer itself (capped at 2000 chars, with a
pointer to the thread when longer or when files are attached). Quoted
blocks are HTML-escaped, which they were not before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comments render CommonMark, which leaves a pasted https://… as dead text —
students share links constantly and nobody writes [text](url) by hand. Add
remark-gfm so plain URLs, www hosts and emails become links (and tables and
strikethrough come along), plus table styling so a wide table scrolls inside
itself instead of stretching the page.
Sanitizing is unchanged: javascript: is neither linked nor autolinked, raw
HTML is still dropped, and third-party images are still rendered as plain
links by the img component.
Рекламу по обучающим запросам (obsidian как пользоваться, obsidian заметки)
ведём на бесплатный вход в школу, а не на продажу: порог входа нулевой,
а из 19 бесплатных регистраций 6 дошли до покупки (32%).
Чтобы Google видел, какие клики дают студентов:
- User.gclid — идентификатор клика рядом с уже собираемыми UTM;
- gclid прокидывается из URL через page → форму → API (той же цепочкой);
- src/lib/ads-conversion.ts — серверная отправка конверсии 7719084914
(UPLOAD_CLICKS). Серверно, а не тегом: после регистрации сразу редирект
в кабинет, тег на странице «спасибо» не сработает — грабля из разбора
платежей 20260810.
Всё gated по env (GADS_*): нет кредов — тихо не отправляем, регистрацию
не роняем. Миграция идемпотентна: колонка заведена на проде вручную.
Windows reports an empty or generic MIME type for .md files, so the
type-first check rejected them with "Разрешены только jpg, png, pdf, md"
even though the extension was allowed — a client hit this while sending
us a vault note.
Extension is now the primary gate; MIME is validated only when the
browser actually sent a meaningful one. Generic types fall back to a
type derived from the extension so the file is served correctly later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Add an image button to the comment toolbar backed by a new upload route
(images only, 5 MB, stored under comments/<userId>/), inserted as Markdown.
Only images served from our own storage are rendered: a third-party src would
be fetched by every reader of the lesson, exposing their address. Such links
are shown as plain links instead — this also defuses the external images a few
existing comments already carry.
Add a formatting toolbar + safe Markdown rendering (react-markdown +
rehype-sanitize), raise the length limit 2000 -> 10000, and let authors
edit their own comments (new editComment action + editedAt column shown
as an 'изменено' badge).
Dashboard tiles and the curator CTA pointed to /curator/homework without
a status filter, so the default all-submissions list (newest first)
buried old PENDING items pages deep — 19 June submissions sat unseen
for 6 weeks while the top of the list showed freshly approved work.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
payment-router now forwards name/phone from the order. Store phone on
create; for existing users fill name (when it is an email placeholder)
and phone (when empty) without overwriting meaningful data. Also stop
dropping the parsed phone column in CSV import.
ZIP/DOCX etc. download natively — no need to force attachment; and the
157 MB Second Brain Vault archive must stream from Caddy with Range,
not through the app. Proxy attachment stays for small inline types (PDF/images).
Direct CDN links opened PDFs inline (no Content-Disposition), which reads
as 'can't download' behind popup blockers / on mobile. New proxy route
streams the file with attachment + human name; lesson link points to it.
regenerate() silently did nothing on {ok:false} or a rejected server
action, risking an unhandled promise rejection and leaving the student
staring at a stuck "меняем…" button with no feedback. Wrap the action
call in try/catch and show an inline error message on failure.
Adds GET /api/pdf: resolves the caller via a Bearer sbpdf_ API key
(PdfApiKey table) or a Better Auth session cookie, then gates on paid
course access, burst/monthly usage limits, generates the PDF via
generateCleanPdf, records a ToolUsage row, and streams the file with
X-Uses-Count/X-Max-Uses headers. Errors map to 401/403/422/429/504
JSON responses. Whitelists /api/pdf in middleware PUBLIC_ROUTES so the
route can perform its own auth instead of being redirected to /login.
Second-brain scheme (logo + six cards with rays) reworked from the old
platform's infographic. Root stays role-redirecting for signed-in users;
middleware opens "/" as an exact match only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Apply linkify() to student submission text (curator review page + student
cabinet: reviewed/approved/pending) and to curator feedback text, so pasted
links (e.g. Yandex.Disk) render as clickable anchors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Toolbox is still being refined; hide it from clients on prod while keeping it
usable on staging. Entry points (header link, dashboard card) render only when
TOOLBOX_VISIBLE=true; a tools/layout redirects /tools/* to /dashboard otherwise.
Routes and ToolUsage table are untouched (no destructive migration).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rendering <head dangerouslySetInnerHTML={headCode}> in the App Router root
layout let the manual <head> overwrite the React-hoisted <link rel=stylesheet>
during hydration — all Tailwind styles dropped for real users once JS ran
(headless snapshots taken before hydration looked fine). headCode held the
Yandex.Metrika snippet. Replace with HtmlInjector client component that appends
the code through the DOM post-hydration, recreating <script> nodes so they run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Curator list labeled APPROVED-without-feedback submissions as 'Без ответа'
(badge keyed on feedbacks only). Key it on status: Принято / С отзывом /
На рассмотрении / Отклонено / Без ответа. Dashboards already count by status.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two more inside-LMS generators under /tools, completing the 6-tool set:
- Dataview: DQL builder (TABLE/LIST/TASK/CALENDAR, FROM/WHERE/SORT/LIMIT),
output wrapped in a dataview code block
- Bases: .base YAML builder (views, filters and:, order, sort, groupBy, limit);
filter expressions emitted as single-quoted YAML scalars for safety
Syntax verified against official Obsidian docs (research workflow). Audit fix:
Bases sort entries use 'column:' (not 'property:') per documented working .base
files; 'property:' is kept only for groupBy. 14 new tests (47 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inside-LMS toolbox for registered students: callout-CSS, YAML-frontmatter,
theme CSS-variables, Style Settings generators. Auth inherited from (student)
route group; no public surface, no email gate.
- Pure generators in src/lib/tools/* with Vitest (33 tests)
- Shared YAML-safe scalar quoter (_shared/yaml.ts) used by frontmatter +
style-settings: unquoted user input was silently breaking YAML (tags '#x'
-> null, color default '#7C3AED' -> null, 'a: b' -> parse error)
- hex validation in callout (no NaN), date input constrained, clipboard +
analytics calls guarded
- Prisma ToolUsage model + migration; logToolUsage Server Action (auth-first,
10-min dedup window per user/tool)
- Tool index /tools + ToolCard (explicit lucide map, no import *) + header link
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- window.ym reachGoal 'signup_free' on registration success
- GateViewTracker client component fires 'gate_view' on tripwire gate mount
- counter id via NEXT_PUBLIC_METRIKA_COUNTER_ID (build-time)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/internal/quiz-lead accepts archetype quiz results and
idempotently upserts subscriber in Listmonk (create on 201, PUT on 409).
Documents QUIZ_LEAD_SECRET and LISTMONK_* vars in .env.example.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Attachments uploaded via question-upload get a CDN URL
(files.second-brain.ru via S3_CDN_URL), but the validator only accepted
the direct S3 endpoint prefix — so every attachment was silently dropped
(message text saved, file lost). Add isAllowedPublicUrl (CDN + direct S3)
in lib/s3 and use it in both question routes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Curators/admins can mark a submission APPROVED (and complete the lesson)
without writing feedback. The student sees 'ДЗ принято ✓' and can no
longer resubmit. No email is sent on silent approval.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The mustChangePassword guard redirected an impersonating admin to
/change-password, which sits outside the student layout (no
return-to-admin banner) — a dead end. Skip the guard when impersonating,
and add the stop-impersonate banner to the change-password page as a
fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lessons with homework/quiz have no manual complete button — they
auto-complete on submission. Before submission nothing was shown,
leaving students unsure how to progress. Add a muted hint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lesson files were served inline (no Content-Disposition) → browsers
opened PDFs in a new tab instead of downloading; flaky across
browsers/providers. Set Content-Disposition: attachment with the
human-readable filename (RFC 5987 for Cyrillic) on upload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- /api/internal/grant: secret-auth endpoint for payment-router to
provision access on a paid order — find-or-create user (emailVerified,
temp password, mustChangePassword), enroll by course slug list,
AccessLog, delivery email. Idempotent by order_id.
- User.mustChangePassword flag (+ migration); (student) layout redirects
flagged users to /change-password (forced first-login change).
- email.ts: sendCourseGrantEmail (temp password for new buyers).
- middleware: /api/internal is public (own secret auth).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Better Auth does not auto-resend on 403 (verified against Resend logs),
so request a fresh link via authClient.sendVerificationEmail before
telling the user a new email was sent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
requireEmailVerification was enabled but no sendVerificationEmail was
configured — new users saw "check your email", never received anything,
and couldn't log in (403 shown as "wrong email or password").
- email.ts: add verification email template; welcome email no longer
claims the account is "confirmed"
- auth.ts: configure emailVerification (send on signup, resend on
unverified login attempt, auto sign-in after verification, 24h TTL)
- register route: callbackURL=/dashboard so the verify link lands in
the cabinet
- login form: distinguish 403 (unverified — tell user a fresh link was
sent) and 429 (rate limit) from wrong credentials
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Backend: explicitly return 409 EMAIL_TAKEN when the email already
exists, instead of Better Auth's silent 200 (common after migration).
- Frontend: split error handling — network failure, non-JSON body
(502/proxy), and meaningful server errors get distinct messages; the
"email already registered" case shows login / reset-password links
instead of a generic "connection error".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract ImpersonateButton into a shared component and reuse it on the
admin user profile page (header, hidden for admins) — same behaviour as
the users list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Quick-enroll modal now takes access duration in days (0/empty = unlimited)
and shows the computed expiry date, instead of a manual date picker.
- Dashboard upsell no longer shows the "Всё включено" bundle of a course
the student already owns in its base edition (obsidian/zotero).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>