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

MVP Spec 01 — Seller Onboarding & Profile

Обновлён 1 апр. 2026 г., 12:41 · 0 комментариев

MVP Spec 01 — Seller Onboarding & Profile

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

  • Статус документа: working spec
  • Актуально на: 28 марта 2026 года
  • Владелец: backend/platform-команда
  • Пересмотр: перед реализацией, при изменении domain rules или при смене source-of-truth по контракту
  • Область применения: детальные feature-спеки для product backlog и реализации MVP/v1 направлений
  • Связанные документы:

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"
EmailRFC 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_idUUIDPK
emailstring?Уникальный, nullable
phonestringУникальный, +998XXXXXXXXX
password_hashstringbcrypt
account_typeAccountTypeBUYER / SELLER / SELLER_STAFF / ADMIN
account_statusAccountStatusactive / under_review / blocked
created_atDateTime
updated_atDateTime

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

АтрибутТипОписание
seller_idUUIDPK
account_idUUID FK→ Account, unique
seller_typeSellerTypeschool_offline / online_school / individual_contributor
telegram_chat_idBigInt?Заполняется после верификации
telegram_verified_atDateTime?
created_atDateTime

SchoolProfile (для seller_type = school_offline)

АтрибутТипОписание
school_idUUIDPK
seller_idUUID FK→ Seller, unique
org_namestring3–200 символов
short_descstringmax 150 символов
full_desctextmax 2000 символов
phonestringКонтактный
emailstringКонтактный
website_urlstring?
instagram_urlstring?
telegram_channelstring?
logo_urlstring?URL в CDN
updated_atDateTime

OnlineSchoolProfile (для seller_type = online_school)

АтрибутТипОписание
online_school_idUUIDPK
seller_idUUID FK→ Seller, unique
org_namestring
short_descstringmax 150
full_desctextmax 2000
phonestring
emailstring
website_urlstring?
instagram_urlstring?
telegram_channelstring?
logo_urlstring?
updated_atDateTime

IndividualContributorProfile (для seller_type = individual_contributor)

АтрибутТипОписание
contributor_idUUIDPK
seller_idUUID FK→ Seller, unique
first_namestring
last_namestring
taglinestringmax 150 символов
biotextmax 2000 символов
phonestring
emailstring
work_formatWorkFormatonline / offline / mobile_tutor
photo_urlstring?
instagram_urlstring?
telegram_channelstring?
updated_atDateTime

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

АтрибутТипОписание
address_idUUIDPK
seller_idUUID FK→ Seller
branch_namestring?Название филиала
citystringВсегда публичный
full_addressstringПоказывается только если display_publicly = true
latitudeDecimal(10,7)Только если display_publicly = true
longitudeDecimal(10,7)Только если display_publicly = true
display_publiclybooleandefault: true
is_primarybooleandefault: false
order_indexintДля сортировки
created_atDateTime

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

АтрибутТипОписание
idUUIDPK
seller_idUUID FK→ Seller
subject_idUUID FK→ subject_registry
@@unique([seller_id, subject_id])

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

АтрибутТипОписание
codestringPK, 6-значный числовой
seller_idUUID FK→ Seller
chat_idBigIntTelegram chat_id из бота
expires_atDateTimenow() + 10 минут
usedbooleandefault: false

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

АтрибутТипОписание
tokenUUIDPK, для email-ссылки
account_idUUID FK→ Account
typeResetTypeemail_link / sms_code
codestring?6-значный, только для SMS-flow
expires_atDateTimenow() + 30 мин (email) / now() + 10 мин (SMS)
usedbooleandefault: false
attemptsintСчётчик неверных попыток (для SMS, max 3)
created_atDateTime

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

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

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

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

// Шаг 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 /profile400: { error: 'SELLER_TYPE_IMMUTABLE', message: 'Тип аккаунта нельзя изменить самостоятельно. Напишите нам: support@qadam.uz' }
Заблокированный продавец пытается войти200 (логин успешен) + в ответе profile.account_status = 'blocked'. Фронтенд редиректит на /seller/blocked
Telegram-бот: пользователь написал боту, но не нажал /startchat_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_idTBDБот при /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