# MVP Spec 01 — Seller 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: A (Supply)
> Status: Draft v2
> Sync note, 28 Mar 2026:
> - live API prefix is `/api/v1/`, not `/api/`;
> - implemented now: `POST /api/v1/auth/register/seller`, password reset flow, `GET/PATCH /api/v1/seller/profile`, address CRUD, `POST /api/v1/seller/telegram/verify`, `DELETE /api/v1/seller/telegram`;
> - `PATCH /api/v1/admin/sellers/:seller_id/status` is still planned and not implemented;
> - current DB truth for Telegram binding is `telegramChatId` + `telegramVerifiedAt`; fields like `telegram_channel` in older draft fragments are not current schema truth.

---

## 1. Контекст и цель

Seller Onboarding — первая точка входа любого образовательного провайдера на платформу. От качества этого флоу зависит успех Phase A (цель: 800 активных продавцов).

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

**Что не входит в этот модуль:**
- Создание айтемов/курсов → Spec 02
- Управление сотрудниками (включая создание SELLER_STAFF аккаунтов) → Spec 03
- Публичная страница продавца `/sellers/[id]` → Spec 12
- Telegram-уведомления о лидах → Spec 10

---

## 2. Роли пользователей

| Роль | Действие в этом модуле |
|------|----------------------|
| **Гость** | Регистрируется как продавец, проходит онбординг, восстанавливает пароль |
| **Seller (Owner)** | Просматривает и редактирует профиль организации, управляет адресами, привязывает Telegram |
| **Admin** | Меняет статус аккаунта продавца (block/unblock/send to review) |

---

## 3. Use Cases

---

### UC-01: Регистрация продавца (Happy Path)

**Актор:** Гость (незарегистрированный пользователь)
**Предусловие:** Пользователь не имеет аккаунта на платформе
**Триггер:** Пользователь хочет разместить свои образовательные услуги

**Полный поток:**

```
[Точка входа]
→ Пользователь находится на главной странице /
→ Видит в хедере кнопку "Войти / Зарегистрироваться"
→ Нажимает кнопку → открывается страница /register

───────────────────────────────────────────────────────
ШАГ 1 — Выбор роли (экран выбора кто вы)
───────────────────────────────────────────────────────
→ Экран показывает два варианта в виде карточек:
    [🎓 Я ищу курсы]     [🏫 Я обучаю / Моя школа]
→ Пользователь нажимает карточку "Я обучаю / Моя школа"
→ В URL добавляется /register?role=seller
→ Переходит к Шагу 2

───────────────────────────────────────────────────────
ШАГ 2 — Данные аккаунта
───────────────────────────────────────────────────────
→ Форма с полями:
    - Имя *
    - Фамилия *
    - Номер телефона * (формат +998 XX XXX-XX-XX, маска при вводе)
    - Email *
    - Пароль * (с иконкой показать/скрыть)
    - Повторите пароль * (с иконкой показать/скрыть)
→ Все поля валидируются при потере фокуса (on blur)
→ Кнопка "Продолжить" становится активной когда все поля валидны
→ Пользователь заполняет все поля → нажимает "Продолжить"
→ Система проверяет уникальность email и телефона
→ Переходит к Шагу 3

───────────────────────────────────────────────────────
ШАГ 3 — Выбор типа продавца
───────────────────────────────────────────────────────
→ Экран показывает три варианта в виде карточек с иконками и описаниями:

    [🏫 Учебный центр / Школа]
    Офлайн или гибридный формат. У вас есть физический адрес.

    [💻 Онлайн-школа]
    Вы проводите занятия только онлайн.

    [👤 Репетитор / Тренер]
    Вы работаете как частный специалист.

→ Пользователь выбирает тип — карточка подсвечивается
→ Нажимает "Продолжить" → переходит к Шагу 4

───────────────────────────────────────────────────────
ШАГ 4 — Профиль организации (онбординг)
───────────────────────────────────────────────────────
→ Форма заполнения профиля (состав полей зависит от типа — см. UC-03)
→ Поля "Телефон" и "Email" предзаполнены из Шага 2 (не переспрашиваем)
→ Пользователь заполняет профиль
→ Нажимает "Завершить регистрацию"
→ Система:
    1. Создаёт Account { account_type: SELLER, account_status: active }
    2. Создаёт Seller { seller_type: выбранный тип }
    3. Создаёт профиль нужного типа (SchoolProfile / OnlineSchoolProfile / IndividualContributorProfile)
    4. Создаёт SellerAddress (если офлайн/hybrid тип)
    5. Создаёт связи с субъектами (SellerSubjectLink)
→ Выдаёт access_token + refresh_token (записывает в httpOnly cookie)
→ Редирект на /seller (личный кабинет)
→ Показывает welcome-модал: "Добро пожаловать! Создайте первый курс и получите первых клиентов" [кнопка "Создать курс"]
```

**Индикатор прогресса:**
На всех шагах вверху отображается степпер: ● ○ ○ ○ → ● ● ○ ○ → ● ● ● ○ → ● ● ● ●
Пользователь видит на каком шаге он находится и сколько осталось.

---

### UC-01 — Альтернативные потоки и обработка ошибок

#### Шаг 2 — Ошибки валидации полей

**2a. Email уже зарегистрирован в системе:**
```
Триггер: Пользователь вводит email → нажимает "Продолжить" →
         сервер возвращает 409 EMAIL_TAKEN

UI-реакция:
→ Поле email: красная обводка + красный значок ⚠ справа
→ Под полем: красный текст "Пользователь с таким email уже зарегистрирован."
→ Ссылка: "Войти" (→ /login) и "Восстановить пароль" (→ /forgot-password)
→ Кнопка "Продолжить" остаётся заблокированной до исправления
```

**2b. Телефон уже зарегистрирован в системе:**
```
Триггер: сервер возвращает 409 PHONE_TAKEN

UI-реакция:
→ Поле телефона: красная обводка + ⚠
→ Под полем: "Аккаунт с таким номером уже существует."
→ Ссылка: "Войти" (→ /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. Технический сбой (сеть/сервер недоступны):**
```
Триггер: Запрос к серверу завершился с ошибкой сети или 5xx

UI-реакция:
→ Toast уведомление (красный): "Не удалось выполнить запрос. Проверьте интернет-соединение и попробуйте снова."
→ Кнопка "Продолжить" остаётся активной для повтора
→ Данные формы не сбрасываются
→ Если ошибка повторяется: добавляется ссылка "Написать в поддержку" (→ Telegram/email поддержки)
```

**2h. Сервер вернул неизвестную ошибку (500, неожиданный ответ):**
```
UI-реакция:
→ Toast: "Что-то пошло не так с нашей стороны. Мы уже разбираемся. Попробуйте через несколько минут."
→ В консоль (dev): оригинальное сообщение ошибки для дебага
→ Пользователю: человекочитаемое сообщение, НЕ "fetch error" или технический стектрейс
```

#### Шаг 4 — Ошибки онбординга

**4a. Адрес не удалось геокодировать через Nominatim:**
```
UI-реакция:
→ Поле адреса: жёлтая обводка (предупреждение, не ошибка)
→ Под полем: "Не удалось определить точные координаты. Переместите маркер на карте вручную."
→ Показываем карту с маркером по центру Ташкента
→ Пользователь может перетащить маркер на нужное место
→ Кнопка "Сохранить адрес" активна только когда координаты установлены
```

**4b. Координаты вне bounds Узбекистана:**
```
UI-реакция:
→ Toast: "Указанный адрес находится за пределами Узбекистана. Проверьте правильность адреса."
→ Маркер возвращается к последней валидной позиции
```

**4c. Загрузка логотипа — файл > 5 МБ:**
```
UI-реакция:
→ Поле загрузки: красная обводка
→ Под полем: "Файл слишком большой. Максимальный размер — 5 МБ."
→ Файл не загружается, поле сбрасывается
```

**4d. Загрузка логотипа — неверный формат:**
```
UI-реакция:
→ Под полем: "Допустимые форматы: JPG, PNG, WebP."
```

**4e. Пользователь обновил страницу на Шаге 4:**
```
Поведение:
→ Шаги 1-3 хранятся в sessionStorage
→ При обновлении: показываем Шаг 4 с восстановленными данными из sessionStorage
→ Если данные шагов 1-3 потеряны (очищен браузер): редирект на /register (начало)
→ Toast: "Продолжите заполнение профиля с начала."
```

**4f. Регистрация зависла (кнопка "Завершить регистрацию" нажата, идёт запрос):**
```
UI-реакция:
→ Кнопка переходит в состояние loading: spinner + текст "Сохраняем..."
→ Все поля формы блокируются (disabled) чтобы избежать повторной отправки
→ Если запрос длится > 10 секунд: Toast "Это занимает больше обычного. Пожалуйста, подождите."
→ Если запрос > 30 секунд: Toast "Похоже, возникла проблема. Попробуйте снова." + разблокировка кнопки
```

---

### UC-02: Восстановление доступа (забыл пароль)

**Актор:** Гость (зарегистрированный пользователь, не помнящий пароль)
**Предусловие:** У пользователя есть аккаунт, но он не может войти
**Триггер:** Нажал "Восстановить пароль" на странице входа или на странице ошибки при регистрации

**Полный поток:**

```
[Точка входа]
→ Пользователь на /login нажимает "Забыли пароль?"
→ Или на /register видит ошибку "Email занят" и нажимает "Восстановить пароль"
→ Открывается /forgot-password

───────────────────────────────────────────────────────
ШАГ 1 — Ввод email или телефона
───────────────────────────────────────────────────────
→ Поле с placeholder "Email или номер телефона"
→ Пользователь вводит email или телефон в формате +998...
→ Нажимает "Отправить код"
→ Система находит аккаунт
→ Если введён email: отправляет письмо со ссылкой для сброса (TTL 30 минут)
→ Если введён телефон: отправляет SMS с 6-значным кодом (TTL 10 минут)
→ Экран: "Код отправлен на +998 XX ***-**-XX. Проверьте SMS."

───────────────────────────────────────────────────────
ШАГ 2 — Ввод кода (только для phone-flow)
───────────────────────────────────────────────────────
→ Поле для 6-значного кода (автофокус, auto-submit при вводе 6 цифр)
→ Таймер обратного отсчёта: "Повторно отправить через 0:45"
→ После таймера: ссылка "Отправить повторно"
→ Пользователь вводит код → Шаг 3

───────────────────────────────────────────────────────
ШАГ 3 — Новый пароль
───────────────────────────────────────────────────────
→ Поля "Новый пароль" + "Повторите пароль"
→ Требования к паролю показаны явно: мин 8 символов, мин 1 цифра
→ Пользователь вводит пароль → "Сохранить"
→ Система обновляет пароль
→ Автоматический вход (выдача токенов)
→ Редирект на /seller (если аккаунт продавца)
→ Toast: "Пароль успешно изменён. Вы вошли в аккаунт."
```

**Альтернативные потоки:**

**2a. Аккаунт с таким email/телефоном не найден:**
```
UI-реакция:
→ Поле: красная обводка + ⚠
→ Под полем: "Аккаунт с такими данными не найден. Проверьте правильность ввода."
→ Ссылка: "Зарегистрироваться" (→ /register)
→ Не раскрываем существует ли аккаунт по безопасности:
  сообщение одинаковое для email и phone
```

**2b. SMS-код неверный:**
```
UI-реакция:
→ Поле кода: красная обводка + ⚠
→ Под полем: "Неверный код. Осталось попыток: 2 из 3."
→ После 3 неверных попыток: поле блокируется, таймер 5 минут до новой попытки
→ Сообщение: "Слишком много неверных попыток. Запросите новый код через 5:00."
```

**2c. SMS-код истёк:**
```
UI-реакция:
→ После ввода: "Код устарел. Запросите новый."
→ Кнопка "Отправить повторно" подсвечивается
```

**2d. Ссылка в email истекла (> 30 минут):**
```
UI-реакция: страница /reset-password?token=...
→ Показывает: "Ссылка устарела. Ссылки для сброса пароля действуют 30 минут."
→ Кнопка: "Запросить новую ссылку" → возврат к /forgot-password
```

---

### UC-03: Редактирование профиля организации

**Актор:** Seller (Owner или Admin CRM — из Spec 03)
**Предусловие:** Продавец авторизован, находится в личном кабинете
**Триггер:** Продавец переходит в /seller/profile

**Полный поток:**

```
→ В левом меню кабинета нажимает "О школе" / "Мой профиль"
→ Открывается /seller/profile
→ Видит заполненный профиль в режиме просмотра
→ Нажимает кнопку "Редактировать"
→ Поля становятся редактируемыми
→ Вносит изменения
→ Нажимает "Сохранить"
→ Система валидирует → сохраняет
→ Toast (зелёный): "Профиль обновлён ✓"
→ Страница возвращается в режим просмотра
→ Изменения сразу отражаются на публичной странице продавца
```

**Альтернативные потоки:**

**a. Попытка сохранить с пустым обязательным полем:**
```
UI-реакция: (аналогично UC-01, 2f)
→ Обязательные пустые поля: красная обводка + ⚠
→ Под каждым: "Это поле обязательно для заполнения"
→ Форма не сохраняется
```

**b. Новый телефон уже используется другим аккаунтом:**
```
UI-реакция:
→ Поле телефона: красная обводка + ⚠
→ Под полем: "Этот номер привязан к другому аккаунту."
```

**c. Пользователь нажал "Отмена" с несохранёнными изменениями:**
```
UI-реакция:
→ Диалог подтверждения: "Изменения не сохранены. Выйти без сохранения?"
→ [Остаться] [Выйти без сохранения]
```

---

### UC-04: Заполнение профиля — поля для каждого типа продавца

#### Тип: school_offline (Учебный центр / Школа)

```
Обязательные поля:
─────────────────
• Название организации (3–200 символов)
• Короткое описание (10–150 символов) — для карточки в каталоге
• Полное описание (20–2000 символов) — для публичного профиля
• Контактный телефон (предзаполнен из Шага 2, редактируемый)
• Контактный email (предзаполнен из Шага 2, редактируемый)
• Направления/категории (мультивыбор из Subject registry, min 1, max 10)
• Минимум 1 физический адрес (город + полный адрес + координаты через NominatimAutocomplete)

Опциональные поля:
──────────────────
• Веб-сайт (URL)
• Instagram (URL)
• Telegram-канал (URL или @username)
• Логотип / фото организации (JPG/PNG/WebP, max 5 МБ, min 200×200px)
• Дополнительные адреса (для филиалов, max 10 адресов всего)
```

#### Тип: online_school (Онлайн-школа)

```
Обязательные поля:
─────────────────
• Название организации (3–200 символов)
• Короткое описание (10–150 символов)
• Полное описание (20–2000 символов)
• Контактный телефон (предзаполнен)
• Контактный email (предзаполнен)
• Направления/категории (min 1, max 10)

Опциональные поля:
──────────────────
• Веб-сайт
• Instagram, Telegram-канал
• Логотип

НЕТ физического адреса — это онлайн-школа.
```

#### Тип: individual_contributor (Репетитор / Тренер)

```
Обязательные поля:
─────────────────
• Имя и Фамилия (предзаполнены из Шага 2)
• Короткое описание / tagline (10–150 символов)
• Полное bio (20–2000 символов)
• Контактный телефон (предзаполнен)
• Контактный email (предзаполнен)
• Направления/категории (min 1, max 10)
• Формат работы: "Онлайн" / "Офлайн (есть адрес)" / "Выезд к ученику"
  - Если "Офлайн": показываем поле адреса (обязательное)
  - Если "Выезд": адрес не требуется

Опциональные поля:
──────────────────
• Фото профиля (JPG/PNG/WebP, max 5 МБ)
• Instagram, Telegram
```

---

### UC-05: Управление несколькими адресами (только school_offline)

**Актор:** Seller (school_offline, Owner или Admin CRM)
**Предусловие:** Продавец авторизован, seller_type = school_offline
**Триггер:** В разделе "О школе" нажимает "Добавить адрес"

**Полный поток:**

```
→ В разделе профиля видит список своих адресов
→ Нажимает "+ Добавить адрес"
→ Открывается форма добавления адреса (модал или inline-блок):
    - Название филиала (необязательно, напр. "Чиланзар")
    - Поле адреса с NominatimAutocomplete (debounce 300мс)
    - Мини-карта: показывает маркер на найденном адресе
    - Чекбокс "Показывать точный адрес публично" (default: включён)
→ Пользователь вводит адрес → автокомплит предлагает варианты
→ Выбирает вариант → маркер перемещается на карте
→ Проверяет корректность маркера
→ Нажимает "Сохранить адрес"
→ Адрес появляется в списке
→ Первый добавленный адрес автоматически становится основным (is_primary: true)
→ Для последующих: кнопка "Сделать основным"
```

**Альтернативные потоки:**

**a. Попытка добавить более 10 адресов:**
```
UI-реакция:
→ Кнопка "+ Добавить адрес" не отображается при достижении лимита
→ Рядом с заголовком: "(10/10 адресов)"
→ Если пользователь попытался через API: Toast "Достигнут лимит адресов (10)."
```

**b. Попытка удалить единственный адрес:**
```
UI-реакция:
→ Кнопка "Удалить" недоступна (disabled) для последнего адреса
→ Tooltip при наведении: "Необходим минимум 1 адрес для вашего типа аккаунта."
```

**c. Удаление адреса с подтверждением:**
```
→ При нажатии "Удалить": Диалог "Удалить адрес «Чиланзар»? Это действие нельзя отменить."
→ [Отмена] [Удалить]
→ При удалении основного адреса: первый оставшийся становится основным автоматически
→ Toast: "Адрес удалён. Новый основной адрес: {название}."
```

---

### UC-06: Привязка Telegram для уведомлений

**Актор:** Seller (любого типа)
**Предусловие:** Продавец авторизован
**Триггер:** В разделе "Уведомления" нажимает "Подключить Telegram"

**Полный поток:**

```
→ В левом меню кабинета: "Уведомления" → подраздел "Telegram"
→ Видит блок: статус "Не подключён"
→ Нажимает "Подключить Telegram"

ШАГ 1 — Открыть бота
→ Кнопка "Открыть бота @qadam_notify_bot" (открывает t.me/qadam_notify_bot в новой вкладке)
→ Инструкция: "1. Откройте бота по ссылке. 2. Нажмите /start. 3. Скопируйте 6-значный код из бота. 4. Введите его ниже."

ШАГ 2 — Ввод кода
→ Поле для 6 цифр (с маской _ _ _ _ _ _)
→ Таймер: "Код действует ещё 9:59" (обратный отсчёт)
→ Пользователь вводит код

ШАГ 3 — Верификация
→ Система проверяет код:
    - Находит запись в TelegramVerificationCode по коду
    - Проверяет expires_at > now()
    - Проверяет used = false
    - Проверяет что chat_id не привязан к другому seller_id
→ Сохраняет Seller.telegram_chat_id = chat_id
→ Помечает код как used = true
→ Бот отправляет сообщение: "✅ Аккаунт «{org_name}» подключён. Вы будете получать уведомления о новых заявках."
→ На сайте блок меняется: статус "Подключён ✓ @username"
→ Toast: "Telegram успешно подключён!"
```

**Альтернативные потоки:**

**a. Пользователь не нашёл бота / не открыл его:**
```
→ Таймер истёк → "Код устарел. Начните сначала — откройте бота и нажмите /start."
→ Кнопка "Попробовать снова" сбрасывает флоу
```

**b. Введён неверный код:**
```
UI-реакция:
→ Поле кода: красная обводка + ⚠
→ Под полем: "Неверный код. Убедитесь, что вводите код именно из бота @qadam_notify_bot."
```

**c. Код истёк (> 10 минут):**
```
UI-реакция:
→ "Код устарел. Вернитесь в бота и нажмите /start для получения нового кода."
→ Ссылка: "Открыть бота"
```

**d. Этот Telegram уже привязан к другому аккаунту:**
```
UI-реакция:
→ Toast (красный): "Этот Telegram-аккаунт уже используется другой организацией. Используйте другой аккаунт Telegram."
```

**e. Отключить Telegram:**
```
→ При нажатии "Отключить Telegram": диалог подтверждения
→ "Отключить уведомления в Telegram? Вы перестанете получать уведомления о новых заявках."
→ [Отмена] [Отключить]
→ После: Seller.telegram_chat_id = null, статус "Не подключён"
→ Toast: "Telegram отключён. Включите email-уведомления чтобы не пропустить заявки."
```

---

### UC-07: Статусная модель аккаунта продавца (Admin flow)

**Актор:** Admin
**Предусловие:** Admin авторизован на /admin
**Триггер:** Admin просматривает список продавцов

**Переходы статусов:**

```
         [регистрация]
               ↓
           active  ←──────────────────────────────┐
         ↙        ↘                                │
[Admin: на проверку] [Admin: заблокировать]        │
         ↓                    ↓                    │
     under_review          blocked           [Admin: разблокировать]
         ↓                    ↑
[Admin: одобрить]      [Admin: заблокировать из любого статуса]
         ↓
       active
```

**Поведение UI продавца в зависимости от статуса:**

| Статус | Личный кабинет | Публичный профиль | Айтемы в поиске |
|--------|---------------|-------------------|-----------------|
| `active` | Полный доступ | Виден | Видны (если approved) |
| `under_review` | Только просмотр, баннер "На проверке" | Не виден | Не видны |
| `blocked` | Страница "Аккаунт заблокирован" с причиной | Не виден | Не видны |

**Сообщение при заблокированном входе:**
```
Ваш аккаунт заблокирован.
Причина: {причина от админа, если указана}
Если вы считаете это ошибкой, напишите нам: support@qadam.uz
```

---

## 4. Бизнес-правила и валидации

### Таблица валидаций полей

| Поле | Правило | Ошибка пользователю |
|------|---------|-------------------|
| Телефон | +998 + 9 цифр, уникальный | "Введите номер в формате +998 XX XXX-XX-XX" |
| Email | RFC 5322, уникальный | "Введите корректный email, например: name@mail.ru" |
| Пароль | ≥ 8 символов, ≥ 1 цифра | "Пароль: минимум 8 символов, минимум 1 цифра" |
| Название организации | 3–200 символов | "Название: от 3 до 200 символов" |
| Короткое описание | 10–150 символов | "Краткое описание: от 10 до 150 символов" |
| Полное описание | 20–2000 символов | "Описание: от 20 до 2000 символов" |
| Категории | 1–10 выбранных | "Выберите минимум 1 и не более 10 направлений" |
| Адрес (school_offline) | Минимум 1 адрес | "Укажите хотя бы один адрес вашей школы" |
| Координаты адреса | Bounds Узбекистана (lat 37–45.6, lon 55.9–73.2) | "Адрес должен находиться в Узбекистане" |
| Логотип | JPG/PNG/WebP, max 5 МБ, min 200×200px | "Файл: max 5 МБ, форматы JPG/PNG/WebP, min 200×200px" |
| Telegram-код | 6 цифр, TTL 10 мин, одноразовый | "Неверный или устаревший код" |
| Кол-во адресов | max 10 на организацию | "Достигнут лимит: максимум 10 адресов" |

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

1. **Наследование полей:** Имя, фамилия и телефон из Шага 2 предзаполняются в Шаге 4 — не переспрашиваем
2. **Прогресс онбординга:** Шаги 1-3 хранятся в sessionStorage, восстанавливаются при обновлении страницы
3. **Тип продавца неизменяем:** seller_type нельзя изменить самостоятельно после регистрации — только через обращение в поддержку
4. **Один email/телефон — несколько ролей:** Multi-account support — один аккаунт может быть и BUYER и SELLER
5. **Статус active по умолчанию:** Новый продавец сразу может создавать курсы и получать лиды
6. **Публичность адреса:** Если `display_publicly = false`, полный адрес и координаты НЕ передаются в публичные API — виден только город
7. **Основной адрес:** Первый добавленный адрес автоматически is_primary = true. При удалении основного — следующий по order_index становится основным
8. **Rate limiting на SMS:** Не более 3 SMS в 10 минут на один номер телефона

---

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

> Используем существующие сущности из knowledge base. Не вводим новые AccountType — BUYER, SELLER, SELLER_STAFF, ADMIN уже определены. SELLER_STAFF создаётся в Spec 03.

### Account (существующая сущность, расширения не требуются)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| account_id | UUID | PK |
| email | string? | Уникальный, nullable |
| phone | string | Уникальный, +998XXXXXXXXX |
| password_hash | string | bcrypt |
| account_type | AccountType | BUYER / SELLER / SELLER_STAFF / ADMIN |
| account_status | AccountStatus | active / under_review / blocked |
| created_at | DateTime | |
| updated_at | DateTime | |

### Seller (существующая сущность)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| seller_id | UUID | PK |
| account_id | UUID FK | → Account, unique |
| seller_type | SellerType | school_offline / online_school / individual_contributor |
| telegram_chat_id | BigInt? | Заполняется после верификации |
| telegram_verified_at | DateTime? | |
| created_at | DateTime | |

### SchoolProfile (для seller_type = school_offline)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| school_id | UUID | PK |
| seller_id | UUID FK | → Seller, unique |
| org_name | string | 3–200 символов |
| short_desc | string | max 150 символов |
| full_desc | text | max 2000 символов |
| phone | string | Контактный |
| email | string | Контактный |
| website_url | string? | |
| instagram_url | string? | |
| telegram_channel | string? | |
| logo_url | string? | URL в CDN |
| updated_at | DateTime | |

### OnlineSchoolProfile (для seller_type = online_school)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| online_school_id | UUID | PK |
| seller_id | UUID FK | → Seller, unique |
| org_name | string | |
| short_desc | string | max 150 |
| full_desc | text | max 2000 |
| phone | string | |
| email | string | |
| website_url | string? | |
| instagram_url | string? | |
| telegram_channel | string? | |
| logo_url | string? | |
| updated_at | DateTime | |

### IndividualContributorProfile (для seller_type = individual_contributor)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| contributor_id | UUID | PK |
| seller_id | UUID FK | → Seller, unique |
| first_name | string | |
| last_name | string | |
| tagline | string | max 150 символов |
| bio | text | max 2000 символов |
| phone | string | |
| email | string | |
| work_format | WorkFormat | online / offline / mobile_tutor |
| photo_url | string? | |
| instagram_url | string? | |
| telegram_channel | string? | |
| updated_at | DateTime | |

### SellerAddress (общая для всех типов с физическим адресом)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| address_id | UUID | PK |
| seller_id | UUID FK | → Seller |
| branch_name | string? | Название филиала |
| city | string | Всегда публичный |
| full_address | string | Показывается только если display_publicly = true |
| latitude | Decimal(10,7) | Только если display_publicly = true |
| longitude | Decimal(10,7) | Только если display_publicly = true |
| display_publicly | boolean | default: true |
| is_primary | boolean | default: false |
| order_index | int | Для сортировки |
| created_at | DateTime | |

### SellerSubjectLink (многие-ко-многим: seller ↔ subject)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| id | UUID | PK |
| seller_id | UUID FK | → Seller |
| subject_id | UUID FK | → subject_registry |
| @@unique([seller_id, subject_id]) | | |

### TelegramVerificationCode (временная, TTL-based)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| code | string | PK, 6-значный числовой |
| seller_id | UUID FK | → Seller |
| chat_id | BigInt | Telegram chat_id из бота |
| expires_at | DateTime | now() + 10 минут |
| used | boolean | default: false |

### PasswordResetToken (для UC-02, временная)

| Атрибут | Тип | Описание |
|---------|-----|---------|
| token | UUID | PK, для email-ссылки |
| account_id | UUID FK | → Account |
| type | ResetType | email_link / sms_code |
| code | string? | 6-значный, только для SMS-flow |
| expires_at | DateTime | now() + 30 мин (email) / now() + 10 мин (SMS) |
| used | boolean | default: false |
| attempts | int | Счётчик неверных попыток (для SMS, max 3) |
| created_at | DateTime | |

---

## 6. Технические контракты

### 6.1 Prisma Schema (изменения/добавления)

```prisma
enum AccountType {
  BUYER
  SELLER
  SELLER_STAFF  // Используется в Spec 03
  ADMIN
}

enum AccountStatus {
  active
  under_review
  blocked
}

enum SellerType {
  school_offline
  online_school
  individual_contributor
}

enum WorkFormat {
  online
  offline
  mobile_tutor
}

enum ResetType {
  email_link
  sms_code
}

model Account {
  account_id     String        @id @default(uuid())
  email          String?       @unique
  phone          String        @unique
  password_hash  String
  account_type   AccountType
  account_status AccountStatus @default(active)
  created_at     DateTime      @default(now())
  updated_at     DateTime      @updatedAt

  seller Seller?
  buyer  Buyer?
  reset_tokens PasswordResetToken[]
}

model Seller {
  seller_id            String    @id @default(uuid())
  account_id           String    @unique
  seller_type          SellerType
  telegram_chat_id     BigInt?
  telegram_verified_at DateTime?
  created_at           DateTime  @default(now())

  account              Account                    @relation(fields: [account_id], references: [account_id])
  school_profile       SchoolProfile?
  online_school_profile OnlineSchoolProfile?
  contributor_profile  IndividualContributorProfile?
  addresses            SellerAddress[]
  subjects             SellerSubjectLink[]
  telegram_codes       TelegramVerificationCode[]
  staff                SellerStaff[]              // → Spec 03
  items                Item[]                     // → Spec 02
  leads                Lead[]                     // → Spec 09
}

model SchoolProfile {
  school_id        String   @id @default(uuid())
  seller_id        String   @unique
  org_name         String   @db.VarChar(200)
  short_desc       String   @db.VarChar(150)
  full_desc        String   @db.Text
  phone            String
  email            String
  website_url      String?
  instagram_url    String?
  telegram_channel String?
  logo_url         String?
  updated_at       DateTime @updatedAt

  seller Seller @relation(fields: [seller_id], references: [seller_id])
}

model OnlineSchoolProfile {
  online_school_id String   @id @default(uuid())
  seller_id        String   @unique
  org_name         String   @db.VarChar(200)
  short_desc       String   @db.VarChar(150)
  full_desc        String   @db.Text
  phone            String
  email            String
  website_url      String?
  instagram_url    String?
  telegram_channel String?
  logo_url         String?
  updated_at       DateTime @updatedAt

  seller Seller @relation(fields: [seller_id], references: [seller_id])
}

model IndividualContributorProfile {
  contributor_id   String     @id @default(uuid())
  seller_id        String     @unique
  first_name       String
  last_name        String
  tagline          String     @db.VarChar(150)
  bio              String     @db.Text
  phone            String
  email            String
  work_format      WorkFormat
  photo_url        String?
  instagram_url    String?
  telegram_channel String?
  updated_at       DateTime   @updatedAt

  seller Seller @relation(fields: [seller_id], references: [seller_id])
}

model SellerAddress {
  address_id       String   @id @default(uuid())
  seller_id        String
  branch_name      String?
  city             String
  full_address     String
  latitude         Decimal  @db.Decimal(10, 7)
  longitude        Decimal  @db.Decimal(10, 7)
  display_publicly Boolean  @default(true)
  is_primary       Boolean  @default(false)
  order_index      Int      @default(0)
  created_at       DateTime @default(now())

  seller Seller @relation(fields: [seller_id], references: [seller_id])
}

model SellerSubjectLink {
  id         String @id @default(uuid())
  seller_id  String
  subject_id String

  seller Seller @relation(fields: [seller_id], references: [seller_id])

  @@unique([seller_id, subject_id])
}

model TelegramVerificationCode {
  code       String   @id
  seller_id  String
  chat_id    BigInt
  expires_at DateTime
  used       Boolean  @default(false)

  seller Seller @relation(fields: [seller_id], references: [seller_id])
}

model PasswordResetToken {
  token      String    @id @default(uuid())
  account_id String
  type       ResetType
  code       String?   // только для sms_code
  expires_at DateTime
  used       Boolean   @default(false)
  attempts   Int       @default(0)
  created_at DateTime  @default(now())

  account Account @relation(fields: [account_id], references: [account_id])
}
```

### 6.2 TypeScript DTO

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

// Шаг 2
export class RegisterAccountDto {
  @IsString() @MinLength(2) @MaxLength(50)
  first_name: string

  @IsString() @MinLength(2) @MaxLength(50)
  last_name: string

  @Matches(/^\+998\d{9}$/, { message: 'Введите номер в формате +998XXXXXXXXX' })
  phone: string

  @IsEmail({}, { message: 'Введите корректный email' })
  email: string

  @MinLength(8) @Matches(/\d/, { message: 'Пароль должен содержать минимум 1 цифру' })
  password: string
}

// Шаг 3
export class SelectSellerTypeDto {
  @IsEnum(SellerType)
  seller_type: SellerType
}

// Шаг 4 — зависит от типа, общий базовый
export class SellerOnboardingBaseDto {
  @IsString() @MinLength(3) @MaxLength(200)
  org_name: string  // или first_name + last_name для individual_contributor

  @IsString() @MinLength(10) @MaxLength(150)
  short_desc: string

  @IsString() @MinLength(20) @MaxLength(2000)
  full_desc: string

  @Matches(/^\+998\d{9}$/)
  phone: string

  @IsEmail()
  email: string

  @IsArray() @ArrayMinSize(1) @ArrayMaxSize(10) @IsUUID(4, { each: true })
  subject_ids: string[]

  @IsOptional() @IsUrl()
  website_url?: string

  @IsOptional() @IsString()
  instagram_url?: string

  @IsOptional() @IsString()
  telegram_channel?: string

  @IsOptional() @IsUrl()
  logo_url?: string
}

// Только для school_offline
export class CreateAddressDto {
  @IsOptional() @IsString()
  branch_name?: string

  @IsString() @MinLength(2)
  city: string

  @IsString() @MinLength(5)
  full_address: string

  @Min(37.0) @Max(45.6)
  latitude: number

  @Min(55.9) @Max(73.2)
  longitude: number

  @IsBoolean() @IsOptional()
  display_publicly?: boolean  // default: true
}

// Для individual_contributor
export class IndividualOnboardingDto extends SellerOnboardingBaseDto {
  @IsEnum(WorkFormat)
  work_format: WorkFormat

  @IsOptional() @ValidateNested()
  address?: CreateAddressDto  // обязательно если work_format = offline
}

// ─── Редактирование профиля ───────────────────────────────────────────────

export class UpdateSellerProfileDto {
  @IsOptional() @IsString() @MinLength(3) @MaxLength(200)
  org_name?: string

  @IsOptional() @IsString() @MinLength(10) @MaxLength(150)
  short_desc?: string

  @IsOptional() @IsString() @MinLength(20) @MaxLength(2000)
  full_desc?: string

  @IsOptional() @Matches(/^\+998\d{9}$/)
  phone?: string

  @IsOptional() @IsEmail()
  email?: string

  @IsOptional() @IsUrl()
  website_url?: string

  @IsOptional() @IsString()
  instagram_url?: string

  @IsOptional() @IsString()
  telegram_channel?: string

  @IsOptional() @IsUrl()
  logo_url?: string

  @IsOptional() @IsArray() @ArrayMinSize(1) @ArrayMaxSize(10) @IsUUID(4, { each: true })
  subject_ids?: string[]
}

// ─── Восстановление пароля ────────────────────────────────────────────────

export class ForgotPasswordDto {
  @IsString()
  identifier: string  // email или телефон — определяем по формату
}

export class VerifyResetCodeDto {
  @IsString() @Length(6, 6)
  code: string

  @IsUUID()
  token: string  // session token из шага 1
}

export class ResetPasswordDto {
  @IsUUID()
  token: string

  @MinLength(8) @Matches(/\d/)
  new_password: string
}

// ─── Telegram верификация ─────────────────────────────────────────────────

export class TelegramVerifyDto {
  @IsString() @Length(6, 6)
  code: string
}

// ─── Response ─────────────────────────────────────────────────────────────

export interface SellerProfileResponse {
  seller_id: string
  seller_type: SellerType
  account_status: AccountStatus
  // Объединённые поля из профильной таблицы:
  org_name: string
  short_desc: string
  full_desc: string
  phone: string
  email: string
  website_url: string | null
  instagram_url: string | null
  telegram_channel: string | null
  logo_url: string | null
  work_format?: WorkFormat  // только для individual_contributor
  subjects: SubjectShortDto[]
  addresses: SellerAddressPublicDto[]
  telegram_connected: boolean
}

export interface SellerAddressPublicDto {
  address_id: string
  branch_name: string | null
  city: string
  // full_address и координаты только если display_publicly = true:
  full_address?: string
  latitude?: number
  longitude?: number
  is_primary: boolean
}
```

### 6.3 API Endpoints

```
────────────────────────────────────────────────────────────────
РЕГИСТРАЦИЯ
────────────────────────────────────────────────────────────────

POST /api/v1/auth/register/seller
Body: RegisterAccountDto & SelectSellerTypeDto & SellerOnboardingBaseDto & { addresses?: CreateAddressDto[] }
→ 201: { access_token: string, seller: SellerProfileResponse }
   (токен записывается в httpOnly cookie, тоже возвращается в body для mobile)
→ 409: { error: 'EMAIL_TAKEN' | 'PHONE_TAKEN', message: string }
→ 422: { errors: [{ field: string, message: string }] }
→ 500: { error: 'INTERNAL_ERROR', message: 'Что-то пошло не так. Попробуйте позже.' }

────────────────────────────────────────────────────────────────
ВОССТАНОВЛЕНИЕ ПАРОЛЯ
────────────────────────────────────────────────────────────────

POST /api/v1/auth/forgot-password
Body: ForgotPasswordDto
→ 200: { method: 'sms' | 'email', masked_identifier: string, token: string }
   masked_identifier: '+998 99 ***-**-12' или 'us***@mail.ru'
→ 404: { error: 'ACCOUNT_NOT_FOUND', message: 'Аккаунт с такими данными не найден.' }
→ 429: { error: 'RATE_LIMIT', message: 'Слишком много запросов. Попробуйте через {retry_after} секунд.' }

POST /api/v1/auth/verify-reset-code
Body: VerifyResetCodeDto
→ 200: { valid: true }
→ 400: { error: 'CODE_INVALID' | 'CODE_EXPIRED' | 'TOO_MANY_ATTEMPTS', message: string, attempts_left?: number }

POST /api/v1/auth/reset-password
Body: ResetPasswordDto
→ 200: { access_token: string }  // автологин после сброса
→ 400: { error: 'TOKEN_INVALID' | 'TOKEN_EXPIRED' | 'TOKEN_USED', message: string }
→ 422: { errors: [{ field: string, message: string }] }

────────────────────────────────────────────────────────────────
ПРОФИЛЬ ПРОДАВЦА
────────────────────────────────────────────────────────────────

GET /api/v1/seller/profile
Auth: Bearer (seller)
→ 200: SellerProfileResponse
→ 401: { error: 'UNAUTHORIZED' }

PATCH /api/v1/seller/profile
Auth: Bearer (seller)
Body: UpdateSellerProfileDto
→ 200: SellerProfileResponse
→ 409: { error: 'PHONE_TAKEN', message: 'Этот номер привязан к другому аккаунту.' }
→ 422: { errors: [{ field: string, message: string }] }

────────────────────────────────────────────────────────────────
АДРЕСА
────────────────────────────────────────────────────────────────

GET /api/v1/seller/addresses
Auth: Bearer (seller)
→ 200: SellerAddressPublicDto[]

POST /api/v1/seller/addresses
Auth: Bearer (seller, seller_type: school_offline | individual_contributor)
Body: CreateAddressDto
→ 201: SellerAddressPublicDto
→ 400: { error: 'MAX_ADDRESSES_REACHED', message: 'Достигнут лимит: максимум 10 адресов.' }
→ 400: { error: 'INVALID_COORDINATES', message: 'Адрес должен находиться в Узбекистане.' }
→ 403: { error: 'SELLER_TYPE_NOT_ALLOWED', message: 'Адреса доступны только для офлайн-школ и репетиторов.' }

PATCH /api/v1/seller/addresses/:address_id
Auth: Bearer (seller)
Body: Partial<CreateAddressDto>
→ 200: SellerAddressPublicDto
→ 404: { error: 'ADDRESS_NOT_FOUND' }

DELETE /api/v1/seller/addresses/:address_id
Auth: Bearer (seller)
→ 204
→ 400: { error: 'CANNOT_DELETE_ONLY_ADDRESS', message: 'Необходим минимум 1 адрес.' }
→ 404: { error: 'ADDRESS_NOT_FOUND' }

PATCH /api/v1/seller/addresses/:address_id/set-primary
Auth: Bearer (seller)
→ 200: SellerAddressPublicDto

────────────────────────────────────────────────────────────────
TELEGRAM ВЕРИФИКАЦИЯ
────────────────────────────────────────────────────────────────

POST /api/v1/seller/telegram/verify
Auth: Bearer (seller)
Body: TelegramVerifyDto
→ 200: { success: true, username: string }
→ 400: { error: 'CODE_INVALID' | 'CODE_EXPIRED' | 'TELEGRAM_ALREADY_BOUND', message: string }

DELETE /api/v1/seller/telegram
Auth: Bearer (seller)
→ 204

────────────────────────────────────────────────────────────────
ADMIN: УПРАВЛЕНИЕ СТАТУСОМ АККАУНТА
────────────────────────────────────────────────────────────────

PATCH /api/v1/admin/sellers/:seller_id/status
Auth: Bearer (admin)
Body: { status: AccountStatus, reason?: string }
→ 200: { seller_id: string, account_status: AccountStatus }
→ 400: { error: 'INVALID_STATUS_TRANSITION', message: string }
→ 404: { error: 'SELLER_NOT_FOUND' }
```

---

## 7. Edge Cases и обработка ошибок

| Сценарий | Поведение |
|----------|----------|
| Пользователь обновил страницу на Шаге 3–4 | Шаги 1-3 восстанавливаются из sessionStorage; если нет — редирект на /register |
| Параллельная регистрация с одинаковым email (race condition) | Уникальный индекс в БД поймает второй запрос → 409 EMAIL_TAKEN |
| Загрузка логотипа провалилась (S3/CDN недоступен) | Toast: "Не удалось загрузить изображение. Попробуйте позже." Регистрация продолжается без логотипа |
| Nominatim недоступен при вводе адреса | Показываем карту без автокомплита; пользователь может перетащить маркер вручную |
| Пользователь ввёл телефон в Шаге 4 отличный от Шага 2 | Сохраняем телефон из Шага 4 как contact_phone (отличается от auth_phone в Account) |
| Продавец пытается изменить seller_type через PATCH /profile | 400: { error: 'SELLER_TYPE_IMMUTABLE', message: 'Тип аккаунта нельзя изменить самостоятельно. Напишите нам: support@qadam.uz' } |
| Заблокированный продавец пытается войти | 200 (логин успешен) + в ответе profile.account_status = 'blocked'. Фронтенд редиректит на /seller/blocked |
| Telegram-бот: пользователь написал боту, но не нажал /start | chat_id не зарегистрирован → код не существует → CODE_INVALID |
| Повторный запрос Telegram-кода (пока старый ещё активен) | Старый код помечается used=true; генерируется новый |
| SMS не пришло (проблема с SMS-шлюзом) | Через 3 минуты пользователь видит кнопку "Отправить повторно"; Toast после 2-го неуспеха: "Проблемы с доставкой SMS. Попробуйте через email." |

---

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

| Тема | Статус | Примечание |
|------|--------|-----------|
| SMS-шлюз (провайдер верификации) | TBD | Не определён провайдер (Playmobile, Twilio, EskizUz). Метод `sendSms()` — заглушка |
| Верификация email при регистрации | Исключено из MVP | В MVP не требуем подтверждения email. Добавить в v1.0 |
| Phone verification (при регистрации) | Исключено из MVP | Телефон не верифицируется при регистрации. Только при восстановлении пароля |
| S3/CDN для загрузки изображений | TBD | Провайдер не определён. Метод `uploadFile()` — абстракция |
| OAuth (Google, Apple) | Вне скоупа | Не планируется в MVP |
| 2FA (двухфакторная авторизация) | Вне скоупа | Не планируется в MVP |
| GDPR / личные данные | Частично | Privacy Policy страница создаётся в v1.0. Правила хранения данных нужно уточнить |
| Аналитическое отражение (SAA-слой) | TBD | Каждый `Account` и `Seller` должен создавать событие в SAA-слой. ETL-пайплайн описывается отдельно |
| Soft delete для аккаунтов | TBD | Нужна ли возможность удалить аккаунт продавца? Механизм не определён |
| Email-шаблоны (forgot password) | TBD | Дизайн писем не определён |
| Rate limiting на регистрацию | Реализовать | Не более 3 новых регистраций с одного IP в час (защита от ботов) |
| Telegram-бот: механизм передачи chat_id | TBD | Бот при /start должен передавать chat_id + генерировать код в БД. Реализация бота — отдельная задача |

---

## Зависимости

| Модуль | Связь |
|--------|-------|
| **Spec 02** (Items) | Item.seller_id → Seller.seller_id; Item.location использует SellerAddress |
| **Spec 03** (Staff) | SellerStaff.seller_id → Seller.seller_id |
| **Spec 04** (Admin Moderation) | Изменение Account.account_status |
| **Spec 10** (Notifications) | Использует Seller.telegram_chat_id |
| **Spec 12** (Public Profile) | Читает SchoolProfile / OnlineSchoolProfile / IndividualContributorProfile + SellerAddress + SellerSubjectLink |
| **Spec 16** (Reference Data) | SellerSubjectLink ссылается на subject_registry |
