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/statusis still planned and not implemented;- current DB truth for Telegram binding is
telegramChatId+telegramVerifiedAt; fields liketelegram_channelin 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" |
| 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 адресов" |
Бизнес-правила
- Наследование полей: Имя, фамилия и телефон из Шага 2 предзаполняются в Шаге 4 — не переспрашиваем
- Прогресс онбординга: Шаги 1-3 хранятся в sessionStorage, восстанавливаются при обновлении страницы
- Тип продавца неизменяем: seller_type нельзя изменить самостоятельно после регистрации — только через обращение в поддержку
- Один email/телефон — несколько ролей: Multi-account support — один аккаунт может быть и BUYER и SELLER
- Статус active по умолчанию: Новый продавец сразу может создавать курсы и получать лиды
- Публичность адреса: Если
display_publicly = false, полный адрес и координаты НЕ передаются в публичные API — виден только город - Основной адрес: Первый добавленный адрес автоматически is_primary = true. При удалении основного — следующий по order_index становится основным
- Rate limiting на SMS: Не более 3 SMS в 10 минут на один номер телефона
5. Модель данных
Используем существующие сущности из knowledge base. Не вводим новые AccountType — BUYER, SELLER, SELLER_STAFF, ADMIN уже определены. SELLER_STAFF создаётся в Spec 03.
Account (существующая сущность, расширения не требуются)
| Атрибут | Тип | Описание |
|---|---|---|
| account_id | UUID | PK |
| 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 | Контактный |
| 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 | |
| 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 | |
| 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 (изменения/добавления)
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 /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 |