Qadam Roadmap
проектdocs/Agents/specs/2026-03-24-mvp-spec-03-staff-management.md

MVP Spec 03 — Staff Management

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

MVP Spec 03 — Staff Management

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

  • Статус документа: 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 v1


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

Сотрудники (Staff) — люди, которые работают в образовательной организации. В MVP это две группы:

  1. Административный персонал — люди, которые управляют кабинетом продавца (обрабатывают лиды, редактируют курсы). Не отображаются публично.
  2. Преподаватели / Перформеры — люди, которые ведут занятия. Отображаются на карточке курса и в профиле школы. Являются частью доверительной информации для байера.

Цель модуля: дать владельцу школы инструмент для создания команды с разными уровнями доступа, и предоставить публичный профиль каждого преподавателя как часть trust-сигнала платформы.

Что не входит в этот модуль:

  • Управление расписанием и рабочими часами → Spec v1.0 (CRM)
  • Расчёт зарплат, KPI → Spec v1.5 (Analytics)
  • Роли Manager и Teacher в полном объёме → Spec v1.0 (CRM Roles)
  • Привязка сотрудника к конкретному айтему → Spec 02 (ItemPerformerLink)

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

РольДействия
Seller OwnerПолный CRUD сотрудников, изменение ролей, блокировка
Seller Admin CRMСоздание и редактирование сотрудников, блокировка (без изменения Owner)
Seller ManagerТолько просмотр списка сотрудников
Seller TeacherПросмотр собственного профиля и его редактирование (bio, фото)

3. Use Cases


UC-01: Создание сотрудника (Happy Path)

Актор: Seller Owner / Admin CRM Предусловие: Продавец авторизован Триггер: Нажимает "+ Добавить сотрудника" в /seller/staff

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

[Точка входа]
→ Продавец в левом меню кабинета нажимает "Сотрудники"
→ Открывается /seller/staff
→ Нажимает "+ Добавить сотрудника"
→ Открывается модал или страница /seller/staff/new

─────────────────────────────────────────────────────────
ФОРМА СОЗДАНИЯ СОТРУДНИКА
─────────────────────────────────────────────────────────
Блок 1 — Основные данные:
    - Имя * (2–50 символов)
    - Фамилия * (2–50 символов)
    - Телефон * (формат +998XXXXXXXXX)
    - Email * (валидный email)

Блок 2 — Роль в организации *:
    Радио-кнопки с описанием:

    ◉ Администратор
       Может редактировать курсы, обрабатывать заявки, управлять персоналом.
       Не отображается публично.

    ○ Преподаватель / Тренер
       Ведёт занятия. Отображается в карточках курсов и профиле школы.
       Может входить в кабинет только в v1.0 (сейчас — только публичный профиль).

Блок 3 — Публичный профиль преподавателя
    (показывается только если выбрана роль "Преподаватель / Тренер"):
    - Специализация (до 100 символов), напр. "Преподаватель английского"
    - Опыт работы (число + "лет"), необязательно
    - Bio / описание (до 1000 символов), необязательно
    - Фото (JPG/PNG/WebP, max 5 МБ, min 200×200px), необязательно

→ Нажимает "Создать"
→ Система создаёт:
    Account { account_type: SELLER_STAFF, account_status: active }
    SellerStaff { seller_id, role: admin | teacher, staff_status: active }
    PerformerProfile (если role = teacher)
→ На указанный email отправляется письмо-приглашение:
    "Вы добавлены в команду {org_name} на платформе Qadam.
    Войдите по ссылке: [ссылка на /login]
    Ваш логин: {email}, временный пароль: {temp_password}"
→ Toast: "Сотрудник добавлен. Приглашение отправлено на {email}."
→ Новый сотрудник появляется в списке /seller/staff

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

1a. Email уже зарегистрирован в системе (у другого аккаунта):

UI-реакция:
→ Поле email: красная обводка + ⚠
→ Под полем: "Пользователь с таким email уже зарегистрирован на платформе."
→ Подсказка: "Если это ваш сотрудник — попросите его войти и связаться с поддержкой для привязки к вашей организации."

1b. Телефон уже зарегистрирован:

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

1c. Обязательное поле пустое:

UI-реакция:
→ Красная обводка + ⚠ + "Это поле обязательно для заполнения"
→ Форма не отправляется

1d. Загрузка фото преподавателя — файл > 5 МБ:

UI-реакция:
→ Под полем фото: "Файл слишком большой. Максимум 5 МБ."
→ Файл не принимается, поле сбрасывается

1e. Ошибка отправки email-приглашения (SMTP недоступен):

Поведение:
→ Сотрудник СОЗДАЁТСЯ (аккаунт в БД сохранён)
→ Toast (жёлтый, предупреждение): "Сотрудник добавлен, но письмо-приглашение не было отправлено.
  Передайте логин и временный пароль вручную или повторите отправку."
→ В карточке сотрудника: кнопка "Отправить приглашение повторно"

1f. Технический сбой при сохранении:

UI-реакция:
→ Toast (красный): "Не удалось создать сотрудника. Данные сохранены в форме. Попробуйте снова."
→ Форма не закрывается, данные не сбрасываются

UC-02: Просмотр списка сотрудников

Актор: Seller (Owner / Admin CRM / Manager) Предусловие: Продавец авторизован Триггер: Открывает /seller/staff

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

→ Список сотрудников в виде карточек или таблицы:
  Каждая запись: фото (или аватар-заглушка), имя, роль, статус, email, телефон
  Бейджи ролей: [Администратор] / [Преподаватель]
  Бейджи статусов: [Активен] / [Заблокирован]

→ Действия на карточке (для Owner/Admin CRM):
  ✏ Редактировать
  🔒 Заблокировать / Разблокировать
  🗑 Удалить (только Owner)

→ Фильтр по роли: Все / Администраторы / Преподаватели
→ Пустое состояние (нет сотрудников): иллюстрация + текст
  "У вас пока нет сотрудников. Добавьте первого."
  Кнопка "+ Добавить сотрудника"

UC-03: Редактирование профиля сотрудника

Актор: Seller Owner / Admin CRM (любого сотрудника) / Teacher (только свой профиль) Предусловие: Сотрудник существует

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

→ В /seller/staff нажимает "Редактировать" на карточке сотрудника
→ Открывается форма с предзаполненными данными
→ Поля доступные для редактирования:

  Для Owner / Admin CRM:
    - Имя, фамилия, телефон, email
    - Роль (смена роли — только Owner)
    - Специализация, опыт, bio (если Преподаватель)
    - Фото (если Преподаватель)

  Для Teacher (свой профиль):
    - Специализация, bio, фото (только публичный профиль)
    - НЕ может: менять email/телефон/роль

→ Нажимает "Сохранить"
→ Toast: "Профиль обновлён ✓"
→ Изменения публичного профиля преподавателя сразу отражаются
  на страницах айтемов и публичном профиле школы

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

a. Owner пытается изменить роль единственного Owner:

UI-реакция:
→ Dropdown роли заблокирован (disabled)
→ Tooltip: "Нельзя снять роль Owner с последнего владельца."

b. Admin CRM пытается редактировать Owner-аккаунт:

UI-реакция:
→ Кнопка "Редактировать" недоступна для Owner-карточки
→ Tooltip: "Только владелец может изменять свои данные."

UC-04: Блокировка / разблокировка сотрудника

Актор: Seller Owner / Admin CRM Предусловие: Сотрудник существует и активен

Блокировка:

→ Нажимает "Заблокировать" на карточке сотрудника
→ Диалог: "Заблокировать {имя}?
  Сотрудник потеряет доступ к кабинету. Его профиль преподавателя
  будет скрыт с публичных страниц."
→ [Отмена] [Заблокировать]
→ SellerStaff.staff_status = blocked
→ Account.account_status = blocked (для этого staff аккаунта)
→ Профиль преподавателя скрывается с публичных страниц
→ Toast: "{имя} заблокирован."

Разблокировка:

→ Нажимает "Разблокировать"
→ Без диалога: мгновенное действие
→ staff_status = active, account_status = active
→ Профиль преподавателя снова виден публично (если был teacher)
→ Toast: "{имя} разблокирован."

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

a. Попытка заблокировать единственного Owner:

UI-реакция:
→ Кнопка "Заблокировать" недоступна
→ Tooltip: "Нельзя заблокировать единственного владельца организации."

UC-05: Удаление сотрудника

Актор: Seller Owner (только) Предусловие: Сотрудник существует

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

→ Нажимает "Удалить" на карточке сотрудника
→ Диалог:
  "Удалить {имя} из организации?
  Сотрудник потеряет доступ к кабинету. Если он привязан к курсам
  как преподаватель — его профиль будет удалён с этих курсов.
  Это действие нельзя отменить."
→ [Отмена] [Удалить]
→ Выполняется:
    SellerStaff.staff_status = archived (soft delete)
    Account.account_status = blocked
    ItemPerformerLink: все записи с этим staff_id удаляются
→ Toast: "{имя} удалён из организации."

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

a. Попытка удалить единственного Owner:

UI-реакция:
→ Кнопка "Удалить" недоступна
→ Tooltip: "Нельзя удалить единственного владельца."

b. Сотрудник привязан к активным айтемам как преподаватель:

UI-реакция:
→ Диалог добавляет предупреждение:
  "Этот преподаватель указан в {N} курсах: {название1}, {название2}...
  После удаления его профиль исчезнет с этих курсов."
→ [Отмена] [Всё равно удалить]

UC-06: Первый вход сотрудника по приглашению

Актор: Новый сотрудник (SellerStaff) Предусловие: Owner создал сотрудника, письмо с приглашением отправлено

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

→ Сотрудник получает email с ссылкой и временным паролем
→ Открывает /login
→ Вводит email + временный пароль
→ Успешный вход
→ Система обнаруживает что пароль временный (is_temp_password = true)
→ Принудительный редирект на /change-password:
  "Пожалуйста, установите постоянный пароль для вашего аккаунта."
  Поля: Новый пароль + Подтверждение
→ После смены пароля: редирект в /seller (кабинет с ограниченным меню)
→ Toast: "Пароль установлен. Добро пожаловать!"

Меню Staff в кабинете (MVP — ограниченное):

  • Admin CRM: видит те же разделы что Owner (кроме биллинга)
  • Teacher (MVP): видит только "Мой профиль" и список курсов где участвует

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

Валидация полей

ПолеПравилоОшибка пользователю
Имя / Фамилия2–50 символов"Имя: от 2 до 50 символов"
Телефон+998XXXXXXXXX, уникальный в системе"Введите номер в формате +998XXXXXXXXX"
EmailВалидный, уникальный в системе"Введите корректный email"
Специализациядо 100 символов"Максимум 100 символов"
Опыт работы0–70 лет"Укажите корректное значение (0–70 лет)"
Bioдо 1000 символов"Bio: максимум 1000 символов"
ФотоJPG/PNG/WebP, max 5 МБ, min 200×200px"Фото: max 5 МБ, min 200×200px, JPG/PNG/WebP"

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

  1. Owner — единственная незаменимая роль. Нельзя удалить, заблокировать или сменить роль единственного Owner. Минимум 1 Owner всегда.
  2. Роль Owner создаётся при регистрации. Seller-аккаунт = Owner своей организации. Нельзя передать Owner через интерфейс на MVP (только через поддержку).
  3. SELLER_STAFF — отдельный AccountType. У каждого сотрудника свой Account. Это не роль внутри seller_account, это отдельный аккаунт с ограниченным доступом.
  4. Публичный профиль — только Teacher. Только сотрудники с ролью teacher имеют PerformerProfile и отображаются публично. Admin и Manager — только внутренние.
  5. Блокировка скрывает профиль. При staff_status = blocked PerformerProfile не возвращается в публичных API.
  6. Удаление — soft delete. staff_status = archived. Физически не удаляется — история лидов сохраняется.
  7. Временный пароль. При создании сотрудника генерируется временный пароль (8 символов, случайный). При первом входе принудительная смена.
  8. Лимит сотрудников. На MVP — без жёсткого лимита (TBD).

Матрица доступов (MVP)

ФункцияOwnerAdmin CRMManagerTeacher
Создать сотрудника
Редактировать любого✅ (кроме Owner)
Редактировать свой профиль✅ (только bio/фото)
Сменить роль
Заблокировать✅ (кроме Owner)
Удалить
Просмотр списка

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

SellerStaff (сотрудник организации)

АтрибутТипОписание
staff_idUUIDPK
seller_idUUID FK→ Seller (организация)
account_idUUID FK→ Account (SELLER_STAFF)
staff_roleStaffRoleowner / admin_crm / manager / teacher
staff_statusStaffStatusactive / blocked / archived
created_atDateTime
updated_atDateTime

PerformerProfile (публичный профиль преподавателя)

АтрибутТипОписание
performer_idUUIDPK
staff_idUUID FK→ SellerStaff, unique
specializationstring?до 100 символов
experience_yearsint?0–70
biotext?до 1000 символов
photo_urlstring?URL в CDN
is_activebooleandefault: true (false при блокировке)
updated_atDateTime

Account (расширение для SELLER_STAFF)

Дополнительное поле к существующей модели:

АтрибутТипОписание
is_temp_passwordbooleandefault: true при создании через invite

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

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

enum StaffRole {
  owner
  admin_crm
  manager
  teacher
}

enum StaffStatus {
  active
  blocked
  archived
}

model SellerStaff {
  staff_id    String      @id @default(uuid())
  seller_id   String
  account_id  String      @unique
  staff_role  StaffRole
  staff_status StaffStatus @default(active)
  created_at  DateTime    @default(now())
  updated_at  DateTime    @updatedAt

  seller           Seller             @relation(fields: [seller_id], references: [seller_id])
  account          Account            @relation(fields: [account_id], references: [account_id])
  performer_profile PerformerProfile?
  item_performers  ItemPerformerLink[]
}

model PerformerProfile {
  performer_id      String   @id @default(uuid())
  staff_id          String   @unique
  specialization    String?  @db.VarChar(100)
  experience_years  Int?
  bio               String?  @db.Text
  photo_url         String?
  is_active         Boolean  @default(true)
  updated_at        DateTime @updatedAt

  staff SellerStaff @relation(fields: [staff_id], references: [staff_id])
}

// Добавление поля в Account:
model Account {
  // ... существующие поля ...
  is_temp_password Boolean @default(false)
}

6.2 TypeScript DTO

// ─── Создание сотрудника ──────────────────────────────────────────────────

export class CreateStaffDto {
  @IsString() @MinLength(2) @MaxLength(50)
  first_name: string

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

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

  @IsEmail()
  email: string

  @IsEnum(StaffRole)
  staff_role: StaffRole  // admin_crm | teacher (owner нельзя создать вручную)

  // Только для teacher:
  @IsOptional() @IsString() @MaxLength(100)
  specialization?: string

  @IsOptional() @IsInt() @Min(0) @Max(70)
  experience_years?: number

  @IsOptional() @IsString() @MaxLength(1000)
  bio?: string

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

// ─── Редактирование ───────────────────────────────────────────────────────

export class UpdateStaffDto {
  @IsOptional() @IsString() @MinLength(2) @MaxLength(50)
  first_name?: string

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

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

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

  @IsOptional() @IsEnum(StaffRole)
  staff_role?: StaffRole  // только Owner может менять
}

export class UpdatePerformerProfileDto {
  @IsOptional() @IsString() @MaxLength(100)
  specialization?: string

  @IsOptional() @IsInt() @Min(0) @Max(70)
  experience_years?: number

  @IsOptional() @IsString() @MaxLength(1000)
  bio?: string

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

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

export interface StaffListItemResponse {
  staff_id: string
  first_name: string
  last_name: string
  phone: string
  email: string
  staff_role: StaffRole
  staff_status: StaffStatus
  performer_profile: PerformerProfileDto | null  // только для teacher
}

export interface PerformerProfileDto {
  performer_id: string
  specialization: string | null
  experience_years: number | null
  bio: string | null
  photo_url: string | null
  is_active: boolean
}

// Public response (для публичных страниц — без контактных данных)
export interface PerformerPublicDto {
  performer_id: string
  first_name: string
  last_name: string
  specialization: string | null
  experience_years: number | null
  bio: string | null
  photo_url: string | null
}

6.3 API Endpoints

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

GET /api/seller/staff
Auth: Bearer (seller)
Query: ?role=teacher&status=active&page=1&limit=50
→ 200: { staff: StaffListItemResponse[], total: number }
→ 403: { error: 'INSUFFICIENT_ROLE' }  // Teacher не видит список

POST /api/seller/staff
Auth: Bearer (seller: owner | admin_crm)
Body: CreateStaffDto
→ 201: StaffListItemResponse
→ 409: { error: 'EMAIL_TAKEN' | 'PHONE_TAKEN', message: string }
→ 400: { error: 'CANNOT_CREATE_OWNER', message: 'Роль Owner нельзя создать вручную.' }
→ 422: { errors: ValidationError[] }

GET /api/seller/staff/:staff_id
Auth: Bearer (seller)
→ 200: StaffListItemResponse
→ 403: { error: 'FORBIDDEN' }  // нельзя смотреть чужих сотрудников
→ 404: { error: 'STAFF_NOT_FOUND' }

PATCH /api/seller/staff/:staff_id
Auth: Bearer (seller: owner | admin_crm | teacher[self only])
Body: UpdateStaffDto
→ 200: StaffListItemResponse
→ 400: { error: 'CANNOT_DEMOTE_LAST_OWNER', message: 'Нельзя сменить роль последнего владельца.' }
→ 403: { error: 'CANNOT_EDIT_OWNER' }  // admin пытается редактировать owner
→ 422: { errors: ValidationError[] }

PATCH /api/seller/staff/:staff_id/performer-profile
Auth: Bearer (seller: owner | admin_crm | teacher[self only])
Body: UpdatePerformerProfileDto
→ 200: PerformerProfileDto
→ 400: { error: 'NOT_TEACHER', message: 'Профиль преподавателя доступен только для роли Teacher.' }

PATCH /api/seller/staff/:staff_id/status
Auth: Bearer (seller: owner | admin_crm)
Body: { status: 'active' | 'blocked' }
→ 200: { staff_id: string, staff_status: StaffStatus }
→ 400: { error: 'CANNOT_BLOCK_LAST_OWNER', message: 'Нельзя заблокировать последнего владельца.' }
→ 403: { error: 'CANNOT_BLOCK_OWNER' }  // admin пытается заблокировать owner

DELETE /api/seller/staff/:staff_id
Auth: Bearer (seller: owner only)
→ 204
→ 400: { error: 'CANNOT_DELETE_LAST_OWNER', message: 'Нельзя удалить последнего владельца.' }
→ 403: { error: 'INSUFFICIENT_ROLE', message: 'Только владелец может удалять сотрудников.' }

POST /api/seller/staff/:staff_id/resend-invite
Auth: Bearer (seller: owner | admin_crm)
→ 200: { message: 'Приглашение отправлено на {email}.' }
→ 400: { error: 'ALREADY_ACTIVATED', message: 'Сотрудник уже использовал приглашение.' }

────────────────────────────────────────────────────────────────
ПУБЛИЧНЫЙ API: ПРОФИЛИ ПРЕПОДАВАТЕЛЕЙ
────────────────────────────────────────────────────────────────

GET /api/sellers/:seller_id/performers
Auth: Public
→ 200: PerformerPublicDto[]  // только active performers

GET /api/items/:item_id/performers
Auth: Public
→ 200: PerformerPublicDto[]  // преподаватели конкретного курса

────────────────────────────────────────────────────────────────
AUTH: СМЕНА ВРЕМЕННОГО ПАРОЛЯ
────────────────────────────────────────────────────────────────

POST /api/auth/change-temp-password
Auth: Bearer (staff с is_temp_password = true)
Body: { new_password: string, confirm_password: string }
→ 200: { success: true }
→ 400: { error: 'PASSWORDS_DO_NOT_MATCH' | 'PASSWORD_TOO_WEAK', message: string }

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

СценарийПоведение
Owner пытается удалить себя400 CANNOT_DELETE_LAST_OWNER
Admin CRM пытается изменить роль403 INSUFFICIENT_ROLE (только Owner меняет роли)
Удаление преподавателя привязанного к айтемамItemPerformerLink каскадно удаляются, предупреждение в UI
Сотрудник входит со старым паролем после смены401 INVALID_CREDENTIALS, стандартная ошибка
Resend invite для уже активного сотрудника400 ALREADY_ACTIVATED
Teacher пытается просмотреть список сотрудников403 INSUFFICIENT_ROLE
Seller заблокирован (account_status = blocked) — его преподавателиPerformerProfile возвращается в публичных API только если seller.account_status = active
Создание сотрудника с email который используется как Buyer409 EMAIL_TAKEN — нет multi-role для staff (только buyer↔seller, не buyer↔staff)
Параллельное добавление двух сотрудников с одним emailUnique constraint в Account.email поймает второй запрос

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

ТемаСтатусПримечание
Вход Teacher в кабинет (MVP)ЧастичноTeacher может войти и видит только /seller/me (свой профиль). Полноценный Teacher-dashboard — v1.0
Роли Manager в MVPЧастичноManager создаётся, но его дополнительные права (CRM) активны только в v1.0
Передача роли OwnerИсключено из MVPТолько через поддержку. Интерфейс — v1.0
Лимит сотрудниковTBDНет лимита на MVP. Нужен ли лимит по тарифу — TBD
SMTP провайдер (email-приглашения)TBDПровайдер не определён
SMS-приглашение (вместо email)Исключено из MVPТолько email
Удаление аккаунта при удалении сотрудникаTBDSoft delete staff_status = archived. Нужно ли также блокировать Account? Да — добавлено в UC-05
Аудит лог (кто создал/изменил сотрудника)Исключено из MVPEventLog/SAA
Фото преподавателя: thumbnail генерацияTBDАналогично Spec 02
Temporary password expiryTBDНужно ли время жизни для временного пароля? (например 7 дней)

Зависимости

МодульСвязь
Spec 01 (Seller Profile)SellerStaff.seller_id → Seller.seller_id
Spec 02 (Items)ItemPerformerLink.seller_staff_id → SellerStaff.staff_id
Spec 04 (Admin)Публичный PerformerProfile виден только при active seller
Spec 12 (Public Profile)GET /api/sellers/:id/performers используется на публичной странице школы
Spec v1.0 (CRM Roles)Полная матрица доступов для Manager и Teacher реализуется в v1.0