MVP Spec 08 — Buyer Onboarding & Profile
Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев
MVP Spec 08 — Buyer Onboarding & Profile
Паспорт документа
- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
Version: MVP · Priority: P0 · Phase: B (Demand) Status: Draft v1 Sync note, 28 Mar 2026:
- live API prefix is
/api/v1/, not/api/;- implemented now:
POST /api/v1/auth/register/buyer,POST /api/v1/auth/add-buyer-role,GET/POST/PATCH /api/v1/me/profile,GET/POST/PATCH/DELETE /api/v1/me/children,PATCH /api/v1/me/interests;- frontend flow inside
qadam-webещё не полностью синхронизирован с этим backend-контрактом;- route ideas вроде
/register/add-buyer-roleнужно считать UX-эскизом, а не текущим production route contract.
1. Контекст и цель
Buyer Onboarding — регистрация и управление профилем покупателя. Байер на Qadam — это родитель, ищущий курсы для ребёнка, или студент, выбирающий для себя. Понимание типа байера позволяет персонализировать поиск и рекомендации.
Цель модуля: обеспечить быструю и понятную регистрацию байера (4 шага), дать возможность родителям добавлять профили детей (для персонализации), а студентам — указать контекст обучения. Личный кабинет байера /me объединяет лиды, избранное и настройки профиля.
Ключевые продуктовые решения:
- Регистрация: 4-шаговый флоу (роль → данные аккаунта → подтип → онбординг)
- Два подтипа байера:
parent(ищет для детей) иstudent(ищет для себя) - Родитель может добавлять профили детей (имя, возраст, класс)
- Студент может добавить профиль родителя (для уведомлений)
- Multi-account: один человек может быть и байером, и продавцом (разные роли, один аккаунт)
- Личный кабинет байера:
/me— лиды, профиль, (избранное — v1.0)
Что не входит в этот модуль:
- Отправка лидов → Spec 07
- Избранное (saved items) → v1.0
- Уведомления байеру при смене статуса лида → v1.0
- Восстановление пароля → аналогично Spec 01 UC-02 (ссылка ниже)
- Публичный профиль байера → не предусмотрен (профиль приватный)
2. Роли пользователей
| Роль | Действие в этом модуле |
|---|---|
| Гость | Регистрируется как байер (parent или student), проходит онбординг |
| Buyer (parent) | Просматривает/редактирует профиль, добавляет/редактирует профили детей |
| Buyer (student) | Просматривает/редактирует профиль, добавляет профиль родителя |
| Seller | Добавляет байер-профиль к существующему seller-аккаунту (multi-account) |
| Admin | Видит байеров в /admin, может изменить статус аккаунта |
3. Use Cases
UC-01: Регистрация байера (Parent) — Happy Path
Актор: Гость (незарегистрированный пользователь) Предусловие: Пользователь не имеет аккаунта на платформе Триггер: Хочет записать ребёнка на курс и пройти регистрацию
Полный поток:
[Точка входа]
→ Пользователь находится на главной / или попытался совершить действие
требующее авторизации
→ Видит в хедере кнопку "Войти / Зарегистрироваться"
→ Нажимает → открывается /register
───────────────────────────────────────────────────────
ШАГ 1 — Выбор роли
───────────────────────────────────────────────────────
→ Экран показывает два варианта в виде карточек:
[🎓 Я ищу курсы] [🏫 Я обучаю / Моя школа]
→ Пользователь нажимает "Я ищу курсы"
→ В URL добавляется /register?role=buyer
→ Переходит к Шагу 2
───────────────────────────────────────────────────────
ШАГ 2 — Данные аккаунта
───────────────────────────────────────────────────────
→ Форма с полями:
- Имя *
- Фамилия *
- Номер телефона * (формат +998 XX XXX-XX-XX, маска при вводе)
- Email (необязательно)
- Пароль * (с иконкой показать/скрыть)
- Повторите пароль * (с иконкой показать/скрыть)
→ Все поля валидируются при потере фокуса (on blur)
→ Кнопка "Продолжить" активна когда обязательные поля валидны
→ Пользователь заполняет поля → нажимает "Продолжить"
→ Система проверяет уникальность телефона (и email если указан)
→ Переходит к Шагу 3
───────────────────────────────────────────────────────
ШАГ 3 — Выбор подтипа байера
───────────────────────────────────────────────────────
→ Экран показывает два варианта в виде карточек:
[👨👧 Я родитель]
Ищу курсы для ребёнка. Могу добавить несколько детей.
[🎓 Я студент]
Ищу курсы для себя. Выбираю сам.
→ Пользователь выбирает "Я родитель" — карточка подсвечивается
→ Нажимает "Продолжить" → переходит к Шагу 4
───────────────────────────────────────────────────────
ШАГ 4 — Онбординг (для parent)
───────────────────────────────────────────────────────
→ Заголовок: "Расскажите о ребёнке"
→ Подзаголовок: "Это поможет нам показывать подходящие курсы. Можно пропустить."
→ Форма (всё необязательно):
- Имя ребёнка (placeholder: "Амир")
- Возраст ребёнка (число от 3 до 18)
- Класс (число от 1 до 11, необязательно, появляется если возраст ≥ 6)
→ Кнопки: "Добавить ребёнка" и "Пропустить"
ПУТЬ A — Пользователь добавляет ребёнка:
→ Заполняет поля → нажимает "Добавить ребёнка"
→ Карточка ребёнка появляется под формой
→ Ссылка "+ Добавить ещё ребёнка" (можно добавить до 5)
→ Кнопка "Завершить регистрацию"
ПУТЬ B — Пользователь нажимает "Пропустить":
→ Онбординг пропускается, сразу → завершение
───────────────────────────────────────────────────────
ЗАВЕРШЕНИЕ
───────────────────────────────────────────────────────
→ При нажатии "Завершить регистрацию":
→ Система:
1. Создаёт Account { account_type: BUYER, account_status: active }
2. Создаёт Buyer { buyer_type: parent }
3. Создаёт Parent { first_name, last_name, phone, email }
4. Если добавлены дети: создаёт Student[] + ParentStudentLink[]
→ Выдаёт access_token + refresh_token (httpOnly cookie)
→ Редирект на /me (личный кабинет байера)
→ Показывает welcome-модал:
"Добро пожаловать на Qadam! Начните поиск курсов для вашего ребёнка."
[кнопка "Найти курсы" → /catalog]
Индикатор прогресса: На всех шагах вверху — степпер: ● ○ ○ ○ → ● ● ○ ○ → ● ● ● ○ → ● ● ● ●
UC-01 — Альтернативные потоки и обработка ошибок
Шаг 2 — Ошибки валидации полей
2a. Телефон уже зарегистрирован:
Триггер: сервер возвращает 409 PHONE_TAKEN
UI-реакция:
→ Поле телефона: красная обводка + ⚠
→ Под полем: "Аккаунт с таким номером уже существует."
→ Ссылки: "Войти" (→ /login) | "Восстановить пароль" (→ /forgot-password)
2b. Email уже зарегистрирован:
Триггер: сервер возвращает 409 EMAIL_TAKEN
UI-реакция:
→ Поле email: красная обводка + ⚠
→ Под полем: "Пользователь с таким email уже зарегистрирован."
→ Ссылки: "Войти" (→ /login) | "Восстановить пароль" (→ /forgot-password)
2c. Неверный формат телефона (on-blur):
UI-реакция:
→ Поле телефона: красная обводка + ⚠
→ Под полем: "Введите номер в формате +998 XX XXX-XX-XX"
→ Маска при вводе: +998 (__)___-__-__
2d. Пароль слишком короткий (< 8 символов, on-blur):
UI-реакция:
→ Поле пароля: красная обводка + ⚠
→ Под полем: "Пароль должен содержать минимум 8 символов"
→ Индикатор силы пароля: [слабый / средний / сильный]
2e. Пароли не совпадают (on-blur):
UI-реакция:
→ Поле "Повторите пароль": красная обводка + ⚠
→ Под полем: "Пароли не совпадают"
2f. Обязательное поле пустое (при нажатии "Продолжить"):
UI-реакция:
→ Все незаполненные обязательные поля: красная обводка + ⚠
→ Под каждым: "Это поле обязательно для заполнения"
→ Страница прокручивается к первому полю с ошибкой
2g. Технический сбой (сеть/сервер):
UI-реакция:
→ Toast (красный): "Не удалось выполнить запрос. Проверьте интернет-соединение и попробуйте снова."
→ Кнопка "Продолжить" остаётся активной
→ Данные не сбрасываются
Шаг 4 — Ошибки онбординга
4a. Возраст ребёнка вне диапазона:
UI-реакция:
→ Поле возраста: красная обводка + ⚠
→ Под полем: "Укажите возраст от 3 до 18 лет"
4b. Попытка добавить 6-го ребёнка:
UI-реакция:
→ Ссылка "+ Добавить ещё ребёнка" не отображается после 5 детей
→ Рядом с заголовком: "(5/5 детей)"
4c. Регистрация зависла (кнопка "Завершить регистрацию" нажата):
UI-реакция:
→ Кнопка переходит в loading: spinner + "Сохраняем..."
→ Все поля формы disabled (нет повторной отправки)
→ Если > 10 секунд: Toast "Это занимает больше обычного. Пожалуйста, подождите."
→ Если > 30 секунд: Toast "Похоже, возникла проблема. Попробуйте снова." + разблокировка кнопки
4d. Пользователь обновил страницу на Шаге 3–4:
Поведение:
→ Шаги 1-3 хранятся в sessionStorage
→ При обновлении: показываем последний сохранённый шаг
→ Если данные шагов потеряны: редирект на /register
→ Toast: "Пожалуйста, начните регистрацию заново."
UC-02: Регистрация байера (Student) — Happy Path
Актор: Гость Предусловие: Пользователь не имеет аккаунта Триггер: Хочет искать курсы для себя
Полный поток:
[Точка входа]
→ Аналогично UC-01 Шаги 1–3
→ На Шаге 3: выбирает "Я студент"
→ Переходит к Шагу 4
───────────────────────────────────────────────────────
ШАГ 4 — Онбординг (для student)
───────────────────────────────────────────────────────
→ Заголовок: "Расскажите о себе"
→ Подзаголовок: "Поможем найти подходящие курсы. Можно пропустить."
→ Форма (всё необязательно):
- Год рождения (число, 4 цифры, от 1950 до текущий год - 5)
- Класс (число 1–11, показывается если год рождения соответствует школьному возрасту)
- Направления интересов (мультивыбор из subject_registry, max 5, с поиском)
→ Кнопки: "Сохранить и продолжить" и "Пропустить"
───────────────────────────────────────────────────────
ЗАВЕРШЕНИЕ
───────────────────────────────────────────────────────
→ Система:
1. Создаёт Account { account_type: BUYER, account_status: active }
2. Создаёт Buyer { buyer_type: student }
3. Создаёт Student { first_name, last_name, birth_year, school_grade (если указан) }
4. Если указаны интересы: создаёт StudentSubjectLink[]
→ httpOnly cookie с токенами
→ Редирект на /me
→ Welcome-модал: "Добро пожаловать! Найдите курс который вам подходит."
[кнопка "Перейти в каталог" → /catalog]
UC-02 — Альтернативные потоки
4a. Год рождения в будущем или нереальный:
UI-реакция:
→ Поле год рождения: красная обводка + ⚠
→ Под полем: "Введите корректный год рождения"
4b. Выбрано более 5 направлений интересов:
UI-реакция:
→ Чекбоксы после 5 выбранных: disabled (серые)
→ Под полем: "Выберите не более 5 направлений"
UC-03: Байер редактирует профиль
Актор: Buyer (авторизован) Предусловие: Байер авторизован, находится в личном кабинете Триггер: Нажимает "Редактировать профиль" в /me/profile
Полный поток:
→ В меню /me нажимает "Профиль" → открывается /me/profile
→ Видит заполненный профиль в режиме просмотра:
- Имя, Фамилия
- Телефон (из Account)
- Email (из Account, если указан)
- Тип: "Родитель" или "Студент"
- (для студента): год рождения, класс, интересы
→ Нажимает "Редактировать"
→ Поля становятся редактируемыми
→ Поле телефона: редактируемое, НО при изменении требует SMS-верификации (TBD — см. раздел 8)
→ Вносит изменения
→ Нажимает "Сохранить"
→ Система валидирует → сохраняет
→ Toast (зелёный): "Профиль обновлён ✓"
→ Страница возвращается в режим просмотра
Альтернативные потоки:
a. Попытка сохранить с пустым обязательным полем:
UI-реакция:
→ Пустые обязательные поля: красная обводка + ⚠
→ Под каждым: "Это поле обязательно для заполнения"
→ Форма не сохраняется
b. Новый телефон уже используется другим аккаунтом:
UI-реакция:
→ Поле телефона: красная обводка + ⚠
→ Под полем: "Этот номер привязан к другому аккаунту."
c. Новый email уже используется другим аккаунтом:
UI-реакция:
→ Поле email: красная обводка + ⚠
→ Под полем: "Пользователь с таким email уже зарегистрирован."
d. Пользователь нажал "Отмена" с несохранёнными изменениями:
UI-реакция:
→ Диалог подтверждения: "Изменения не сохранены. Выйти без сохранения?"
→ [Остаться] [Выйти без сохранения]
e. Технический сбой при сохранении:
UI-реакция:
→ Toast (красный): "Не удалось сохранить изменения. Попробуйте снова."
→ Данные формы не сбрасываются
UC-04: Родитель добавляет профиль ребёнка
Актор: Buyer (buyer_type = parent) Предусловие: Байер авторизован как parent Триггер: В разделе /me/children нажимает "+ Добавить ребёнка"
Полный поток:
→ Байер-родитель в меню /me видит раздел "Мои дети"
→ Переходит на /me/children
→ Видит список добавленных детей (или пустое состояние с иллюстрацией)
→ Нажимает "+ Добавить ребёнка"
→ Открывается форма (инлайн или модал):
- Имя ребёнка * (2–50 символов)
- Год рождения (необязательно, число)
- Класс (необязательно, 1–11)
- Интересы (необязательно, мультивыбор, max 5 направлений)
→ Нажимает "Добавить"
→ Система создаёт Student + ParentStudentLink
→ Карточка ребёнка появляется в списке
→ Toast: "Ребёнок добавлен ✓"
Альтернативные потоки:
a. Имя ребёнка пустое:
UI-реакция:
→ Поле имени: красная обводка + ⚠
→ Под полем: "Введите имя ребёнка"
b. Класс не соответствует году рождения (предупреждение, не ошибка):
UI-реакция:
→ Под полем класса: жёлтый текст ⚠ "Класс не соответствует возрасту — проверьте данные"
→ Форму можно сохранить (не блокируем)
c. Попытка добавить 6-го ребёнка:
UI-реакция:
→ Кнопка "+ Добавить ребёнка" disabled или скрыта
→ Рядом с заголовком: "(5/5)"
→ Если пользователь попал через прямой URL: Toast "Достигнут лимит: максимум 5 детей."
d. Редактирование карточки ребёнка:
→ На каждой карточке ребёнка: кнопка "Редактировать" (иконка карандаша)
→ При нажатии: форма с предзаполненными данными
→ Сохранение: PATCH /api/v1/me/children/:student_id
→ Toast: "Данные ребёнка обновлены ✓"
e. Удаление карточки ребёнка:
→ На карточке: кнопка "Удалить" (иконка корзины)
→ Диалог: "Удалить профиль «{имя}»? Это действие нельзя отменить."
→ [Отмена] [Удалить]
→ После: DELETE /api/v1/me/children/:student_id
→ Toast: "Профиль {имя} удалён."
UC-05: Продавец добавляет байер-профиль к существующему аккаунту (multi-account)
Актор: Seller (авторизован как продавец) Предусловие: У пользователя уже есть аккаунт с типом SELLER (или SELLER_STAFF) Триггер: Хочет также искать курсы как покупатель
Полный поток:
[Точка входа]
→ Продавец авторизован, находится в /seller
→ В хедере нажимает на аватар → выпадающее меню
→ Видит пункт "Добавить аккаунт покупателя"
→ Нажимает → открывается /register/add-buyer-role
───────────────────────────────────────────────────────
ДОБАВЛЕНИЕ BUYER-РОЛИ
───────────────────────────────────────────────────────
→ Экран: "Вы уже зарегистрированы как продавец. Добавить аккаунт покупателя к этому профилю?"
→ Имя и телефон уже известны (из Account) — предзаполнены, не редактируемы
→ Выбор подтипа (Родитель / Студент) — аналогично UC-01 Шаг 3
→ Онбординг — аналогично UC-01 или UC-02 Шаг 4
→ Нажимает "Добавить аккаунт покупателя"
→ Система:
1. Обновляет Account.account_type на SELLER (НЕ меняем — аккаунт уже SELLER)
ПРИМЕЧАНИЕ: Multi-account реализуется через отдельные записи Buyer и Seller
привязанные к одному Account. account_type остаётся SELLER.
2. Создаёт Buyer { buyer_type: выбранный }
3. Создаёт Parent или Student профиль
→ Toast: "Аккаунт покупателя добавлен! Теперь вы можете искать курсы."
→ В хедере появляется переключатель ролей: [Я продавец] [Я покупатель]
→ Редирект на /me (личный кабинет байера)
Альтернативные потоки:
a. У аккаунта уже есть buyer-профиль:
Поведение:
→ Если Buyer уже существует для этого account_id:
→ Сервер возвращает 409 BUYER_ALREADY_EXISTS
→ UI: "У вашего аккаунта уже есть профиль покупателя."
→ Ссылка: "Перейти в кабинет покупателя" → /me
UC-06: Восстановление пароля (ссылка на Spec 01)
Актор: Гость (зарегистрированный, не помнящий пароль) Предусловие: У пользователя есть аккаунт
Поток восстановления пароля для байера идентичен потоку для продавца, описанному в Spec 01 UC-02. Отличие только в редиректе после успеха:
- Продавец → редиректится на
/seller - Байер → редиректится на
/me
Для реализации: использовать единый POST /api/v1/auth/forgot-password и POST /api/v1/auth/reset-password. Тип редиректа определяется по account_type аккаунта, для которого сбрасывается пароль.
4. Бизнес-правила и валидации
Таблица валидаций полей
| Поле | Правило | Ошибка пользователю |
|---|---|---|
| Имя (first_name) | 2–50 символов, обязательное | "Введите имя (от 2 до 50 символов)" |
| Фамилия (last_name) | 2–50 символов, обязательное | "Введите фамилию (от 2 до 50 символов)" |
| Телефон | +998 + 9 цифр, уникальный | "Введите номер в формате +998 XX XXX-XX-XX" |
| RFC 5322, уникальный, необязательный | "Введите корректный email, например: name@mail.ru" | |
| Пароль | ≥ 8 символов, ≥ 1 цифра | "Пароль: минимум 8 символов, минимум 1 цифра" |
| Имя ребёнка (Student.first_name) | 2–50 символов | "Введите имя ребёнка (от 2 до 50 символов)" |
| Возраст ребёнка | 3–18 лет | "Укажите возраст от 3 до 18 лет" |
| Класс ребёнка | 1–11, только если возраст ≥ 6 | "Класс должен быть от 1 до 11" |
| Год рождения (student self) | 1950 – (текущий год − 5) | "Введите корректный год рождения" |
| Интересы студента | max 5 из subject_registry | "Выберите не более 5 направлений" |
| Кол-во детей | max 5 | "Достигнут лимит: максимум 5 детей" |
Бизнес-правила
- Email необязателен при регистрации байера: телефон — единственный обязательный идентификатор
- buyer_type неизменяем: нельзя сменить parent на student и обратно самостоятельно после регистрации — только через поддержку
- Multi-account: один Account может иметь и Buyer и Seller записи одновременно. account_type остаётся как при последней регистрации — логика ролей проверяется через наличие Buyer/Seller записей
- Предзаполнение полей при онбординге: имя, фамилия и телефон из Шага 2 не переспрашиваются в Шаге 4
- Прогресс онбординга: Шаги 1–3 хранятся в sessionStorage; восстанавливаются при обновлении страницы
- Статус active по умолчанию: новый байер сразу может отправлять лиды
- ParentStudentLink — двунаправленная связь: Parent → Student и Student → Parent (если студент указал профиль родителя)
- Дети не имеют аккаунта: Student в контексте "ребёнок родителя" — это просто профиль без Account. Student в контексте самостоятельного байера — это Buyer { buyer_type: student } с Account
- Rate limiting на регистрацию: не более 3 регистраций с одного IP в час
5. Модель данных
Используем существующую сущность Account. Buyer, Parent, Student, ParentStudentLink — новые сущности. StudentSubjectLink — новая.
Buyer
| Атрибут | Тип | Описание |
|---|---|---|
| buyer_id | UUID | PK |
| account_id | UUID FK | → Account, unique |
| buyer_type | BuyerType | parent / student |
| created_at | DateTime |
Parent
| Атрибут | Тип | Описание |
|---|---|---|
| parent_id | UUID | PK |
| buyer_id | UUID FK | → Buyer, unique |
| first_name | string | 2–50 символов |
| last_name | string | 2–50 символов |
| phone | string | предзаполнен из Account.phone |
| string? | предзаполнен из Account.email, необязательный | |
| updated_at | DateTime |
Student
| Атрибут | Тип | Описание |
|---|---|---|
| student_id | UUID | PK |
| buyer_id | UUID FK? | → Buyer; nullable — ребёнок родителя не имеет account |
| first_name | string | 2–50 символов |
| last_name | string? | необязательная для детей |
| birth_year | int? | необязательный |
| school_grade | int? | 1–11, необязательный |
| updated_at | DateTime |
ParentStudentLink
| Атрибут | Тип | Описание |
|---|---|---|
| id | UUID | PK |
| parent_id | UUID FK | → Parent |
| student_id | UUID FK | → Student |
| @@unique([parent_id, student_id]) | один родитель — один раз связан с каждым ребёнком |
StudentSubjectLink
| Атрибут | Тип | Описание |
|---|---|---|
| id | UUID | PK |
| student_id | UUID FK | → Student |
| subject_id | UUID FK | → subject_registry |
| @@unique([student_id, subject_id]) |
6. Технические контракты
6.1 Prisma Schema
enum BuyerType {
parent
student
}
model Buyer {
buyer_id String @id @default(uuid())
account_id String @unique
buyer_type BuyerType
created_at DateTime @default(now())
account Account @relation(fields: [account_id], references: [account_id])
parent Parent?
student Student?
leads Lead[] // → Spec 07
}
model Parent {
parent_id String @id @default(uuid())
buyer_id String @unique
first_name String @db.VarChar(50)
last_name String @db.VarChar(50)
phone String
email String?
updated_at DateTime @updatedAt
buyer Buyer @relation(fields: [buyer_id], references: [buyer_id])
children ParentStudentLink[]
}
model Student {
student_id String @id @default(uuid())
buyer_id String? @unique // null если это ребёнок (нет своего Account)
first_name String @db.VarChar(50)
last_name String? @db.VarChar(50)
birth_year Int?
school_grade Int?
updated_at DateTime @updatedAt
buyer Buyer? @relation(fields: [buyer_id], references: [buyer_id])
parents ParentStudentLink[]
subjects StudentSubjectLink[]
}
model ParentStudentLink {
id String @id @default(uuid())
parent_id String
student_id String
parent Parent @relation(fields: [parent_id], references: [parent_id])
student Student @relation(fields: [student_id], references: [student_id])
@@unique([parent_id, student_id])
}
model StudentSubjectLink {
id String @id @default(uuid())
student_id String
subject_id String
student Student @relation(fields: [student_id], references: [student_id])
@@unique([student_id, subject_id])
}
6.2 TypeScript DTO
// ─── Регистрация байера ────────────────────────────────────────────────────
export class RegisterBuyerAccountDto {
@IsString() @MinLength(2) @MaxLength(50)
first_name: string
@IsString() @MinLength(2) @MaxLength(50)
last_name: string
@Matches(/^\+998\d{9}$/, { message: 'Введите номер в формате +998XXXXXXXXX' })
phone: string
@IsOptional()
@IsEmail({}, { message: 'Введите корректный email' })
email?: string
@MinLength(8) @Matches(/\d/, { message: 'Пароль должен содержать минимум 1 цифру' })
password: string
@IsEnum(BuyerType)
buyer_type: BuyerType
}
// Онбординг родителя — добавление детей
export class AddChildDto {
@IsString() @MinLength(2) @MaxLength(50)
first_name: string
@IsOptional() @IsInt() @Min(3) @Max(18)
age?: number
@IsOptional() @IsInt() @Min(1) @Max(11)
school_grade?: number
}
export class BuyerOnboardingParentDto {
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@ArrayMaxSize(5)
@Type(() => AddChildDto)
children?: AddChildDto[]
}
// Онбординг студента
export class BuyerOnboardingStudentDto {
@IsOptional() @IsInt() @Min(1950)
birth_year?: number
@IsOptional() @IsInt() @Min(1) @Max(11)
school_grade?: number
@IsOptional() @IsArray() @ArrayMaxSize(5) @IsUUID(4, { each: true })
subject_ids?: string[]
}
// ─── Редактирование профиля ───────────────────────────────────────────────
export class UpdateBuyerProfileDto {
@IsOptional() @IsString() @MinLength(2) @MaxLength(50)
first_name?: string
@IsOptional() @IsString() @MinLength(2) @MaxLength(50)
last_name?: string
@IsOptional()
@Matches(/^\+998\d{9}$/)
phone?: string
@IsOptional()
@IsEmail()
email?: string
}
// ─── Ребёнок (для родителя) ────────────────────────────────────────────────
export class CreateChildDto {
@IsString() @MinLength(2) @MaxLength(50)
first_name: string
@IsOptional() @IsString() @MaxLength(50)
last_name?: string
@IsOptional() @IsInt() @Min(3) @Max(18)
age?: number
@IsOptional() @IsInt() @Min(1) @Max(11)
school_grade?: number
@IsOptional() @IsArray() @ArrayMaxSize(5) @IsUUID(4, { each: true })
subject_ids?: string[]
}
export class UpdateChildDto {
@IsOptional() @IsString() @MinLength(2) @MaxLength(50)
first_name?: string
@IsOptional() @IsString() @MaxLength(50)
last_name?: string
@IsOptional() @IsInt() @Min(3) @Max(18)
age?: number
@IsOptional() @IsInt() @Min(1) @Max(11)
school_grade?: number
@IsOptional() @IsArray() @ArrayMaxSize(5) @IsUUID(4, { each: true })
subject_ids?: string[]
}
// ─── Response ─────────────────────────────────────────────────────────────
export interface BuyerProfileResponse {
buyer_id: string
buyer_type: BuyerType
first_name: string
last_name: string
phone: string
email: string | null
account_status: AccountStatus
// Для parent:
children?: ChildProfileDto[]
// Для student:
birth_year?: number | null
school_grade?: number | null
interests?: SubjectShortDto[]
}
export interface ChildProfileDto {
student_id: string
first_name: string
last_name: string | null
age: number | null
school_grade: number | null
interests: SubjectShortDto[]
}
6.3 API Endpoints
────────────────────────────────────────────────────────────────
РЕГИСТРАЦИЯ БАЙЕРА
────────────────────────────────────────────────────────────────
POST /api/v1/auth/register/buyer
Body: RegisterBuyerAccountDto & (BuyerOnboardingParentDto | BuyerOnboardingStudentDto)
→ 201: { access_token: string, buyer: BuyerProfileResponse }
(токен записывается в httpOnly cookie; также возвращается в body для mobile)
→ 409: { error: 'PHONE_TAKEN' | 'EMAIL_TAKEN', message: string }
→ 422: { errors: [{ field: string, message: string }] }
→ 500: { error: 'INTERNAL_ERROR', message: 'Что-то пошло не так. Попробуйте позже.' }
────────────────────────────────────────────────────────────────
ДОБАВЛЕНИЕ BUYER-РОЛИ К СУЩЕСТВУЮЩЕМУ АККАУНТУ (multi-account)
────────────────────────────────────────────────────────────────
POST /api/v1/auth/add-buyer-role
Auth: Bearer (seller или seller_staff — уже авторизован)
Body: { buyer_type: BuyerType } & (BuyerOnboardingParentDto | BuyerOnboardingStudentDto)
→ 201: BuyerProfileResponse
→ 409: { error: 'BUYER_ALREADY_EXISTS', message: 'У вашего аккаунта уже есть профиль покупателя.' }
→ 422: { errors: [{ field: string, message: string }] }
────────────────────────────────────────────────────────────────
ПРОФИЛЬ БАЙЕРА
────────────────────────────────────────────────────────────────
GET /api/v1/me/profile
Auth: Bearer (buyer)
→ 200: BuyerProfileResponse
→ 401: { error: 'UNAUTHORIZED' }
PATCH /api/v1/me/profile
Auth: Bearer (buyer)
Body: UpdateBuyerProfileDto
→ 200: BuyerProfileResponse
→ 409: { error: 'PHONE_TAKEN' | 'EMAIL_TAKEN', message: string }
→ 422: { errors: [{ field: string, message: string }] }
────────────────────────────────────────────────────────────────
ДЕТИ (только для buyer_type = parent)
────────────────────────────────────────────────────────────────
GET /api/v1/me/children
Auth: Bearer (buyer, parent)
→ 200: ChildProfileDto[]
→ 401: { error: 'UNAUTHORIZED' }
→ 403: { error: 'FORBIDDEN', message: 'Доступно только для аккаунтов типа "Родитель".' }
POST /api/v1/me/children
Auth: Bearer (buyer, parent)
Body: CreateChildDto
→ 201: ChildProfileDto
→ 400: { error: 'MAX_CHILDREN_REACHED', message: 'Достигнут лимит: максимум 5 детей.' }
→ 403: { error: 'FORBIDDEN' }
→ 422: { errors: [{ field: string, message: string }] }
PATCH /api/v1/me/children/:student_id
Auth: Bearer (buyer, parent)
Body: UpdateChildDto
→ 200: ChildProfileDto
→ 403: { error: 'FORBIDDEN' } // чужой ребёнок
→ 404: { error: 'CHILD_NOT_FOUND' }
→ 422: { errors: [{ field: string, message: string }] }
DELETE /api/v1/me/children/:student_id
Auth: Bearer (buyer, parent)
→ 204
→ 403: { error: 'FORBIDDEN' }
→ 404: { error: 'CHILD_NOT_FOUND' }
────────────────────────────────────────────────────────────────
ИНТЕРЕСЫ СТУДЕНТА
────────────────────────────────────────────────────────────────
PATCH /api/v1/me/interests
Auth: Bearer (buyer, student)
Body: { subject_ids: string[] } // полная замена, max 5
→ 200: { interests: SubjectShortDto[] }
→ 400: { error: 'MAX_INTERESTS_EXCEEDED', message: 'Выберите не более 5 направлений.' }
→ 403: { error: 'FORBIDDEN', message: 'Доступно только для аккаунтов типа "Студент".' }
→ 422: { errors: [{ field: string, message: string }] }
7. Edge Cases и обработка ошибок
| Сценарий | Поведение |
|---|---|
| Пользователь обновил страницу на Шаге 3–4 | Шаги 1–3 восстанавливаются из sessionStorage; если нет данных — редирект на /register |
| Параллельная регистрация с одинаковым телефоном (race condition) | Уникальный индекс в БД поймает второй запрос → 409 PHONE_TAKEN |
| Байер с buyer_type = student пытается вызвать GET /api/v1/me/children | 403 FORBIDDEN: "Доступно только для аккаунтов типа «Родитель»" |
| Байер с buyer_type = parent пытается вызвать PATCH /api/v1/me/interests | 403 FORBIDDEN: "Доступно только для аккаунтов типа «Студент»" |
| Продавец добавляет buyer-роль, но уже имеет Buyer запись (старая регистрация) | 409 BUYER_ALREADY_EXISTS — предлагаем перейти в /me |
| Удаление ребёнка у которого есть linked лиды (гипотетически, в v1.0) | MVP: удаление разрешено безусловно. В v1.0 добавить предупреждение если у ребёнка есть связанные записи |
| account_type при multi-account (Seller добавляет Buyer) | Account.account_type остаётся SELLER. Авторизационная логика проверяет наличие Buyer/Seller record, не только account_type |
| Байер пытается войти в /seller (у него нет Seller record) | Редирект с /seller → /me с Toast: "Этот раздел доступен только продавцам. Зарегистрируйте школу чтобы продавать курсы." |
| Гость, оставивший лид без регистрации, потом регистрируется с тем же телефоном | Лиды оставленные гостем НЕ привязываются автоматически. MVP ограничение — решить в v1.0 |
| Байер пытается изменить buyer_type через PATCH /me/profile | 400: { error: 'BUYER_TYPE_IMMUTABLE', message: 'Тип аккаунта нельзя изменить самостоятельно. Напишите нам: support@qadam.uz' } |
8. TBD / Сознательно опущено
| Тема | Статус | Примечание |
|---|---|---|
| SMS-верификация при смене телефона | TBD | В MVP телефон меняется без верификации. В v1.0 добавить OTP при изменении phone в PATCH /api/v1/me/profile |
| Email-верификация при регистрации | Исключено из MVP | Email не обязателен и не верифицируется при регистрации |
| Ретроспективная привязка гостевых лидов к байеру | Исключено из MVP | Слишком сложно. При регистрации лиды не подхватываются |
| Избранное (saved items) | Вне скоупа MVP | Добавить в v1.0 |
| Push-уведомления и email при смене статуса лида | Вне скоупа MVP | Байер видит статус в /me/leads. Активные нотификации — v1.0 |
| Удаление аккаунта байера | TBD | Механизм удалённого аккаунта не определён для MVP |
| Профиль студента-подростка — вход от его имени | Вне скоупа | Ребёнок не имеет Account в MVP. Отдельный аккаунт для ребёнка — рассмотреть в v2.0 |
| OAuth (Google, Apple) | Вне скоупа | Не планируется в MVP |
| Buyer-student может добавить профиль родителя | Частично описано | Связь Student → Parent через ParentStudentLink возможна, но UI для этого не описан в MVP |
| Аналитическое отражение (SAA-слой) | TBD | Buyer.created, Student.added и т.д. — события для ETL описываются отдельно |
| GDPR / согласие на обработку данных | Частично | Чекбокс "согласен с обработкой персональных данных" в форме регистрации — юридический текст уточняется |
9. Зависимости
| Модуль | Связь |
|---|---|
| Spec 01 (Seller Onboarding) | Account используется совместно; multi-account: один Account может иметь Buyer + Seller |
| Spec 07 (Lead Submission) | Lead.buyer_account_id → Account; данные профиля байера предзаполняют LeadModal |
| Spec 09 (Seller CRM) | Продавец видит lead_name из лида — идентифицирует байера |
| Spec 16 (Reference Data) | StudentSubjectLink и BuyerOnboardingStudentDto ссылаются на subject_registry |