# MVP Spec 08 — Buyer Onboarding & Profile

## Паспорт документа

- Статус документа: working spec
- Актуально на: 28 марта 2026 года
- Владелец: backend/platform-команда
- Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
- Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
- Связанные документы:
  - [Product roadmap и delivery checklist](../product-roadmap.md)
  - [Roadmap](../../project/roadmap.md)
  - [Карта API-маршрутов](../../architecture/api-routes.md)

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

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

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_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 |
| email | 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

```prisma
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

```typescript
// ─── Регистрация байера ────────────────────────────────────────────────────

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 |
