openapi: 3.0.3
info:
  title: minibi API
  version: 1.0.0
  description: |
    Публичное API платформы записи к мастерам minibi — каталог мастеров,
    услуги, свободные слоты, отзывы, офферы, мероприятия, гостевая
    онлайн-запись и трекер «Задачи и проекты» (защищённая часть по JWT).

    Основные сценарии:
    * поиск мастеров (по запросу, гео-радиусу, bounding box, рейтингу);
    * карточка мастера, список услуг, рабочие места, расписание;
    * свободные слоты на дату и ближайшие дни с записью;
    * отзывы, офферы (товары), мероприятия;
    * **гостевая онлайн-запись** без регистрации (двухшаговый флоу с кодом
      подтверждения на email);
    * **задачи и проекты**: проекты со статусами-колонками, участниками
      и инвайтами; задачи с исполнителями, плановыми датами, коммента-
      риями, вложениями и связями relates/blocks (JWT; создание проекта —
      тарифная фича Pro).

    Большинство эндпоинтов публичные и не требуют авторизации. Формат ошибок:
    `{"code": "<machine_code>", "error": "<текст>"}`.
    Мутирующие гостевые эндпоинты (`/auth/*`, `/guest/appointments`,
    `/events/{id}/join-guest`) ограничены rate limit'ом 2 запроса/сек,
    burst 5 — при превышении возвращается `429`.

servers:
  - url: https://minibi.pro
    description: Продакшен
  - url: http://localhost:8085
    description: Локальная разработка

tags:
  - name: Мастера
    description: Публичный каталог мастеров, услуги, слоты, расписание, отзывы (/api/v1)
  - name: Лендинг
    description: Данные для лендингов и веб-записи (/api/public)
  - name: Офферы и мероприятия
    description: Публичные офферы (товары) и мероприятия (/api/v2)
  - name: Гостевая запись
    description: Онлайн-запись и участие в мероприятиях без аккаунта
  - name: Аутентификация
    description: Программный доступ к защищённым данным (JWT)
  - name: Задачи и проекты
    description: >
      Защищено JWT из POST /api/v1/auth/login. Создание проекта — тарифная
      фича Pro (без него 403 subscription_required); доступ к задачам —
      через участие в проекте.
  - name: Служебные
    description: Тарифы, юридический манифест, версии приложения

security: []

paths:
  /api/v1/masters/search:
    get:
      tags: [Мастера]
      operationId: searchMasters
      summary: Поиск мастеров
      description: |
        Поиск мастеров по текстовому запросу, гео (радиус или bounding box)
        и минимальному рейтингу. Поддерживает постраничную выдачу (`page`/`limit`
        или курсор `after` при активной персонализации у авторизованного
        зрителя — в этом случае ответ дополняется полями `cursor`/`has_more`).
        С Bearer-токеном выдача персонализируется по интересам пользователя.
      security:
        - {}
        - bearerAuth: []
      parameters:
        - name: query
          in: query
          required: false
          schema: { type: string }
          description: Текстовый запрос (имя, username, хэштег с #).
        - name: lat
          in: query
          required: false
          schema: { type: number, format: double }
          description: Широта центра поиска (с `lng` и `radius_km`).
        - name: lng
          in: query
          required: false
          schema: { type: number, format: double }
          description: Долгота центра поиска.
        - name: radius_km
          in: query
          required: false
          schema: { type: number, format: double }
          description: Радиус поиска в километрах.
        - name: sw_lat
          in: query
          required: false
          schema: { type: number, format: double }
          description: Широта юго-западного угла bounding box.
        - name: sw_lng
          in: query
          required: false
          schema: { type: number, format: double }
          description: Долгота юго-западного угла bounding box.
        - name: ne_lat
          in: query
          required: false
          schema: { type: number, format: double }
          description: Широта северо-восточного угла bounding box.
        - name: ne_lng
          in: query
          required: false
          schema: { type: number, format: double }
          description: Долгота северо-восточного угла bounding box.
        - name: min_rating
          in: query
          required: false
          schema: { type: number, format: double, default: 0 }
          description: Минимальный рейтинг мастера.
        - name: page
          in: query
          required: false
          schema: { type: integer, default: 0 }
          description: Номер страницы (без курсорной пагинации).
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 20 }
          description: Размер страницы.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: Opaque-курсор из `cursor.after` предыдущего ответа.
      responses:
        '200':
          description: Список карточек мастеров.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/MasterCard' }
                  cursor:
                    nullable: true
                    allOf: [{ $ref: '#/components/schemas/Cursor' }]
                    description: Курсор (только при персонализированной выдаче).
                  has_more:
                    type: boolean
                    description: Есть ли ещё результаты (при курсорной пагинации).
                  ranking_version:
                    type: string
                    description: Версия ранжирования (при персонализации).
              example:
                data:
                  - id: 507f1f77bcf86cd799439111
                    first_name: Анна
                    last_name: Смирнова
                    username: anna.nails
                    avatar_url: https://firsts3.ru/avatars/507f1f77bcf86cd799439111.jpg
                    bio: Маникюр и педикюр, студия в центре
                    address: Москва, ул. Тверская, 10
                    latitude: 55.7615
                    longitude: 37.6094
                    rating: 4.9
                    reviews_count: 128
                    price_from: 2500
                    currency: RUB
                    distance_km: 1.2
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/masters/autocomplete:
    get:
      tags: [Мастера]
      operationId: autocompleteMasters
      summary: Автодополнение поиска мастеров
      description: Быстрые подсказки при вводе запроса (минимум 3 символа).
      parameters:
        - name: query
          in: query
          required: true
          schema: { type: string, minLength: 3 }
          description: Префикс запроса (минимум 3 символа).
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 10, maximum: 20 }
          description: Лимит результатов (не более 20).
      responses:
        '200':
          description: Подсказки мастеров и рабочих мест.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AutocompleteResult' }
              example:
                data:
                  - master_id: 507f1f77bcf86cd799439111
                    master_name: Анна Смирнова
                    username: anna.nails
                    avatar_url: https://firsts3.ru/avatars/507f1f77bcf86cd799439111.jpg
                    workspace_id: 507f1f77bcf86cd799439555
                    workspace_name: Студия на Тверской
                    address: Москва, ул. Тверская, 10
                    latitude: 55.7615
                    longitude: 37.6094
                    match_type: name
        '400':
          $ref: '#/components/responses/BadRequest'

  /api/v1/masters/{master_id}:
    get:
      tags: [Мастера]
      operationId: getMasterDetail
      summary: Карточка мастера
      description: |
        Детальная карточка мастера: профиль, услуги, рабочие места.
        Если мастер скрыл публичный профиль (нет согласия на распространение),
        эндпоинт возвращает `404`.
      security:
        - {}
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MasterId'
      responses:
        '200':
          description: Карточка мастера с услугами и рабочими местами.
          content:
            application/json:
              schema:
                type: object
                required: [master, services, workspaces]
                properties:
                  master:
                    type: object
                    additionalProperties: true
                    description: Профиль мастера (поля зависят от выданных согласий).
                  services:
                    type: array
                    items: { $ref: '#/components/schemas/BeautyService' }
                  workspaces:
                    type: array
                    items: { $ref: '#/components/schemas/WorkspaceBrief' }
              example:
                master:
                  id: 507f1f77bcf86cd799439111
                  first_name: Анна
                  last_name: Смирнова
                  username: anna.nails
                  role: master
                  rating: 4.9
                  reviews_count: 128
                  accepts_online_booking: true
                  require_confirmation: false
                  address: Москва, ул. Тверская, 10
                  latitude: 55.7615
                  longitude: 37.6094
                  bio: Маникюр и педикюр
                  subscribers_count: 84
                  subscriptions_count: 12
                services:
                  - id: 507f1f77bcf86cd799439222
                    name: Маникюр с покрытием
                    duration: 90
                    price_from: 2500
                    price_to: 3500
                    currency: RUB
                workspaces:
                  - id: 507f1f77bcf86cd799439555
                    name: Студия на Тверской
                    address: Москва, ул. Тверская, 10
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/masters/{master_id}/services:
    get:
      tags: [Мастера]
      operationId: getMasterServices
      summary: Услуги мастера
      description: Активные услуги мастера. Массив на верхнем уровне.
      parameters:
        - $ref: '#/components/parameters/MasterId'
      responses:
        '200':
          description: Список услуг.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/BeautyService' }
              example:
                - id: 507f1f77bcf86cd799439222
                  master_id: 507f1f77bcf86cd799439111
                  name: Маникюр с покрытием
                  description: Классический маникюр с гель-лаком
                  duration: 90
                  price_from: 2500
                  price_to: 3500
                  currency: RUB
                  is_free: false
                  category: nails
                  is_active: true
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/masters/{master_id}/schedule:
    get:
      tags: [Мастера]
      operationId: getMasterSchedule
      summary: Расписание мастера
      description: Недельное рабочее расписание по дням недели. Массив на верхнем уровне.
      parameters:
        - $ref: '#/components/parameters/MasterId'
      responses:
        '200':
          description: Слоты расписания.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Schedule' }
              example:
                - id: 507f1f77bcf86cd799439333
                  master_id: 507f1f77bcf86cd799439111
                  day_of_week: 1
                  start_time: "10:00"
                  end_time: "19:00"
                  is_active: true
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/masters/{master_id}/slots:
    get:
      tags: [Мастера]
      operationId: getAvailableSlots
      summary: Свободные слоты на дату
      description: |
        Свободные слоты мастера на конкретную дату для набора услуг.
        Итоговая длительность = сумма длительностей услуг. Массив на
        верхнем уровне.
      parameters:
        - $ref: '#/components/parameters/MasterId'
        - name: service_ids
          in: query
          required: true
          schema: { type: string }
          description: |
            CSV идентификаторов услуг, обязательный параметр
            (например `507f...222,507f...223`).
        - name: date
          in: query
          required: true
          schema: { type: string, format: date }
          description: Дата в формате YYYY-MM-DD.
        - name: tz
          in: query
          required: false
          schema: { type: integer, default: 0 }
          description: Смещение часового пояса клиента в минутах (например 180 для UTC+3).
        - name: workspace_id
          in: query
          required: false
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID рабочего места (фильтр слотов).
      responses:
        '200':
          description: Список слотов.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/TimeSlot' }
              example:
                - start: "10:00"
                  end: "11:30"
                  is_booked: false
                  workspace_id: 507f1f77bcf86cd799439555
                  workspace_name: Студия на Тверской
                - start: "11:30"
                  end: "13:00"
                  is_booked: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/masters/{master_id}/next-slots:
    get:
      tags: [Мастера]
      operationId: getNextSlotsPerService
      summary: Ближайший свободный слот по каждой услуге
      description: |
        Map `service_id -> {date, time, timezone}` — ближайшая свободная дата
        и время для каждой активной услуги мастера.
      parameters:
        - $ref: '#/components/parameters/MasterId'
        - name: tz
          in: query
          required: false
          schema: { type: integer, default: 0 }
          description: Смещение часового пояса клиента в минутах.
      responses:
        '200':
          description: Ближайшие слоты по услугам.
          content:
            application/json:
              schema:
                type: object
                additionalProperties:
                  type: object
                  required: [date, time, timezone]
                  properties:
                    date: { type: string, format: date }
                    time: { type: string, example: "10:00" }
                    timezone: { type: string, example: Europe/Moscow }
              example:
                507f1f77bcf86cd799439222:
                  date: "2026-10-17"
                  time: "10:00"
                  timezone: Europe/Moscow
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/masters/{master_id}/available-days:
    get:
      tags: [Мастера]
      operationId: getAvailableDays
      summary: Дни со свободными окнами
      description: |
        Дни в диапазоне `from`–`to`, в которые есть свободные окна для
        заданных услуг. Оба параметра даты обязательны.
      parameters:
        - $ref: '#/components/parameters/MasterId'
        - name: from
          in: query
          required: true
          schema: { type: string, format: date }
          description: Начало диапазона (YYYY-MM-DD).
        - name: to
          in: query
          required: true
          schema: { type: string, format: date }
          description: Конец диапазона (YYYY-MM-DD).
        - name: service_ids
          in: query
          required: false
          schema: { type: string }
          description: CSV идентификаторов услуг.
        - name: workspace_id
          in: query
          required: false
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID рабочего места.
      responses:
        '200':
          description: Дни со свободными окнами.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/AvailableDay' }
              example:
                data:
                  - date: "2026-10-17"
                    free_slots:
                      - time: "10:00"
                        workspace_id: 507f1f77bcf86cd799439555
                        workspace_name: Студия на Тверской
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/masters/{master_id}/reviews:
    get:
      tags: [Мастера]
      operationId: getMasterReviews
      summary: Отзывы о мастере
      description: Отзывы клиентов о мастере с фильтром по рейтингу.
      parameters:
        - $ref: '#/components/parameters/MasterId'
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 20 }
          description: Количество отзывов.
        - name: skip
          in: query
          required: false
          schema: { type: integer, default: 0 }
          description: Смещение (постраничная выдача).
        - name: rating
          in: query
          required: false
          schema: { type: integer, minimum: 0, maximum: 5, default: 0 }
          description: Фильтр по рейтингу (0 — все).
      responses:
        '200':
          description: Список отзывов и общее количество.
          content:
            application/json:
              schema:
                type: object
                required: [reviews, total]
                properties:
                  reviews:
                    type: array
                    items: { $ref: '#/components/schemas/Review' }
                  total: { type: integer }
              example:
                reviews:
                  - id: 507f1f77bcf86cd799439444
                    master_id: 507f1f77bcf86cd799439111
                    client_name: Мария
                    rating: 5
                    text: Отличный маникюр, спасибо!
                    created_at: "2026-09-20T14:30:00Z"
                total: 128
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/masters/{master_id}/reviews/distribution:
    get:
      tags: [Мастера]
      operationId: getRatingDistribution
      summary: Распределение оценок мастера
      description: Количество отзывов по каждой оценке (5..1).
      parameters:
        - $ref: '#/components/parameters/MasterId'
      responses:
        '200':
          description: Распределение оценок.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RatingDistribution' }
              example:
                five: 96
                four: 24
                three: 6
                two: 1
                one: 1
        '404':
          $ref: '#/components/responses/NotFound'

  /api/public/masters:
    get:
      tags: [Лендинг]
      operationId: publicListMasters
      summary: Список публичных мастеров
      description: |
        Постраничный список мастеров с публичным профилем для лендингов.
        Пагинация курсором: `cursor` = ID последнего мастера страницы.
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
          description: Размер страницы (1–100).
        - name: cursor
          in: query
          required: false
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: Курсор (hex24 ID последнего элемента предыдущей страницы).
      responses:
        '200':
          description: Список мастеров.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PublicMastersList' }
        '400':
          $ref: '#/components/responses/BadRequest'

  /api/public/masters/popular:
    get:
      tags: [Лендинг]
      operationId: publicPopularMasters
      summary: Популярные мастера
      description: Топ мастеров по рейтингу (с постами и активными сторис), до 8 записей.
      responses:
        '200':
          description: Список популярных мастеров.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PublicMastersList'

  /api/public/masters/{id}:
    get:
      tags: [Лендинг]
      operationId: publicGetMaster
      summary: Мастер для веб-записи
      description: |
        Публичная карточка мастера для лендинга. С параметром
        `context=booking` отдаётся на основании включённой мастером
        веб-записи (`accepts_online_booking`) — контент (посты/сторис)
        при этом не включается, услуги отдаются.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID мастера (hex24).
        - name: context
          in: query
          required: false
          schema: { type: string, enum: [booking] }
          description: Контекст веб-записи.
      responses:
        '200':
          description: Публичная карточка мастера с услугами.
          content:
            application/json:
              schema:
                type: object
                required: [master, services]
                properties:
                  master: { $ref: '#/components/schemas/PublicMaster' }
                  services:
                    type: array
                    items: { $ref: '#/components/schemas/PublicService' }
              example:
                master:
                  id: 507f1f77bcf86cd799439111
                  first_name: Анна
                  last_name: Смирнова
                  username: anna.nails
                  avatar_url: https://firsts3.ru/avatars/507f1f77bcf86cd799439111.jpg
                  bio: Маникюр и педикюр
                  address: Москва, ул. Тверская, 10
                  accepts_online_booking: true
                  rating: 4.9
                  reviews_count: 128
                  created_at: "2025-03-01T10:00:00Z"
                  updated_at: "2026-10-01T10:00:00Z"
                services:
                  - id: 507f1f77bcf86cd799439222
                    name: Маникюр с покрытием
                    price_from: 2500
                    price_to: 3500
                    currency: RUB
                    is_free: false
                    duration: 90
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/public/masters/{id}/workspaces:
    get:
      tags: [Лендинг]
      operationId: publicGetMasterWorkspaces
      summary: Рабочие места мастера
      description: Публичный список рабочих мест для выбора на странице записи.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID мастера (hex24).
        - name: context
          in: query
          required: false
          schema: { type: string, enum: [booking] }
          description: Контекст веб-записи.
      responses:
        '200':
          description: Рабочие места мастера.
          content:
            application/json:
              schema:
                type: object
                required: [workspaces]
                properties:
                  workspaces:
                    type: array
                    items: { $ref: '#/components/schemas/PublicWorkspace' }
              example:
                workspaces:
                  - id: 507f1f77bcf86cd799439555
                    name: Студия на Тверской
                    address: Москва, ул. Тверская, 10
                    is_default: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/public/masters/{id}/posts:
    get:
      tags: [Лендинг]
      operationId: publicGetMasterPosts
      summary: Посты мастера (референсы)
      description: |
        Постраничный список ссылок на посты мастера (ID + дата создания).
        Полное содержимое поста — `GET /api/v1/masters/{master_id}/posts/{post_id}`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID мастера (hex24).
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 100, minimum: 1, maximum: 100 }
          description: Размер страницы (1–100).
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: Opaque-курсор из `cursor.after` предыдущего ответа.
      responses:
        '200':
          description: Страница постов.
          content:
            application/json:
              schema:
                type: object
                required: [posts, cursor, has_more]
                properties:
                  posts:
                    type: array
                    items:
                      type: object
                      required: [id, created_at]
                      properties:
                        id: { type: string }
                        created_at: { type: string, format: date-time }
                  cursor: { $ref: '#/components/schemas/Cursor' }
                  has_more: { type: boolean }
              example:
                posts:
                  - id: 507f1f77bcf86cd799439666
                    created_at: "2026-10-10T09:00:00Z"
                cursor: { after: eyJjcmVhdGVkX2F0IjoiMjAyNi0xMC0xMFQwOTowMDowMFoifQ }
                has_more: false
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/public/masters/{id}/reviews:
    get:
      tags: [Лендинг]
      operationId: publicGetMasterReviews
      summary: Последние отзывы мастера
      description: Короткий список последних отзывов (по умолчанию 3, максимум 10).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID мастера (hex24).
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 3, minimum: 1, maximum: 10 }
          description: Количество отзывов.
      responses:
        '200':
          description: Отзывы.
          content:
            application/json:
              schema:
                type: object
                required: [reviews, total]
                properties:
                  reviews:
                    type: array
                    items: { $ref: '#/components/schemas/PublicReview' }
                  total: { type: integer }
                  cursor:
                    type: string
                    description: Зарезервировано.
                  has_more: { type: boolean }
              example:
                reviews:
                  - id: 507f1f77bcf86cd799439444
                    author_name: Мария
                    rating: 5
                    text: Отличный маникюр!
                    created_at: "2026-09-20T14:30:00Z"
                total: 128
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/offers:
    get:
      tags: [Офферы и мероприятия]
      operationId: listOffers
      summary: Опубликованные офферы
      description: |
        Лента опубликованных офферов (товаров) с курсорной пагинацией.
      parameters:
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: Курсор из предыдущего ответа.
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 20 }
          description: Размер страницы.
        - name: type
          in: query
          required: false
          schema: { type: string, enum: [product] }
          description: Тип оффера (сейчас только product).
        - name: category
          in: query
          required: false
          schema: { type: string }
          description: Категория оффера.
        - name: hashtags
          in: query
          required: false
          schema:
            type: array
            items: { type: string }
          style: form
          explode: true
          description: Фильтр по хэштегам (повторяющийся параметр).
      responses:
        '200':
          description: Страница офферов.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OfferPage' }
        '400':
          $ref: '#/components/responses/BadRequest'

  /api/v2/offers/{id}:
    get:
      tags: [Офферы и мероприятия]
      operationId: getOffer
      summary: Карточка оффера
      description: |
        Опубликованный оффер с карточкой товара и способами получения
        (fulfillment_methods). Ответ кешируется (ETag / Last-Modified).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID оффера (hex24).
      responses:
        '200':
          description: Оффер.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Offer' }
          headers:
            ETag:
              schema: { type: string }
              description: Тег версии оффера.
            Cache-Control:
              schema: { type: string }
              description: 'max-age=120, stale-while-revalidate=300'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/masters/{master_id}/offers:
    get:
      tags: [Офферы и мероприятия]
      operationId: listMasterOffers
      summary: Офферы мастера
      description: Опубликованные офферы конкретного мастера (курсорная пагинация).
      parameters:
        - $ref: '#/components/parameters/MasterId'
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: Курсор из предыдущего ответа.
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 20 }
          description: Размер страницы.
      responses:
        '200':
          description: Страница офферов мастера.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OfferPage' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/events/{id}:
    get:
      tags: [Офферы и мероприятия]
      operationId: getEvent
      summary: Карточка мероприятия
      description: |
        Активное мероприятие мастера. С Bearer-токеном ответ дополняется
        полем `my_status` (статус участия текущего пользователя).
      security:
        - {}
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID мероприятия (hex24).
      responses:
        '200':
          description: Мероприятие.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Event' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/masters/{master_id}/events:
    get:
      tags: [Офферы и мероприятия]
      operationId: listMasterEvents
      summary: Мероприятия мастера
      description: Активные мероприятия мастера (курсорная пагинация).
      parameters:
        - $ref: '#/components/parameters/MasterId'
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: Курсор из предыдущего ответа.
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 20 }
          description: Размер страницы.
      responses:
        '200':
          description: Страница мероприятий.
          content:
            application/json:
              schema:
                type: object
                required: [data, cursor, has_more]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Event' }
                  cursor: { $ref: '#/components/schemas/Cursor' }
                  has_more: { type: boolean }
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v2/events/{id}/join-guest:
    post:
      tags: [Гостевая запись]
      operationId: joinEventGuest
      summary: Гостевое участие в мероприятии
      description: |
        Запись на мероприятие без аккаунта. Двухшаговый флоу:

        1. `POST /api/v1/auth/send-code` с `{"identifier": "<email>"}` —
           гость получает код подтверждения на email (email может быть
           незарегистрирован). Легаси-путь: поле `phone` + SMS-код.
        2. `POST /api/v2/events/{id}/join-guest` с этим `code`.

        Существующему пользователю проставляется `email_verified`, при
        отсутствии аккаунта — создаётся новый (роль client).
        Rate limit: 2 запроса/сек, burst 5 (`429`).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID мероприятия (hex24).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, first_name]
              properties:
                email:
                  type: string
                  format: email
                  description: Email гостя (основной путь; обязателен email или phone).
                phone:
                  type: string
                  description: 'Телефон в E.164 (легаси SMS-путь).'
                code:
                  type: string
                  description: Код подтверждения из шага 1.
                first_name:
                  type: string
                  description: Имя гостя.
                last_name:
                  type: string
                  description: Фамилия гостя (опционально).
            example:
              email: guest@example.com
              code: "482913"
              first_name: Мария
              last_name: Иванова
      responses:
        '201':
          description: Участие создано.
          content:
            application/json:
              schema:
                type: object
                required: [status]
                properties:
                  status:
                    type: string
                    enum: [pending, confirmed]
                    description: Статус участия (pending — требуется подтверждение мастера).
              example:
                status: confirmed
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/guest/appointments:
    post:
      tags: [Гостевая запись]
      operationId: createGuestAppointment
      summary: Гостевая онлайн-запись к мастеру
      description: |
        Главный сценарий интеграции: создание записи к мастеру без аккаунта.

        **Двухшаговый флоу:**

        1. `POST /api/v1/auth/send-code` с `{"identifier": "<email>"}` —
           сервер шлёт код подтверждения на email гостя (незарегистрированный
           email допустим; кулдаун 60 сек, 3 кода/5 мин). Легаси-путь —
           `{"phone": "+7900..."}` с SMS-кодом.
        2. `POST /api/v1/guest/appointments` с полученным `code`.

        Требования:
        * хотя бы один контакт: `client_email` или `client_phone`;
        * `password`/`password_confirmation` — пароль создаваемого аккаунта
          (8+ символов, заглавная + строчная буква + цифра);
        * если email уже занят существующим аккаунтом — `409 user_already_exists`
          (гостевая запись никогда не прикрепляется к чужому аккаунту);
        * слот должен быть свободен на момент запроса — при конфликте
          `409 time_slot_not_available`;
        * rate limit: 2 запроса/сек, burst 5 (`429`).

        Ответ — созданная запись (enriched). Поле `auth_pair` отдаётся только
        доверенному прокси лендинга.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [master_id, service_ids, start_time, client_name, code, password, password_confirmation]
              properties:
                master_id:
                  type: string
                  pattern: '^[a-f0-9]{24}$'
                  description: ID мастера.
                service_ids:
                  type: array
                  minItems: 1
                  items: { type: string, pattern: '^[a-f0-9]{24}$' }
                  description: Идентификаторы услуг.
                start_time:
                  type: string
                  format: date-time
                  description: Время начала записи (ISO 8601, UTC).
                client_name:
                  type: string
                  description: Имя клиента.
                client_email:
                  type: string
                  format: email
                  description: Email гостя (основной путь; обязателен email или phone).
                client_phone:
                  type: string
                  description: 'Телефон в E.164 (легаси SMS-путь).'
                workspace_id:
                  type: string
                  pattern: '^[a-f0-9]{24}$'
                  description: ID рабочего места.
                code:
                  type: string
                  description: Код подтверждения (шаг 1).
                password:
                  type: string
                  minLength: 8
                  description: Пароль создаваемого аккаунта.
                password_confirmation:
                  type: string
                  minLength: 8
                  description: Подтверждение пароля.
                note:
                  type: string
                  description: Комментарий к записи (виден мастеру).
                source:
                  type: string
                  description: Источник записи (маркировка интеграции).
                utm_source:
                  type: string
                  description: UTM-метка source.
                utm_medium:
                  type: string
                  description: UTM-метка medium.
                utm_campaign:
                  type: string
                  description: UTM-метка campaign.
                terms_acceptance:
                  allOf: [{ $ref: '#/components/schemas/LegalConsentInput' }]
                  description: Согласие с условиями (версии — из /api/v1/legal/manifest).
                personal_data_consent:
                  allOf: [{ $ref: '#/components/schemas/LegalConsentInput' }]
                  description: Согласие на обработку персональных данных.
            example:
              master_id: 507f1f77bcf86cd799439111
              service_ids: [507f1f77bcf86cd799439222]
              start_time: "2026-10-17T07:00:00Z"
              client_name: Мария Иванова
              client_email: guest@example.com
              workspace_id: 507f1f77bcf86cd799439555
              code: "482913"
              password: Guest1234
              password_confirmation: Guest1234
              note: Хочу французский маникюр
              source: partner_widget
              utm_source: newsletter
              utm_medium: email
              utm_campaign: october
      responses:
        '201':
          description: Запись создана.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GuestAppointmentResponse' }
        '400':
          description: |
            Ошибка валидации. Коды: `invalid_service_id`, `invalid_workspace`,
            `code_invalid`, `invalid_phone`, `invalid_email`,
            `invalid_identifier` (нет контакта), `password_to_short`,
            `outside_working_hours`, `day_off`, `self_booking`,
            `invalid_time_range`, `workspace_required`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: code_invalid
                error: Неверный или истекший код
        '404':
          description: Мастер или услуга не найдены (`master not found` / `service_not_found`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: |
            Конфликт. Коды: `user_already_exists` (email занят),
            `time_slot_not_available`, `client_schedule_conflict`,
            `master_appointments_conflict`, `online_booking_disabled`,
            `break_conflict`.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: time_slot_not_available
                error: Выбранное время уже занято
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/auth/login:
    post:
      tags: [Аутентификация]
      operationId: login
      summary: Вход по идентификатору и паролю
      description: |
        Вход по email / телефону (E.164) / username и паролю. Возвращает
        пару JWT-токенов и профиль пользователя. Rate limit 2 запроса/сек.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password]
              properties:
                identifier:
                  type: string
                  description: Email, телефон E.164 или @username.
                phone:
                  type: string
                  description: 'Легаси-поле: телефон E.164 (используется, если identifier пуст).'
                password:
                  type: string
                  format: password
                role:
                  type: string
                  enum: [master, client]
                  description: Активная роль (для dual-role пользователей).
            example:
              identifier: user@example.com
              password: Secret123
      responses:
        '200':
          description: Токены и профиль.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '428':
          description: Нужен дополнительный шаг (пароль не установлен или email не подтверждён).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                error: password_not_set
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/auth/refresh-token:
    post:
      tags: [Аутентификация]
      operationId: refreshToken
      summary: Обновление токена
      description: Обмен refresh-токена на новую пару токенов.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [refresh_token]
              properties:
                refresh_token: { type: string }
            example:
              refresh_token: 8f1c2b3d4e5f6a7b8c9d0e1f2a3b4c5d
      responses:
        '200':
          description: Новая пара токенов.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/auth/register:
    post:
      tags: [Аутентификация]
      operationId: register
      summary: Регистрация
      description: |
        Регистрация нового пользователя. Обязательные поля: `email` (уникальный
        идентификатор), `password` (8+ символов, заглавная + строчная + цифра),
        `first_name`, `last_name`, `role` (master|client), `gender`
        (male|female) и обязательные согласия `terms_acceptance` /
        `personal_data_consent` — объекты с `accepted: true`, `version` и
        `locale` по текущему манифесту (`GET /api/v1/legal/manifest`).
        `phone` опционален (контактное поле, без уникальности).
        Ответ 201 — сразу с парой токенов.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RegisterRequest' }
      responses:
        '201':
          description: Пользователь создан, токены выданы.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AuthResponse' }
        '400':
          description: |
            Ошибка валидации. В т.ч. слабый пароль, неверный формат
            email/телефона, отсутствие обязательных юридических согласий
            (`legal_consent_required`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '409':
          description: Email или username уже заняты.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: email_taken
                error: Этот email уже зарегистрирован
        '429':
          $ref: '#/components/responses/RateLimited'

  /api/v1/auth/send-code:
    post:
      tags: [Аутентификация]
      operationId: sendCode
      summary: Отправка кода подтверждения
      description: |
        Шаг 1 гостевого флоу. Отправляет код подтверждения (6 цифр, TTL 15 мин)
        на email. `identifier` — email (в т.ч. незарегистрированный — для
        гостевой записи), либо phone/username зарегистрированного аккаунта.
        Легаси: поле `phone` → SMS.
        Ограничения: кулдаун 60 сек, 3 кода за 5 минут, per-IP лимиты;
        rate limit 2 запроса/сек.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                identifier:
                  type: string
                  description: Email / телефон / username получателя кода.
                phone:
                  type: string
                  description: 'Легаси-поле: телефон E.164 для SMS-кода.'
            example:
              identifier: guest@example.com
      responses:
        '200':
          description: Код отправлен.
          content:
            application/json:
              schema:
                type: object
                required: [sent]
                properties:
                  sent: { type: boolean }
              example:
                sent: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          description: Превышен лимит отправок (`code_rate_limit`, `max_sends_exceeded`, `sends_per_ip_exceeded`).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/subscriptions/plans:
    get:
      tags: [Служебные]
      operationId: getSubscriptionPlans
      summary: Тарифные планы
      description: Список тарифных планов подписки мастеров (Basic/Premium/Pro).
      responses:
        '200':
          description: Тарифы.
          content:
            application/json:
              schema:
                type: object
                required: [plans]
                properties:
                  plans:
                    type: array
                    items: { $ref: '#/components/schemas/SubscriptionPlan' }
              example:
                plans:
                  - id: 507f1f77bcf86cd799439777
                    key: basic
                    name: Basic
                    price: 0
                    currency: RUB
                    duration_days: 30
                    is_active: true
                    sort_order: 1

  /api/v1/legal/manifest:
    get:
      tags: [Служебные]
      operationId: getLegalManifest
      summary: Юридический манифест
      description: |
        Актуальные версии юридических документов (условия, персональные данные)
        и поддерживаемые локали. Версии используются в согласиях при
        регистрации и гостевой записи.
      responses:
        '200':
          description: Манифест.
          content:
            application/json:
              schema:
                type: object
                required: [schema_version, supported_locales, documents]
                properties:
                  schema_version: { type: integer }
                  supported_locales:
                    type: array
                    items: { type: string }
                  documents:
                    type: object
                    additionalProperties: true
                    description: Map kind -> документ (version, effective_date, published, url и т.п.).
              example:
                schema_version: 2
                supported_locales: [ru, en, it]
                documents:
                  terms:
                    version: "2.1"
                    effective_date: "2026-09-01"
                    published: true
                  personal_data:
                    version: "2.1"
                    effective_date: "2026-09-01"
                    published: true

  /api/v1/app/version:
    get:
      tags: [Служебные]
      operationId: getAppVersion
      summary: Версии мобильного приложения
      description: Минимальные и актуальные версии приложений + ссылки на сторы.
      responses:
        '200':
          description: Версии.
          content:
            application/json:
              schema:
                type: object
                required: [ios, android]
                properties:
                  ios: { $ref: '#/components/schemas/AppPlatformVersion' }
                  android: { $ref: '#/components/schemas/AppPlatformVersion' }
              example:
                ios:
                  min_version: "1.4.0"
                  latest_version: "1.6.2"
                  store_url: https://apps.apple.com/app/minibi/id123456789
                android:
                  min_version: "1.4.0"
                  latest_version: "1.6.2"
                  store_url: https://play.google.com/store/apps/details?id=com.minibi

  /api/v2/projects:
    get:
      tags: [Задачи и проекты]
      operationId: listProjects
      summary: Мои проекты
      description: |
        Проекты текущего пользователя (где он владелец или участник) —
        обёртка `{"data": [Project]}`. В элементах списка `tasksCount`
        всегда 0 (счётчик задач заполняется только в карточке проекта) и
        нет `createdAt`.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Список проектов.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/Project' }
              example:
                data:
                  - id: 507f1f77bcf86cd799439901
                    ownerId: 507f1f77bcf86cd799439111
                    title: Студия — производство
                    description: Задачи студии на ноябрь
                    color: "#E1306C"
                    statuses:
                      - { id: 507f1f77bcf86cd799439a01, name: К выполнению, color: "#0095F6", position: 0, isDone: false, isDefault: true }
                      - { id: 507f1f77bcf86cd799439a02, name: В работе, color: "#F5A623", position: 1, isDone: false, isDefault: false }
                      - { id: 507f1f77bcf86cd799439a03, name: Готово, color: "#34C759", position: 2, isDone: true, isDefault: false }
                    owner:
                      id: 507f1f77bcf86cd799439111
                      firstName: Анна
                      lastName: Смирнова
                      username: anna.nails
                      role: master
                    members:
                      - { id: 507f1f77bcf86cd799439211, firstName: Мария, lastName: Иванова, role: member }
                    isArchived: false
                    tasksCount: 0
                    updatedAt: "2026-10-15T09:00:00Z"
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      tags: [Задачи и проекты]
      operationId: createProject
      summary: Создать проект
      description: |
        Создание проекта — **только для роли master** (RoleMiddleware) и
        **тарифная фича Pro** (SubscriptionMiddleware): без Pro —
        `403 subscription_required`. За фичу платит владелец: чтение,
        участие и работа с задачами тарифом не ограничены.
        Заголовок `Idempotency-Key` опционален (мутации задач/проектов
        дедуплицируются по нему, без заголовка middleware пассивна).
        Новому проекту создаётся дефолтный набор статусов.
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: Опциональный ключ идемпотентности.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                  minLength: 1
                  maxLength: 100
                  description: Название проекта (1–100 символов).
                description: { type: string }
                color:
                  type: string
                  pattern: '^#[0-9A-Fa-f]{6}$'
                  description: Цвет проекта (#RRGGBB).
            example:
              title: Студия — производство
              description: Задачи студии на ноябрь
              color: "#E1306C"
      responses:
        '201':
          description: Проект создан.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            Нет прав. Коды: `subscription_required` (создание проекта —
            фича тарифа Pro), либо текущая роль не master.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: subscription_required
                error: Функция доступна на тарифе Pro

  /api/v2/projects/{id}:
    get:
      tags: [Задачи и проекты]
      operationId: getProject
      summary: Карточка проекта
      description: |
        Проект со статусами, владельцем, участниками и счётчиком
        неархивных задач (`tasksCount`). Ответ кешируется (ETag /
        Last-Modified, private).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      responses:
        '200':
          description: Проект.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
          headers:
            ETag:
              schema: { type: string }
              description: Тег версии проекта.
            Cache-Control:
              schema: { type: string }
              description: 'private, max-age=120, stale-while-revalidate=300'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` — пользователь не участник проекта.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_project_not_found` — проект не найден.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

    patch:
      tags: [Задачи и проекты]
      operationId: updateProject
      summary: Обновить проект
      description: |
        Частичное обновление (owner/admin). `null`/отсутствие поля — не
        менять. Архивация — `is_archived: true`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, minLength: 1, maxLength: 100 }
                description: { type: string }
                color: { type: string, pattern: '^#[0-9A-Fa-f]{6}$' }
                is_archived: { type: boolean }
            example:
              title: Студия — производство (декабрь)
              is_archived: false
      responses:
        '200':
          description: Обновлённый проект.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            `not_project_member` (не участник) или `task_action_forbidden`
            (нет прав на изменение).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_project_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

    delete:
      tags: [Задачи и проекты]
      operationId: deleteProject
      summary: Удалить проект
      description: "Удаление проекта (owner). Ответ — `{\"ok\": true}`."
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      responses:
        '200':
          description: Проект удалён.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
              example:
                ok: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: '`task_project_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

  /api/v2/projects/{id}/statuses:
    put:
      tags: [Задачи и проекты]
      operationId: updateProjectStatuses
      summary: Перезаписать набор статусов проекта
      description: |
        **Полная перезапись** набора статусов-колонок: порядок статусов =
        индексу в массиве (`position` = index). Элемент с `id` —
        существующий статус (сохраняет _id), без `id` — новый.
        Меняет набор колонок канбана; `is_done`-статусы участвуют в
        отчётах и признаке завершённости задач, `is_default` — статус,
        куда падают новые задачи.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [statuses]
              properties:
                statuses:
                  type: array
                  items: { $ref: '#/components/schemas/TaskStatusDefInput' }
            example:
              statuses:
                - { id: 507f1f77bcf86cd799439a01, name: К выполнению, color: "#0095F6", is_default: true }
                - { name: В работе, color: "#F5A623" }
                - { name: Готово, color: "#34C759", is_done: true }
      responses:
        '200':
          description: Обновлённый проект с новым набором статусов.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` / `task_action_forbidden`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_action_forbidden
                error: недостаточно прав для этого действия
        '404':
          description: '`task_project_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

  /api/v2/projects/{id}/members:
    post:
      tags: [Задачи и проекты]
      operationId: addProjectMember
      summary: Добавить участника
      description: |
        Добавление участника (owner/admin) по идентификатору —
        email / телефон E.164 / username. `role` — `admin` | `member`
        (default `member`). Пользователь должен существовать (для
        не-юзеров используйте инвайты).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identifier]
              properties:
                identifier:
                  type: string
                  description: Email / телефон E.164 / username пользователя.
                role:
                  type: string
                  enum: [admin, member]
                  default: member
                  description: Роль участника в проекте.
            example:
              identifier: maria@example.com
              role: member
      responses:
        '200':
          description: Обновлённый проект с новым участником.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Project' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` / `task_action_forbidden`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_action_forbidden
                error: недостаточно прав для этого действия
        '404':
          description: Пользователь или проект не найдены (`task_project_not_found` и др.).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

  /api/v2/projects/{id}/members/{user_id}:
    delete:
      tags: [Задачи и проекты]
      operationId: removeProjectMember
      summary: Удалить участника
      description: |
        Удаление участника из проекта (owner/admin, нельзя удалить
        владельца). Ответ — `{"ok": true}`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectId'
        - name: user_id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID пользователя (hex24).
      responses:
        '200':
          description: Участник удалён.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
              example:
                ok: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` / `task_action_forbidden`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_action_forbidden
                error: недостаточно прав для этого действия
        '404':
          description: '`task_project_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

  /api/v2/projects/{id}/invites:
    post:
      tags: [Задачи и проекты]
      operationId: inviteToProject
      summary: Пригласить в проект
      description: |
        Приглашение (owner/admin) по идентификатору — email / телефон
        E.164 / username. Зарегистрированному пользователю уходит
        чат-карточка (`sent_to_chat`), незарегистрированному — email
        (`sent_via_email`; тогда identifier обязан быть email). Инвайт
        всегда создаётся в статусе pending. Заголовок
        `Idempotency-Key` опционален (двойной тап дедуплицируется).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identifier]
              properties:
                identifier:
                  type: string
                  description: Email / телефон E.164 / username получателя.
            example:
              identifier: guest@example.com
      responses:
        '201':
          description: Приглашение отправлено (статус доставки, не статус инвайта).
          content:
            application/json:
              schema:
                type: object
                required: [invite_id, status]
                properties:
                  invite_id: { type: string }
                  status:
                    type: string
                    enum: [sent_to_chat, sent_via_email]
                    description: Канал доставки приглашения.
              example:
                invite_id: 507f1f77bcf86cd799439b01
                status: sent_via_email
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` / `task_action_forbidden`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_action_forbidden
                error: недостаточно прав для этого действия
        '404':
          description: '`task_project_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

  /api/v2/projects/{id}/report:
    get:
      tags: [Задачи и проекты]
      operationId: getProjectReport
      summary: Отчёт проекта
      description: |
        Расчётный отчёт (участнику проекта): per-task строки (время в
        статусах, план-vs-факт) + сводка. `from`/`to` — RFC3339,
        фильтруют задачи по `created_at` (не историю). Кеш запрещён —
        `Cache-Control: no-store` (длительности считаются до now).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/ProjectId'
        - name: from
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: 'Начало периода (RFC3339): фильтр задач по created_at.'
        - name: to
          in: query
          required: false
          schema: { type: string, format: date-time }
          description: 'Конец периода (RFC3339): фильтр задач по created_at.'
      responses:
        '200':
          description: Отчёт проекта.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ProjectReport' }
          headers:
            Cache-Control:
              schema: { type: string }
              description: no-store
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_project_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

  /api/v2/project_invites/{invite_id}/accept:
    post:
      tags: [Задачи и проекты]
      operationId: acceptProjectInvite
      summary: Принять приглашение в проект
      description: |
        Принятие приглашения аутентифицированным получателем: пользователь
        добавляется в проект ролью `member`, чат-карточка приглашения
        обновляется. Заголовок `Idempotency-Key` опционален.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/InviteId'
      responses:
        '200':
          description: Приглашение принято.
          content:
            application/json:
              schema:
                type: object
                required: [invite_id, status]
                properties:
                  invite_id: { type: string }
                  status:
                    type: string
                    enum: [accepted]
              example:
                invite_id: 507f1f77bcf86cd799439b01
                status: accepted
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Приглашение не найдено или истекло.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v2/project_invites/{invite_id}/decline:
    post:
      tags: [Задачи и проекты]
      operationId: declineProjectInvite
      summary: Отклонить приглашение в проект
      description: |
        Отклонение приглашения получателем. Заголовок
        `Idempotency-Key` опционален.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/InviteId'
      responses:
        '200':
          description: Приглашение отклонено.
          content:
            application/json:
              schema:
                type: object
                required: [invite_id, status]
                properties:
                  invite_id: { type: string }
                  status:
                    type: string
                    enum: [declined]
              example:
                invite_id: 507f1f77bcf86cd799439b01
                status: declined
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Приглашение не найдено или истекло.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v2/my/project_invites:
    get:
      tags: [Задачи и проекты]
      operationId: listMyProjectInvites
      summary: Мои активные приглашения в проекты
      description: |
        Pending-приглашения текущего пользователя (не истёкшие) с
        enrichment — карточкой проекта и пригласившим. Поля — snake_case.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Список приглашений.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ProjectInvite' }
              example:
                data:
                  - id: 507f1f77bcf86cd799439b01
                    project_id: 507f1f77bcf86cd799439901
                    project:
                      id: 507f1f77bcf86cd799439901
                      title: Студия — производство
                      color: "#E1306C"
                      owner: { id: 507f1f77bcf86cd799439111, firstName: Анна, lastName: Смирнова }
                    created_at: "2026-10-15T09:00:00Z"
                    expires_at: "2026-10-22T09:00:00Z"
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/tasks:
    get:
      tags: [Задачи и проекты]
      operationId: listTasks
      summary: Список задач
      description: |
        С `project_id` — задачи проекта (доступ участнику), сортировка
        `position` (канбан, дефолт) или `updated` (updated_at desc).
        Без `project_id` — режим «Все проекты»: задачи всех проектов
        текущего пользователя, сортировка всегда `updated_at desc`.
        Курсорная пагинация: `data` / `cursor` / `has_more`.
      security:
        - bearerAuth: []
      parameters:
        - name: project_id
          in: query
          required: false
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID проекта (без него — все проекты пользователя).
        - name: status_id
          in: query
          required: false
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: Фильтр по статусу (именно status_id, не status).
        - name: assignee_id
          in: query
          required: false
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: Фильтр по исполнителю.
        - name: mine
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: 'Только мои задачи (creator или assignee = текущий пользователь).'
        - name: include_archived
          in: query
          required: false
          schema: { type: boolean, default: false }
          description: Включить архивные задачи.
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
          description: Размер страницы (1–50).
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: Opaque-курсор из `cursor.after` предыдущего ответа.
        - name: sort
          in: query
          required: false
          schema: { type: string, enum: [position, updated], default: position }
          description: |
            Сортировка (только с project_id): `position` — канбан-порядок,
            `updated` — updated_at desc. Без project_id всегда updated_at desc.
      responses:
        '200':
          description: Страница задач.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TasksPage' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` — не участник проекта.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_project_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

    post:
      tags: [Задачи и проекты]
      operationId: createTask
      summary: Создать задачу
      description: |
        Создание задачи в проекте (участником). Статус всегда
        `is_default`-статус проекта, `position` считается сервером (max+1024)
        — эти поля в теле не передаются. Поля запроса — snake_case.
        Заголовок `Idempotency-Key` опционален.
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: Опциональный ключ идемпотентности.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [project_id, title]
              properties:
                project_id:
                  type: string
                  pattern: '^[a-f0-9]{24}$'
                  description: ID проекта.
                title:
                  type: string
                  minLength: 1
                  description: Название задачи.
                description: { type: string }
                assignee_id:
                  type: string
                  nullable: true
                  description: Исполнитель (участник проекта); null/нет — без исполнителя.
                planned_start_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: Плановое начало.
                planned_end_at:
                  type: string
                  format: date-time
                  nullable: true
                  description: Плановое окончание (дедлайн).
            example:
              project_id: 507f1f77bcf86cd799439901
              title: Собрать December-коллекцию
              description: Эскизы, образцы, фотосессия
              assignee_id: 507f1f77bcf86cd799439211
              planned_start_at: "2026-11-01T09:00:00Z"
              planned_end_at: "2026-11-20T18:00:00Z"
      responses:
        '201':
          description: Задача создана.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` / `task_action_forbidden`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_project_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_project_not_found
                error: проект не найден

  /api/v2/my/tasks:
    get:
      tags: [Задачи и проекты]
      operationId: listMyTasks
      summary: Мои открытые задачи
      description: |
        Открытые задачи пользователя для дашборда: строго где он creator
        или assignee (комментаторы не попадают), не архив, статус не
        `is_done`, updated_at desc. Обёртка `{"data": [...]}` без
        курсора.
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 5 }
          description: Количество задач (1–50, по умолчанию 5).
      responses:
        '200':
          description: Открытые задачи.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TaskSummary' }
              example:
                data:
                  - id: 507f1f77bcf86cd799439c01
                    projectId: 507f1f77bcf86cd799439901
                    projectTitle: Студия — производство
                    title: Собрать December-коллекцию
                    creatorId: 507f1f77bcf86cd799439111
                    statusId: 507f1f77bcf86cd799439a01
                    statusName: К выполнению
                    statusColor: "#0095F6"
                    statusIsDone: false
                    position: 1024
                    isArchived: false
                    attachments: []
                    participantIds: [507f1f77bcf86cd799439111]
                    commentsCount: 0
                    attachmentsCount: 0
                    linksCount: 0
                    isOverdue: false
                    createdAt: "2026-10-15T10:00:00Z"
                    updatedAt: "2026-10-15T10:00:00Z"
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v2/tasks/{id}:
    get:
      tags: [Задачи и проекты]
      operationId: getTask
      summary: Карточка задачи
      description: |
        Полная карточка задачи (участникам проекта): TaskSummary +
        `description` + `attachments[]`. Ответ кешируется (ETag,
        private, max-age=30).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
      responses:
        '200':
          description: Задача.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
          headers:
            ETag:
              schema: { type: string }
              description: Тег версии задачи.
            Cache-Control:
              schema: { type: string }
              description: 'private, max-age=30, stale-while-revalidate=120'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_not_found` — задача не найдена.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

    patch:
      tags: [Задачи и проекты]
      operationId: updateTask
      summary: Обновить задачу
      description: |
        Частичное обновление (те же поля, что при создании, кроме
        `project_id`, плюс `status_id`/`position`/`is_archived`).
        Отсутствие поля — не менять; явный `null` для `assignee_id` /
        `planned_start_at` / `planned_end_at` — очистить. Права:
        title/description/даты/assignee/is_archived — owner/admin/creator;
        status_id/position — любой участник. Заголовок
        `Idempotency-Key` опционален.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string, minLength: 1 }
                description: { type: string }
                assignee_id:
                  type: string
                  nullable: true
                  description: Исполнитель; null — снять исполнителя.
                status_id:
                  type: string
                  pattern: '^[a-f0-9]{24}$'
                  description: Новый статус (из статусов проекта).
                position:
                  type: number
                  description: Позиция в канбане (double, вставка между соседями = (prev+next)/2).
                planned_start_at:
                  type: string
                  format: date-time
                  nullable: true
                planned_end_at:
                  type: string
                  format: date-time
                  nullable: true
                is_archived: { type: boolean }
            example:
              status_id: 507f1f77bcf86cd799439a02
              position: 1536
      responses:
        '200':
          description: Обновлённая задача.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: |
            `not_project_member` или `task_action_forbidden` (например,
            не-creator меняет title).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_action_forbidden
                error: недостаточно прав для этого действия с задачей
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

    delete:
      tags: [Задачи и проекты]
      operationId: deleteTask
      summary: Удалить задачу
      description: "Удаление задачи. Ответ — `{\"ok\": true}`."
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
      responses:
        '200':
          description: Задача удалена.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
              example:
                ok: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` / `task_action_forbidden`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_action_forbidden
                error: недостаточно прав для этого действия с задачей
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

  /api/v2/tasks/{id}/history:
    get:
      tags: [Задачи и проекты]
      operationId: getTaskHistory
      summary: История статусов задачи
      description: |
        Таймлайн (участнику проекта): записи истории переходов статусов,
        вычисленные длительности по статусам (последний интервал растёт
        до now) и общий возраст задачи. Кеш запрещён — no-store.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
      responses:
        '200':
          description: Таймлайн задачи.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskTimeline' }
          headers:
            Cache-Control:
              schema: { type: string }
              description: no-store
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

  /api/v2/tasks/{id}/attachments:
    post:
      tags: [Задачи и проекты]
      operationId: addTaskAttachment
      summary: Прикрепить файл к задаче
      description: |
        Вложение (участник): `url` — файл из хранилища задач (загруженный
        через `POST /api/v2/tasks/media`, префикс `tasks/`), `file_name`
        (1–255) обязателен. Возвращает обновлённую задачу.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, file_name]
              properties:
                url:
                  type: string
                  description: URL файла из POST /api/v2/tasks/media.
                file_name:
                  type: string
                  minLength: 1
                  maxLength: 255
                  description: Имя файла (1–255 символов).
            example:
              url: https://firsts3.ru/tasks/507f1f77bcf86cd799439c01/photo_8f3a2b.jpg
              file_name: эскиз-1.jpg
      responses:
        '201':
          description: Вложение добавлено (обновлённая задача).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

  /api/v2/tasks/{id}/attachments/{attachment_id}:
    delete:
      tags: [Задачи и проекты]
      operationId: removeTaskAttachment
      summary: Удалить вложение задачи
      description: |
        Удаление вложения (owner/admin/автор вложения). Возвращает
        обновлённую задачу (тот же контракт, что у добавления).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
        - name: attachment_id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID вложения (hex24).
      responses:
        '200':
          description: Вложение удалено (обновлённая задача).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Task' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` / `task_action_forbidden`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_action_forbidden
                error: недостаточно прав для этого действия с задачей
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

  /api/v2/tasks/{id}/links:
    get:
      tags: [Задачи и проекты]
      operationId: listTaskLinks
      summary: Связи задачи
      description: |
        Связи задачи (участнику): оба направления, `direction`
        (outgoing/incoming) — относительно запрошенной задачи,
        «другая» задача связи — mini-task (TaskSummary). Обёртка
        `{"data": [...]}`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
      responses:
        '200':
          description: Список связей.
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/TaskLink' }
              example:
                data:
                  - type: blocks
                    task:
                      id: 507f1f77bcf86cd799439c02
                      projectId: 507f1f77bcf86cd799439901
                      projectTitle: Студия — производство
                      title: Фотосессия коллекции
                      creatorId: 507f1f77bcf86cd799439111
                      statusId: 507f1f77bcf86cd799439a01
                      statusName: К выполнению
                      statusIsDone: false
                      position: 2048
                      isArchived: false
                      attachments: []
                      participantIds: [507f1f77bcf86cd799439111]
                      commentsCount: 0
                      attachmentsCount: 0
                      linksCount: 1
                      isOverdue: false
                      createdAt: "2026-10-15T10:05:00Z"
                      updatedAt: "2026-10-15T10:05:00Z"
                    direction: outgoing
                    createdAt: "2026-10-15T10:10:00Z"
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

    post:
      tags: [Задачи и проекты]
      operationId: addTaskLink
      summary: Связать задачи
      description: |
        Создание связи (участник): обе задачи одного проекта.
        `type` — `relates` (связана) или `blocks` (блокирует).
        Ответ — `{"ok": true}`. Заголовок `Idempotency-Key` опционален.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [task_id, type]
              properties:
                task_id:
                  type: string
                  pattern: '^[a-f0-9]{24}$'
                  description: ID связываемой задачи (того же проекта).
                type:
                  type: string
                  enum: [relates, blocks]
                  description: Тип связи.
            example:
              task_id: 507f1f77bcf86cd799439c02
              type: blocks
      responses:
        '201':
          description: Связь создана.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
              example:
                ok: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` / `task_action_forbidden`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

  /api/v2/tasks/{id}/links/{linked_task_id}:
    delete:
      tags: [Задачи и проекты]
      operationId: removeTaskLink
      summary: Удалить связь задач
      description: |
        Удаление связи (создатель связи/owner/admin). Query-параметр
        `type` **опционален**: без него удаляются все связи пары задач,
        с ним — только связь этого типа (`relates` | `blocks`).
        Ответ — `{"ok": true}`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
        - name: linked_task_id
          in: path
          required: true
          schema: { type: string, pattern: '^[a-f0-9]{24}$' }
          description: ID связанной задачи (hex24).
        - name: type
          in: query
          required: false
          schema: { type: string, enum: [relates, blocks] }
          description: 'Тип связи; без параметра удаляются все связи пары.'
      responses:
        '200':
          description: Связь удалена.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
              example:
                ok: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member` / `task_action_forbidden`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_action_forbidden
                error: недостаточно прав для этого действия с задачей
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

  /api/v2/tasks/{id}/comments:
    get:
      tags: [Задачи и проекты]
      operationId: listTaskComments
      summary: Комментарии задачи
      description: |
        Корневые комментарии задачи (участнику), `created_at` asc,
        курсорная пагинация (`data` / `cursor` / `has_more`).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
          description: Размер страницы.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: Opaque-курсор из `cursor.after` предыдущего ответа.
      responses:
        '200':
          description: Страница комментариев.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskCommentsPage' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

    post:
      tags: [Задачи и проекты]
      operationId: createTaskComment
      summary: Комментарий к задаче
      description: |
        Создание комментария (участник): `text` 1–2000 символов,
        `parent_id` — опциональный ответ (1 уровень вложенности; родитель
        обязан быть корневым комментарием той же задачи). После создания
        автор попадает в участники задачи.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  minLength: 1
                  maxLength: 2000
                  description: Текст комментария.
                parent_id:
                  type: string
                  description: ID корневого комментария (для ответа).
            example:
              text: Эскизы готовы, начинаю образцы
      responses:
        '201':
          description: Комментарий создан.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskComment' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: '`not_project_member`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: not_project_member
                error: вы не участник этого проекта
        '404':
          description: '`task_not_found`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example:
                code: task_not_found
                error: задача не найдена

  /api/v2/tasks/task_comments/{id}:
    put:
      tags: [Задачи и проекты]
      operationId: updateTaskComment
      summary: Редактировать комментарий
      description: |
        Редактирование комментария (только автор). Путь со статическим
        сегментом `task_comments`. Возвращает комментарий с
        `isEdited: true`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskCommentId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  minLength: 1
                  maxLength: 2000
            example:
              text: Эскизы готовы (обновлено)
      responses:
        '200':
          description: Обновлённый комментарий.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskComment' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Редактировать может только автор комментария.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Комментарий не найден.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

    delete:
      tags: [Задачи и проекты]
      operationId: deleteTaskComment
      summary: Удалить комментарий
      description: |
        Удаление комментария (автор/owner/admin). Удаление корневого
        комментария уносит свои ответы. Ответ — `{"ok": true}`.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskCommentId'
      responses:
        '200':
          description: Комментарий удалён.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean }
              example:
                ok: true
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: 'Удалить может автор, owner или admin проекта.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '404':
          description: Комментарий не найден.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v2/tasks/task_comments/{id}/replies:
    get:
      tags: [Задачи и проекты]
      operationId: listTaskCommentReplies
      summary: Ответы на комментарий
      description: |
        Ответы (1 уровень) на корневой комментарий задачи; тот же
        cursor-контракт, что у списка комментариев.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/TaskCommentId'
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
          description: Размер страницы.
        - name: after
          in: query
          required: false
          schema: { type: string }
          description: Opaque-курсор из `cursor.after` предыдущего ответа.
      responses:
        '200':
          description: Страница ответов.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TaskCommentsPage' }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Комментарий не найден.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v2/tasks/media:
    post:
      tags: [Задачи и проекты]
      operationId: uploadTaskMedia
      summary: Загрузка изображения для задачи
      description: |
        Загрузка вложения задачи: multipart/form-data, поле `photo`
        (image/*, до 50 МБ). Возвращает URL файла — он не является
        вложением, пока не передан в
        `POST /api/v2/tasks/{id}/attachments`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [photo]
              properties:
                photo:
                  type: string
                  format: binary
                  description: 'Файл изображения (image/*), до 50 МБ.'
      responses:
        '200':
          description: Файл загружен.
          content:
            application/json:
              schema:
                type: object
                required: [url]
                properties:
                  url:
                    type: string
                    description: URL файла в хранилище задач (префикс tasks/).
              example:
                url: https://firsts3.ru/tasks/507f1f77bcf86cd799439c01/photo_8f3a2b.jpg
        '400':
          description: |
            Ошибка загрузки: не image/*, размер более 50 МБ или пустое
            поле photo.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: JWT-токен из POST /api/v1/auth/login (поле token).

  parameters:
    MasterId:
      name: master_id
      in: path
      required: true
      schema: { type: string, pattern: '^[a-f0-9]{24}$' }
      description: ID мастера (hex24).
    ProjectId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^[a-f0-9]{24}$' }
      description: ID проекта (hex24).
    InviteId:
      name: invite_id
      in: path
      required: true
      schema: { type: string, pattern: '^[a-f0-9]{24}$' }
      description: ID приглашения (hex24).
    TaskId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^[a-f0-9]{24}$' }
      description: ID задачи (hex24).
    TaskCommentId:
      name: id
      in: path
      required: true
      schema: { type: string, pattern: '^[a-f0-9]{24}$' }
      description: ID комментария задачи (hex24).

  responses:
    BadRequest:
      description: Ошибка валидации запроса.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: invalid date format, use YYYY-MM-DD
    Unauthorized:
      description: Не авторизован или невалидный токен.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            code: invalid_credentials
            error: Неверный email или пароль
    NotFound:
      description: Ресурс не найден или скрыт настройками приватности.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: master not found
    Conflict:
      description: Конфликт состояния.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            code: user_already_exists
            error: user with this phone already exists
    RateLimited:
      description: Превышен rate limit (2 запроса/сек, burst 5).
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error: too many requests

  schemas:
    Error:
      type: object
      description: Стандартный формат ошибки — machine-код + текст.
      properties:
        code:
          type: string
          description: Машинный код ошибки (не всегда присутствует).
        error:
          type: string
          description: Человекочитаемое описание.

    Cursor:
      type: object
      properties:
        after:
          type: string
          description: Курсор для следующей страницы.
        before:
          type: string
          description: Курсор для предыдущей страницы.

    MasterCard:
      type: object
      description: Карточка мастера в поисковой выдаче.
      additionalProperties: true
      required: [id, first_name, last_name, rating, reviews_count]
      properties:
        id: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        username: { type: string }
        avatar_url: { type: string }
        bio: { type: string }
        address: { type: string }
        latitude: { type: number, format: double }
        longitude: { type: number, format: double }
        workspaces:
          type: array
          items: { $ref: '#/components/schemas/WorkspaceBrief' }
        rating: { type: number, format: double }
        reviews_count: { type: integer }
        distance_km: { type: number, format: double }
        hashtags:
          type: array
          items: { type: string }
        next_available_date: { type: string }
        next_available_slots:
          type: array
          items: { $ref: '#/components/schemas/TimeSlot' }
        price_from: { type: number }
        currency: { type: string, example: RUB }

    AutocompleteResult:
      type: object
      required: [master_id, master_name]
      properties:
        master_id: { type: string }
        master_name: { type: string }
        username: { type: string }
        avatar_url: { type: string }
        workspace_id: { type: string }
        workspace_name: { type: string }
        address: { type: string }
        latitude: { type: number, format: double }
        longitude: { type: number, format: double }
        match_type: { type: string, description: Тип совпадения (name/username/workspace). }

    WorkspaceBrief:
      type: object
      required: [id, name]
      properties:
        id: { type: string }
        name: { type: string }
        address: { type: string }
        is_default: { type: boolean }

    BeautyService:
      type: object
      description: Услуга мастера. Валюта — настройка мастера (RUB/EUR/USD).
      required: [id, master_id, name, duration, price_from, price_to, currency]
      properties:
        id: { type: string }
        master_id: { type: string }
        name: { type: string }
        description: { type: string }
        hashtags:
          type: array
          items: { type: string }
        urls:
          type: array
          items: { type: string }
          description: Фото услуги.
        category: { type: string }
        duration:
          type: integer
          description: Длительность в минутах.
        price_from: { type: number }
        price_to: { type: number }
        currency: { type: string, enum: [RUB, EUR, USD] }
        is_free: { type: boolean }
        gender: { type: string }
        is_active: { type: boolean }

    Schedule:
      type: object
      description: Слот недельного расписания.
      required: [id, master_id, day_of_week, start_time, end_time, is_active]
      properties:
        id: { type: string }
        master_id: { type: string }
        workspace_id:
          type: string
          nullable: true
          description: Рабочее место (null — базовое расписание).
        day_of_week:
          type: integer
          minimum: 0
          maximum: 6
          description: 'День недели: 0 = воскресенье, 1 = понедельник, ...'
        start_time: { type: string, example: "10:00" }
        end_time: { type: string, example: "19:00" }
        is_active: { type: boolean }

    TimeSlot:
      type: object
      required: [start, end, is_booked]
      properties:
        start: { type: string, example: "10:00" }
        end: { type: string, example: "11:30" }
        is_booked: { type: boolean }
        workspace_id: { type: string }
        workspace_name: { type: string }

    AvailableDay:
      type: object
      required: [date]
      properties:
        date: { type: string, format: date }
        time_ranges:
          type: array
          description: Свободные периоды (deprecated, для совместимости).
          items:
            type: object
            properties:
              start: { type: string, example: "09:00" }
              end: { type: string, example: "12:00" }
              workspace_id: { type: string }
              workspace_name: { type: string }
        free_slots:
          type: array
          description: Отдельные свободные окна.
          items:
            type: object
            required: [time]
            properties:
              time: { type: string, example: "09:00" }
              workspace_id: { type: string }
              workspace_name: { type: string }

    Review:
      type: object
      description: Отзыв о мастере (после завершённой записи).
      required: [id, master_id, rating, text, created_at]
      properties:
        id: { type: string }
        master_id: { type: string }
        client_id: { type: string }
        appointment_id: { type: string }
        rating: { type: integer, minimum: 1, maximum: 5 }
        text: { type: string }
        media_urls:
          type: array
          items: { type: string }
        client_name: { type: string }
        client_photo_url: { type: string }
        master_reply: { type: string, nullable: true }
        master_reply_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    RatingDistribution:
      type: object
      required: [five, four, three, two, one]
      properties:
        five: { type: integer }
        four: { type: integer }
        three: { type: integer }
        two: { type: integer }
        one: { type: integer }

    PublicMaster:
      type: object
      description: Публичный профиль мастера для лендингов.
      required: [id, first_name, last_name, accepts_online_booking, rating, reviews_count]
      properties:
        id: { type: string }
        first_name: { type: string }
        last_name: { type: string }
        username: { type: string }
        avatar_url: { type: string }
        cover_image_url: { type: string }
        bio: { type: string }
        address: { type: string }
        accepts_online_booking: { type: boolean }
        rating: { type: number, format: double }
        reviews_count: { type: integer }
        posts:
          type: array
          items: { $ref: '#/components/schemas/PublicPost' }
        stories:
          type: array
          items: { $ref: '#/components/schemas/PublicStory' }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    PublicPost:
      type: object
      properties:
        id: { type: string }
        urls:
          type: array
          items: { type: string }
        caption: { type: string }
        hashtags:
          type: array
          items: { type: string }
        created_at: { type: string, format: date-time }
        likes_count: { type: integer }

    PublicStory:
      type: object
      additionalProperties: true
      properties:
        id: { type: string }
        media_url: { type: string }
        media_type: { type: string, enum: [image, video] }
        duration: { type: integer }
        muted: { type: boolean }
        audio_url: { type: string }
        music_title: { type: string }
        created_at: { type: string, format: date-time }

    PublicService:
      type: object
      description: Услуга в публичном ответе (валюта мастера).
      required: [id, name, price_from, price_to, currency, duration]
      properties:
        id: { type: string }
        name: { type: string }
        description: { type: string }
        price_from: { type: number }
        price_to: { type: number }
        currency: { type: string, enum: [RUB, EUR, USD] }
        is_free: { type: boolean }
        duration:
          type: integer
          description: Длительность в минутах.
        hashtags:
          type: array
          items: { type: string }
        category: { type: string }
        gender: { type: string }

    PublicWorkspace:
      type: object
      required: [id, name, is_default]
      properties:
        id: { type: string }
        name: { type: string }
        address: { type: string }
        image_url: { type: string }
        is_default: { type: boolean }

    PublicReview:
      type: object
      required: [id, rating, created_at]
      properties:
        id: { type: string }
        author_name: { type: string }
        rating: { type: integer, minimum: 1, maximum: 5 }
        text: { type: string }
        created_at: { type: string, format: date-time }

    PublicMastersList:
      type: object
      required: [masters]
      properties:
        masters:
          type: array
          items: { $ref: '#/components/schemas/PublicMaster' }
        cursor: { $ref: '#/components/schemas/Cursor' }
        has_more: { type: boolean }

    Offer:
      type: object
      description: Оффер (товар). Валюта — настройка мастера-владельца.
      additionalProperties: true
      required: [id, owner_id, type, title, price, currency, status]
      properties:
        id: { type: string }
        owner_id: { type: string }
        type: { type: string, enum: [product] }
        title: { type: string }
        description: { type: string }
        media_urls:
          type: array
          items: { type: string }
        price: { type: number }
        currency: { type: string, enum: [RUB, EUR, USD] }
        is_free: { type: boolean }
        status: { type: string, enum: [draft, published, hidden, archived] }
        category: { type: string }
        hashtags:
          type: array
          items: { type: string }
        availability:
          type: object
          properties:
            mode: { type: string, enum: [available, limited, made_to_order, unavailable] }
            stock: { type: integer, nullable: true }
            made_to_order_days: { type: integer, nullable: true }
        fulfillment_methods:
          type: array
          description: Способы получения (только в карточке оффера).
          items:
            type: object
            properties:
              id: { type: string }
              type: { type: string, enum: [pickup, local_delivery, digital, none] }
              label: { type: string }
              is_active: { type: boolean }
        version: { type: integer }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    OfferPage:
      type: object
      required: [data, cursor, has_more]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/Offer' }
        cursor: { $ref: '#/components/schemas/Cursor' }
        has_more: { type: boolean }

    Event:
      type: object
      description: Мероприятие мастера.
      additionalProperties: true
      required: [id, owner_id, title, price, currency, starts_at, ends_at]
      properties:
        id: { type: string }
        owner_id: { type: string }
        title: { type: string }
        description: { type: string }
        media_urls:
          type: array
          items: { type: string }
        price: { type: number }
        currency: { type: string, enum: [RUB, EUR, USD] }
        is_free: { type: boolean }
        max_participants:
          type: integer
          nullable: true
        participants_count: { type: integer }
        is_full: { type: boolean }
        starts_at: { type: string, format: date-time }
        ends_at: { type: string, format: date-time }
        address: { type: string }
        latitude: { type: number, format: double, nullable: true }
        longitude: { type: number, format: double, nullable: true }
        require_approval: { type: boolean }
        is_active: { type: boolean }
        my_status:
          type: string
          nullable: true
          description: Статус участия текущего пользователя (с Bearer-токеном).
        rating_avg:
          type: number
          format: double
          nullable: true
        reviews_count: { type: integer }

    SubscriptionPlan:
      type: object
      additionalProperties: true
      required: [id, key, name, price, currency, duration_days]
      properties:
        id: { type: string }
        key: { type: string, enum: [basic, premium, pro] }
        name: { type: string }
        description: { type: string }
        price: { type: number }
        currency: { type: string }
        duration_days: { type: integer }
        apple_product_id: { type: string }
        features:
          type: object
          additionalProperties: true
          description: Доступность фич на тарифе.
        is_active: { type: boolean }
        sort_order: { type: integer }

    AppPlatformVersion:
      type: object
      required: [min_version, latest_version, store_url]
      properties:
        min_version: { type: string }
        latest_version: { type: string }
        store_url: { type: string }

    LegalConsentInput:
      type: object
      description: Согласие с юридическим документом (версии — из /api/v1/legal/manifest).
      properties:
        accepted: { type: boolean }
        version: { type: string, example: "2.1" }
        locale: { type: string, example: ru }

    RegisterRequest:
      type: object
      required: [password, first_name, last_name, gender, role, terms_acceptance, personal_data_consent]
      properties:
        email:
          type: string
          format: email
          description: Обязательный идентификатор регистрации (уникальный).
        phone:
          type: string
          description: 'Необязательный телефон E.164 (контактное поле, без уникальности).'
        password:
          type: string
          format: password
          minLength: 8
          description: 8+ символов, заглавная + строчная буква + цифра.
        first_name: { type: string }
        last_name: { type: string }
        gender: { type: string, enum: [male, female] }
        role: { type: string, enum: [master, client] }
        referral_code: { type: string }
        terms_acceptance: { $ref: '#/components/schemas/LegalConsentInput' }
        personal_data_consent: { $ref: '#/components/schemas/LegalConsentInput' }
        distribution_consent:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/LegalConsentInput' }]
          description: Опциональное согласие на распространение контента.

    AuthResponse:
      type: object
      required: [token, refresh_token, expires_in, user]
      properties:
        token: { type: string, description: JWT-токен доступа. }
        refresh_token: { type: string }
        expires_in:
          type: integer
          description: Время жизни токена в секундах.
        user:
          type: object
          additionalProperties: true
          description: Профиль пользователя.
          required: [id, first_name, last_name, role]
          properties:
            id: { type: string }
            first_name: { type: string }
            last_name: { type: string }
            username: { type: string }
            email: { type: string }
            phone: { type: string }
            role: { type: string, enum: [master, client] }
            roles:
              type: array
              items: { type: string }
            email_verified: { type: boolean }
            rating: { type: number }

    GuestAppointmentResponse:
      type: object
      description: Созданная гостевая запись (enriched). auth_pair — только для доверенного прокси лендинга.
      additionalProperties: true
      required: [id, master_id, start_time, status]
      properties:
        id: { type: string }
        master_id: { type: string }
        client_id: { type: string }
        service_id: { type: string }
        service_ids:
          type: array
          items: { type: string }
        start_time: { type: string, format: date-time }
        end_time: { type: string, format: date-time }
        status: { type: string, enum: [confirmed, cancelled, completed] }
        price: { type: number }
        note: { type: string }
        master_name: { type: string }
        client_name: { type: string }
        client_email: { type: string }
        service_name: { type: string }
        workspace_name: { type: string }
        workspace_address: { type: string }
        auth_pair:
          type: object
          nullable: true
          description: Пара токенов (только для доверенного прокси лендинга, обычно отсутствует).
          properties:
            token: { type: string }
            refresh_token: { type: string }
            expires_in: { type: integer }

    ProjectUser:
      type: object
      description: |
        Профиль пользователя в задачах/проектах (enrichment владельца,
        участников, авторов). `role` — роль пользователя в minibi
        (master/client).
      required: [id, firstName, lastName]
      properties:
        id: { type: string }
        firstName: { type: string }
        lastName: { type: string }
        username: { type: string }
        avatarUrl: { type: string }
        role:
          type: string
          enum: [master, client]
          description: Роль пользователя в minibi.

    ProjectMember:
      type: object
      description: |
        Участник проекта: профиль + роль в проекте. В JSON `role` —
        именно роль в проекте (admin/member), роль пользователя в
        minibi при этом затеняется.
      required: [id, role]
      properties:
        id: { type: string }
        firstName: { type: string }
        lastName: { type: string }
        username: { type: string }
        avatarUrl: { type: string }
        role:
          type: string
          enum: [admin, member]
          description: Роль в проекте.

    TaskStatusDef:
      type: object
      description: Статус-колонка проекта (канбан).
      required: [id, name, position]
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string, example: "#0095F6" }
        position:
          type: integer
          description: Порядок колонки.
        isDone:
          type: boolean
          description: Признак завершённости (задачи в этом статусе считаются готовыми).
        isDefault:
          type: boolean
          description: Статус по умолчанию (куда падают новые задачи).

    TaskStatusDefInput:
      type: object
      description: |
        Элемент массива статусов для PUT /projects/{id}/statuses.
        `id` присутствует — существующий статус (сохраняет _id),
        отсутствует — новый. Порядок = индексу в массиве.
      required: [name]
      properties:
        id: { type: string }
        name: { type: string }
        color: { type: string }
        is_done: { type: boolean }
        is_default: { type: boolean }

    Project:
      type: object
      description: |
        Проект. В списке GET /api/v2/projects `tasksCount` всегда 0
        (считается только в карточке) и отсутствует `createdAt`.
      additionalProperties: true
      required: [id, ownerId, title, statuses, members, isArchived, tasksCount, updatedAt]
      properties:
        id: { type: string }
        ownerId: { type: string }
        title: { type: string }
        description: { type: string }
        color: { type: string, example: "#E1306C" }
        statuses:
          type: array
          items: { $ref: '#/components/schemas/TaskStatusDef' }
        owner:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/ProjectUser' }]
          description: Владелец (enrichment).
        members:
          type: array
          items: { $ref: '#/components/schemas/ProjectMember' }
        isArchived: { type: boolean }
        tasksCount:
          type: integer
          description: Число неархивных задач.
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ProjectInvite:
      type: object
      description: |
        Активное (pending, не истёкшее) приглашение в проект.
        snake_case-поля.
      required: [id, project_id, created_at, expires_at]
      properties:
        id: { type: string }
        project_id: { type: string }
        project:
          type: object
          nullable: true
          description: Карточка проекта приглашения.
          properties:
            id: { type: string }
            title: { type: string }
            color: { type: string }
            owner:
              nullable: true
              allOf: [{ $ref: '#/components/schemas/ProjectUser' }]
        inviter:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/ProjectUser' }]
          description: Пригласивший (enrichment).
        created_at: { type: string, format: date-time }
        expires_at: { type: string, format: date-time }

    TaskAttachment:
      type: object
      description: Вложение задачи (файл из POST /api/v2/tasks/media).
      required: [id, url, fileName, uploadedBy, createdAt]
      properties:
        id: { type: string }
        url: { type: string }
        fileName: { type: string }
        uploadedBy: { type: string }
        createdAt: { type: string, format: date-time }

    TaskSummary:
      type: object
      description: |
        Задача в списках и mini-task в связях (без description).
        Поля обогащения (projectTitle, statusName, author, assignee,
        счётчики) заполняются сервером.
      additionalProperties: true
      required: [id, projectId, title, creatorId, statusId, position, isArchived, attachments, commentsCount, isOverdue, createdAt, updatedAt]
      properties:
        id: { type: string }
        projectId: { type: string }
        projectTitle: { type: string }
        title: { type: string }
        creatorId: { type: string }
        author:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/ProjectUser' }]
          description: Автор (создатель) задачи.
        assigneeId: { type: string }
        assignee:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/ProjectUser' }]
          description: Исполнитель.
        statusId: { type: string }
        statusName: { type: string }
        statusColor: { type: string }
        statusIsDone:
          type: boolean
          description: Текущий статус — завершённый (is_done).
        position:
          type: number
          description: Позиция в канбане (double).
        isArchived: { type: boolean }
        plannedStartAt:
          type: string
          format: date-time
          nullable: true
        plannedEndAt:
          type: string
          format: date-time
          nullable: true
          description: Дедлайн.
        attachments:
          type: array
          items: { $ref: '#/components/schemas/TaskAttachment' }
        participantIds:
          type: array
          items: { type: string }
          description: Участники задачи (creator + assignee + прокомментировавшие).
        commentsCount: { type: integer }
        attachmentsCount: { type: integer }
        linksCount: { type: integer }
        isOverdue:
          type: boolean
          description: Просрочена (plannedEndAt прошла, статус не is_done, не архив).
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Task:
      type: object
      description: Полная карточка задачи — TaskSummary + описание.
      additionalProperties: true
      allOf:
        - $ref: '#/components/schemas/TaskSummary'
      properties:
        description: { type: string }

    TaskLink:
      type: object
      description: |
        Связь задач на чтении: обе стороны видят связь, направление —
        относительно запрошенной задачи.
      required: [type, task, direction, createdAt]
      properties:
        type:
          type: string
          enum: [relates, blocks]
        task: { $ref: '#/components/schemas/TaskSummary' }
        direction:
          type: string
          enum: [outgoing, incoming]
        createdAt: { type: string, format: date-time }

    TaskComment:
      type: object
      description: |
        Комментарий задачи. `author` — enrichment, `isOwn` — сравнение
        с текущим пользователем.
      required: [id, taskId, text, isEdited, isOwn, createdAt, updatedAt]
      properties:
        id: { type: string }
        taskId: { type: string }
        text: { type: string }
        parentId: { type: string }
        isEdited: { type: boolean }
        isOwn: { type: boolean }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        author:
          nullable: true
          allOf: [{ $ref: '#/components/schemas/ProjectUser' }]

    TasksPage:
      type: object
      required: [data, cursor, has_more]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/TaskSummary' }
        cursor: { $ref: '#/components/schemas/Cursor' }
        has_more: { type: boolean }

    TaskCommentsPage:
      type: object
      required: [data, cursor, has_more]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/TaskComment' }
        cursor: { $ref: '#/components/schemas/Cursor' }
        has_more: { type: boolean }

    TaskTimeline:
      type: object
      description: |
        Таймлайн задачи: история переходов статусов + суммарные
        длительности по статусам (последний интервал растёт до now)
        + общий возраст задачи.
      required: [taskId, entries, durations, totalSeconds]
      properties:
        taskId: { type: string }
        entries:
          type: array
          items:
            type: object
            required: [toStatusId, changedAt, durationSeconds]
            properties:
              fromStatusId:
                type: string
                description: Предыдущий статус (отсутствует у записи создания задачи).
              toStatusId: { type: string }
              changedBy:
                type: string
                description: ID автора перехода.
              changedAt: { type: string, format: date-time }
              durationSeconds:
                type: integer
                description: Секунд в этом статусе до следующей записи (для последней — до now).
        durations:
          type: array
          items:
            type: object
            required: [statusId, statusName, totalSeconds, entriesCount]
            properties:
              statusId: { type: string }
              statusName: { type: string }
              statusColor: { type: string }
              totalSeconds:
                type: integer
                description: Суммарно секунд в статусе (все заходы).
              entriesCount:
                type: integer
                description: Число заходов в статус.
        totalSeconds:
          type: integer
          description: Общий возраст задачи (сумма длительностей).

    ProjectReport:
      type: object
      description: |
        Отчёт проекта: per-task строки (время в статусах, план-vs-факт)
        и сводка. Пустые planned-даты и зависящие от них метрики — null,
        а не 0. Баккеты сводки взаимоисключающие (сумма = числу строк).
      required: [projectId, summary, rows]
      properties:
        projectId: { type: string }
        from: { type: string, format: date-time }
        to: { type: string, format: date-time }
        summary:
          type: object
          required: [openCount, doneCount, archivedCount, overdueCount, lateFinishCount]
          properties:
            openCount: { type: integer, description: Не завершённые и не в архиве. }
            doneCount: { type: integer, description: Текущий статус is_done. }
            archivedCount: { type: integer, description: Незавершённые в архиве. }
            overdueCount: { type: integer, description: Открытые просроченные. }
            lateFinishCount: { type: integer, description: Завершённые позже плана. }
            avgCycleTimeSeconds: { type: number, nullable: true }
            avgOverdueDays: { type: number, nullable: true }
        rows:
          type: array
          items:
            type: object
            required: [task, timePerStatus, actualStartAt, isArchived]
            properties:
              task: { $ref: '#/components/schemas/TaskSummary' }
              timePerStatus:
                type: array
                items:
                  type: object
                  required: [statusId, statusName, totalSeconds, entriesCount]
                  properties:
                    statusId: { type: string }
                    statusName: { type: string }
                    statusColor: { type: string }
                    totalSeconds: { type: integer }
                    entriesCount: { type: integer }
              plannedStartAt:
                type: string
                format: date-time
                nullable: true
              plannedEndAt:
                type: string
                format: date-time
                nullable: true
              actualStartAt: { type: string, format: date-time }
              actualFinishAt:
                type: string
                format: date-time
                nullable: true
              startDeltaHours:
                type: number
                nullable: true
                description: Факт-минус-план начала, часы.
              finishDeltaHours:
                type: number
                nullable: true
                description: Факт-минус-план окончания, часы.
              overdueDays:
                type: integer
                nullable: true
              isArchived: { type: boolean }
