qwane

образование · CRM · 2026

Виктори — CRM репетиторской школы с журналом занятий

CRM для репетиторской школы: владелец, несколько преподавателей и пять десятков учеников. Ядро продукта — журнал занятия, который заполняется за две минуты: присутствие, домашка и пять навыков по шкале 1–10. Кабинет преподавателя живёт на ноутбуке, кабинет ученика — на телефоне. Паролей в продукте нет вообще.

Посмотреть вживую

Моя роль соло: продукт, дизайн, фронт, бэк, инфра

Виктори — CRM репетиторской школы с журналом занятий — обложка кейса
13 экранов · в двух ширинах
3 роли владелец · преподаватель · ученик
0 паролей вход по одноразовой ссылке
1 схема zod → openapi → хуки фронта
5 навыков шкала 1–10 в каждом журнале

задача: журнал, который заполняется за две минуты

школа держала успеваемость там же, где её держат почти все: расписание в одной таблице, оценки в другой, домашка в переписке. вопрос «кому из учеников сейчас нужно внимание» отвечался вручную и поэтому не отвечался никогда.

продукт собран вокруг одного экрана. журнал занятия — единственное место в системе, где что-то записывается; все остальные экраны только читают. преподаватель отмечает присутствие, статус домашки и пять навыков по шкале от 1 до 10, и на этом его работа с системой заканчивается. если заполнение занимает больше двух минут, журнал не будут вести, а без журнала не работает всё остальное.

шкала и словарь навыков общие на всю школу, и правит их только владелец. дай каждому преподавателю завести свои критерии — оценки перестанут сравниваться между группами, а вместе с ними развалится вся аналитика.

список внимания, который считает система

«сегодня» — стартовый экран преподавателя. справа колонка «кому нужно внимание»: три пропуска подряд, две несданные домашки, навык упал с 7 до 4, пробник ниже группы на 12 баллов. этот список никто не ведёт руками, он считается из журналов, и порядок в нём по тому, что раньше повлияет на результат.

в этом и разница между системой учёта и рабочим инструментом. учёт складывает цифры, инструмент говорит, с кем разговаривать сегодня.

балл при этом никогда не показывается без словесного пояснения: 8 — это «уверенно, решает сам, теряет на оформлении», а не просто восьмёрка. без такого правила шкала расползается, и «семь» одного преподавателя перестаёт значить то же, что «семь» другого.

паролей в продукте не существует

регистрации нет ни у кого. аккаунт всегда выдаёт другой человек: кто пригласил, тот и удостоверил личность. это осознанный размен — школа маленькая, и форма регистрации дала бы здесь только новый способ завести лишнего человека.

внутри две разные сущности, и путать их нельзя:

  • приглашение заводит нового человека и живёт две недели;
  • ссылка для входа пускает уже заведённого и живёт сутки.

вторая нужна ровно тогда, когда ученик сменил телефон или почистил браузер: приглашение к тому моменту уже потрачено. преподаватель выпускает новую ссылку из карточки ученика за минуту.

обе одноразовые, и в базе лежит только sha256-хеш — по той же причине, по которой не хранят пароли открытым текстом: утёкший дамп не должен давать вход. сессия — непрозрачный токен в httponly-куке на три месяца со скользящим продлением, а не jwt: jwt нельзя отозвать до истечения, а доступ ушедшего преподавателя должен гаснуть в ту же секунду. при этом отзыв гасит вход, но не удаляет человека — иначе журналы потеряют авторство, и через полгода будет непонятно, кто ставил оценки.

роль режет видимость, а не функции

guard по роли защищает от того, что ученик дёрнет учительский маршрут. он не защищает от того, что преподаватель увидит чужие группы, — а вот это и есть настоящая утечка в такой системе.

поэтому фильтр вынесен в слой данных: ни один запрос к группам не собирается мимо него, а забытый scope закрыт тестами. тот же класс ошибок в партнёрской платформе закрывался изоляцией по tenant_id; здесь арендатор один и границу проводит роль, но правило то же — данные режутся там, где собирается запрос, а не там, где рисуется экран.

один контракт на фронт и бэк

схема живёт в одном месте, на zod: packages/sharedcreateZodDto для валидации и 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 внутрь приложения — и первый же выкат унесёт файлы школы.

рядом с продуктом в разработке живёт демо-школа: три группы, семнадцать учеников, журналы за два месяца. данные в ней подобраны, а не насыпаны случайно — каждая причина попадания в список внимания представлена ровно одним учеником. при случайном разбросе навык у кого-нибудь просядет на три пункта сам собой, список заполнится шумом, и станет непонятно, работает правило или совпало. скриншоты в этом кейсе сняты на ней.

Хочешь похожий продукт?

Расскажи, что строишь — отвечу в течение дня.

Обсудить проект