образование · CRM · 2026
Виктори — CRM репетиторской школы с журналом занятий
CRM для репетиторской школы: владелец, несколько преподавателей и пять десятков учеников. Ядро продукта — журнал занятия, который заполняется за две минуты: присутствие, домашка и пять навыков по шкале 1–10. Кабинет преподавателя живёт на ноутбуке, кабинет ученика — на телефоне. Паролей в продукте нет вообще.
Моя роль соло: продукт, дизайн, фронт, бэк, инфра
задача: журнал, который заполняется за две минуты
школа держала успеваемость там же, где её держат почти все: расписание в одной таблице, оценки в другой, домашка в переписке. вопрос «кому из учеников сейчас нужно внимание» отвечался вручную и поэтому не отвечался никогда.
продукт собран вокруг одного экрана. журнал занятия — единственное место в системе, где что-то записывается; все остальные экраны только читают. преподаватель отмечает присутствие, статус домашки и пять навыков по шкале от 1 до 10, и на этом его работа с системой заканчивается. если заполнение занимает больше двух минут, журнал не будут вести, а без журнала не работает всё остальное.
шкала и словарь навыков общие на всю школу, и правит их только владелец. дай каждому преподавателю завести свои критерии — оценки перестанут сравниваться между группами, а вместе с ними развалится вся аналитика.
список внимания, который считает система
«сегодня» — стартовый экран преподавателя. справа колонка «кому нужно внимание»: три пропуска подряд, две несданные домашки, навык упал с 7 до 4, пробник ниже группы на 12 баллов. этот список никто не ведёт руками, он считается из журналов, и порядок в нём по тому, что раньше повлияет на результат.
в этом и разница между системой учёта и рабочим инструментом. учёт складывает цифры, инструмент говорит, с кем разговаривать сегодня.
балл при этом никогда не показывается без словесного пояснения: 8 — это «уверенно, решает сам, теряет на оформлении», а не просто восьмёрка. без такого правила шкала расползается, и «семь» одного преподавателя перестаёт значить то же, что «семь» другого.
паролей в продукте не существует
регистрации нет ни у кого. аккаунт всегда выдаёт другой человек: кто пригласил, тот и удостоверил личность. это осознанный размен — школа маленькая, и форма регистрации дала бы здесь только новый способ завести лишнего человека.
внутри две разные сущности, и путать их нельзя:
- приглашение заводит нового человека и живёт две недели;
- ссылка для входа пускает уже заведённого и живёт сутки.
вторая нужна ровно тогда, когда ученик сменил телефон или почистил браузер: приглашение к тому моменту уже потрачено. преподаватель выпускает новую ссылку из карточки ученика за минуту.
обе одноразовые, и в базе лежит только sha256-хеш — по той же причине, по которой не хранят пароли открытым текстом: утёкший дамп не должен давать вход. сессия — непрозрачный токен в httponly-куке на три месяца со скользящим продлением, а не jwt: jwt нельзя отозвать до истечения, а доступ ушедшего преподавателя должен гаснуть в ту же секунду. при этом отзыв гасит вход, но не удаляет человека — иначе журналы потеряют авторство, и через полгода будет непонятно, кто ставил оценки.
роль режет видимость, а не функции
guard по роли защищает от того, что ученик дёрнет учительский маршрут. он не защищает от того, что преподаватель увидит чужие группы, — а вот это и есть настоящая утечка в такой системе.
поэтому фильтр вынесен в слой данных: ни один запрос к группам не собирается мимо него,
а забытый scope закрыт тестами. тот же класс ошибок в
партнёрской платформе закрывался изоляцией по tenant_id; здесь
арендатор один и границу проводит роль, но правило то же — данные режутся там, где
собирается запрос, а не там, где рисуется экран.
один контракт на фронт и бэк
схема живёт в одном месте, на zod: packages/shared → createZodDto для валидации и
swagger → openapi.json → сгенерированные хуки tanstack query. руками openapi не
правится, а ci проверяет, что сгенерированное не разошлось с исходным.
смысл в одной фразе: переименовал поле в схеме — фронт перестал компилироваться ровно в тех местах, где это поле используется. коммит с новым полем в api и старым клиентом сборку не пройдёт.
граблей на этом пути хватило. zod 4 описывает .nullable() как type: [T, "null"], и
на такой записи swagger молча превращает nullable-строку в массив строк: контракт врёт,
а всё зелёное. запрет вынесен в сборку — .nullable() в схемах роняет билд, вместо
него пустое значение передаётся отсутствием поля.
два кабинета, две ширины, один набор правил
тринадцать экранов, и каждый рисовался сразу в двух ширинах: 1440 для преподавателя и 390 для ученика. мобильная версия здесь не сжатая десктопная. восемь колонок журнала на 390 px означают, что имя ученика и пятый навык никогда не видны одновременно, поэтому на телефоне журнал собирается карточками — по карточке на ученика, с тем же поповером балла.
ученик почти всегда с телефона, и его кабинет спроектирован от 390 px вверх: главное действие в нижней трети, цель нажатия не меньше 44 px. видит он ровно три вещи — когда следующее занятие, что задано и как идут его навыки относительно группы. отметить, что сдал домашку, — единственное, что ученик может записать; перевести её в «принято» или «переделать» может только журнал.
цвет, типографика, радиусы и тайминги лежат в одном файле токенов, который фронт импортирует напрямую. копии в приложении нет намеренно: копия разъедется на первой же правке. как такие кабинеты устроены изнутри — в личном кабинете для клиентов и в разборе экранов личного кабинета.
почему здесь нет мультитенантности
одна школа — одно развёртывание. второй заказчик получает свою копию со своей базой, и это не упрощение по лени: пока клиентов единицы, отдельная база дешевле и безопаснее общей, а весь класс ошибок «клиент увидел чужие данные» просто не возникает. мультитенантность — правильное решение для платформы с сотней арендаторов, и там я её и делал; здесь она была бы платой без покупки.
по той же логике это spa, а не next: публичных страниц в продукте нет ни одной, всё за входом по ссылке, рендерить на сервере нечего. next поверх отдельного бэка на nest дал бы второй node-процесс и вторую модель авторизации без единого выигрыша. по ссылке «смотреть вживую» видно ровно это: экран входа и больше ничего — внутрь пускает приглашение, а не адрес.
развилка «своё или коробка» для такой школы разобрана отдельно — своя система против коробки; из чего складывается цена системы такого класса, видно в смете по модулям.
что стоит за выкатом
пуш в main → github actions собирает два образа в ghcr → заходит на вм по ssh →
docker compose pull && up -d. миграции накатывает контейнер api при старте, руками на
сервере не запускается ничего.
постоянного токена реестра на машине нет: в ghcr логинится сам прогон выката своим одноразовым токеном. утёкший с вм долгоживущий ключ открывает весь аккаунт, и это не та экономия, ради которой стоит рисковать.
база, загруженные материалы и сертификаты лежат вне каталога деплоя. правило звучит
скучно ровно до первого раза: положи uploads внутрь приложения — и первый же выкат
унесёт файлы школы.
рядом с продуктом в разработке живёт демо-школа: три группы, семнадцать учеников, журналы за два месяца. данные в ней подобраны, а не насыпаны случайно — каждая причина попадания в список внимания представлена ровно одним учеником. при случайном разбросе навык у кого-нибудь просядет на три пункта сам собой, список заполнится шумом, и станет непонятно, работает правило или совпало. скриншоты в этом кейсе сняты на ней.