Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-08-buyer-onboarding-profile.md

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"
EmailRFC 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 детей"

Бизнес-правила

  1. Email необязателен при регистрации байера: телефон — единственный обязательный идентификатор
  2. buyer_type неизменяем: нельзя сменить parent на student и обратно самостоятельно после регистрации — только через поддержку
  3. Multi-account: один Account может иметь и Buyer и Seller записи одновременно. account_type остаётся как при последней регистрации — логика ролей проверяется через наличие Buyer/Seller записей
  4. Предзаполнение полей при онбординге: имя, фамилия и телефон из Шага 2 не переспрашиваются в Шаге 4
  5. Прогресс онбординга: Шаги 1–3 хранятся в sessionStorage; восстанавливаются при обновлении страницы
  6. Статус active по умолчанию: новый байер сразу может отправлять лиды
  7. ParentStudentLink — двунаправленная связь: Parent → Student и Student → Parent (если студент указал профиль родителя)
  8. Дети не имеют аккаунта: Student в контексте "ребёнок родителя" — это просто профиль без Account. Student в контексте самостоятельного байера — это Buyer { buyer_type: student } с Account
  9. Rate limiting на регистрацию: не более 3 регистраций с одного IP в час

5. Модель данных

Используем существующую сущность Account. Buyer, Parent, Student, ParentStudentLink — новые сущности. StudentSubjectLink — новая.

Buyer

АтрибутТипОписание
buyer_idUUIDPK
account_idUUID FK→ Account, unique
buyer_typeBuyerTypeparent / student
created_atDateTime

Parent

АтрибутТипОписание
parent_idUUIDPK
buyer_idUUID FK→ Buyer, unique
first_namestring2–50 символов
last_namestring2–50 символов
phonestringпредзаполнен из Account.phone
emailstring?предзаполнен из Account.email, необязательный
updated_atDateTime

Student

АтрибутТипОписание
student_idUUIDPK
buyer_idUUID FK?→ Buyer; nullable — ребёнок родителя не имеет account
first_namestring2–50 символов
last_namestring?необязательная для детей
birth_yearint?необязательный
school_gradeint?1–11, необязательный
updated_atDateTime

ParentStudentLink

АтрибутТипОписание
idUUIDPK
parent_idUUID FK→ Parent
student_idUUID FK→ Student
@@unique([parent_id, student_id])один родитель — один раз связан с каждым ребёнком

StudentSubjectLink

АтрибутТипОписание
idUUIDPK
student_idUUID FK→ Student
subject_idUUID 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/children403 FORBIDDEN: "Доступно только для аккаунтов типа «Родитель»"
Байер с buyer_type = parent пытается вызвать PATCH /api/v1/me/interests403 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/profile400: { error: 'BUYER_TYPE_IMMUTABLE', message: 'Тип аккаунта нельзя изменить самостоятельно. Напишите нам: support@qadam.uz' }

8. TBD / Сознательно опущено

ТемаСтатусПримечание
SMS-верификация при смене телефонаTBDВ MVP телефон меняется без верификации. В v1.0 добавить OTP при изменении phone в PATCH /api/v1/me/profile
Email-верификация при регистрацииИсключено из MVPEmail не обязателен и не верифицируется при регистрации
Ретроспективная привязка гостевых лидов к байеруИсключено из 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-слой)TBDBuyer.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