---
openapi: 3.0.3
info:
  title: minik Admin API
  version: v1
  description: Полный административный REST API minik (кампании, флоу, лендинги, офферы,
    отчёты, автоправила, интеграции, команда). Требует JWT из POST /admin_api/v1/auth/login.
    Keitaro — товарный знак его правообладателя; minik не связан с ним и не одобрен им.
    Название встречается в описании импортёра только для обозначения формата дампа,
    который этот эндпоинт читает.
servers:
- url: http://localhost:8080/admin_api/v1
  description: Локальный / self-hosted инстанс (замените на свой хост)
security:
- BearerAuth: []
paths:
  "/auth/login":
    post:
      operationId: post_auth_login
      summary: Аутентифицирует пользователя по email/паролю (и TOTP/backup-коду, если
        у него включена 2FA) и выдаёт JWT на 7 дней.
      tags:
      - Авторизация
      x-permission: public (no auth, rate-limited 1rps/burst10 на IP)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  description: "(string) сверяется регистронезависимо"
                password:
                  type: string
                  description: "(string)"
                mfa_code:
                  type: string
                  description: "(string) обязателен, если у пользователя включена
                    2FA — TOTP-код либо backup-код формата NNNNN-NNNNN"
              required:
              - email
              - password
            example:
              email: user@example.com
              password: MySecurePass123
              mfa_code: '482913'
      responses:
        '200':
          description: успешный вход
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: 'JWT, передаётся далее как Authorization: Bearer
                      <token>'
                  user:
                    type: object
                    description: объект User (см. модель User) без password_hash;
                      при логине поля locale и timezone не заполняются (пустые строки),
                      permissions всегда null — реальные права запрашиваются через
                      GET /me/permissions; role здесь — глобальная users.role, роль
                      в активном workspace несут токен и GET /me
              example:
                token: eyJhbGciOiJIUzI1NiIs...
                user:
                  id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  email: user@example.com
                  name: Jane
                  role: owner
                  mfa_enabled: true
                  locale: ''
                  timezone: ''
                  permissions: null
                  created_at: '2026-01-01T00:00:00Z'
                  updated_at: '2026-01-01T00:00:00Z'
        '400':
          description: невалидный JSON тела запроса
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: неверный email/пароль, отсутствует обязательный mfa_code, либо
            неверный mfa_code
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка подписи токена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Токен несёт роль пользователя В АКТИВНОМ WORKSPACE (workspace_members.role),
        а не глобальную users.role — это то, что проверяет RBAC на всех остальных
        ручках. При устаревшем хэше пароля (bcrypt cost ниже текущего) сервер прозрачно
        перехэширует пароль на лету.
      security: []
  "/me":
    get:
      operationId: get_me
      summary: Возвращает профиль текущего аутентифицированного пользователя вместе
        с его активным workspace_id.
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ''
                  email:
                    type: string
                    description: ''
                  name:
                    type: string
                    description: ''
                  role:
                    type: string
                    description: роль в активном workspace (из токена), не глобальная
                      users.role
                  is_instance_owner:
                    type: boolean
                    description: владелец инстанса; гейтит настройки уровня инстанса
                      (брендирование), не связан с ролью в workspace
                  mfa_enabled:
                    type: boolean
                    description: ''
                  locale:
                    type: string
                    description: ''
                  timezone:
                    type: string
                    description: ''
                  created_at:
                    type: string
                    description: ''
                  updated_at:
                    type: string
                    description: ''
                  workspace_id:
                    type: string
                    description: активный workspace текущей сессии
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                email: user@example.com
                name: Jane
                role: owner
                is_instance_owner: false
                mfa_enabled: true
                locale: en
                timezone: UTC
                created_at: '2026-01-01T00:00:00Z'
                updated_at: '2026-01-01T00:00:00Z'
                workspace_id: 1a2b3c4d-0000-4000-8000-000000000001
        '401':
          description: не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: пользователь из токена не найден в БД (удалён)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
  "/me/profile":
    put:
      operationId: put_me_profile
      summary: Частично обновляет редактируемые пользователем поля своего профиля
        (имя, локаль, часовой пояс).
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) указатель — если не передано (null/отсутствует),
                    поле не меняется"
                locale:
                  type: string
                  description: "(string) то же"
                timezone:
                  type: string
                  description: "(string) то же"
            example:
              name: Jane Doe
              locale: ru
              timezone: Europe/Moscow
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: невалидный JSON
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Роль, email, пароль и права здесь не меняются — это администраторские
        операции через /team/{id}.
  "/me/permissions":
    get:
      operationId: get_me_permissions
      summary: Возвращает роль пользователя в активном workspace и полную эффективную
        RBAC-матрицу (view/edit/delete/admin по каждой сущности) с учётом персональных
        override'ов.
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      responses:
        '200':
          description: ok (если пользователь не член активного workspace — все значения
            false)
          content:
            application/json:
              schema:
                type: object
                properties:
                  role:
                    type: string
                    description: owner|admin|manager|analyst — роль в активном workspace
                  permissions:
                    type: object
                    description: 'map: entity -> {view,edit,delete,admin} -> bool,
                      для entity из [campaigns, landings, offers, domains, ip_lists,
                      ua_lists, reports, rules, team, settings]'
              example:
                role: manager
                permissions:
                  campaigns:
                    view: true
                    edit: true
                    delete: false
                    admin: false
                  landings:
                    view: true
                    edit: true
                    delete: false
                    admin: false
                  offers:
                    view: true
                    edit: true
                    delete: false
                    admin: false
                  domains:
                    view: true
                    edit: true
                    delete: false
                    admin: false
                  ip_lists:
                    view: true
                    edit: true
                    delete: false
                    admin: false
                  ua_lists:
                    view: true
                    edit: true
                    delete: false
                    admin: false
                  reports:
                    view: true
                    edit: true
                    delete: false
                    admin: false
                  rules:
                    view: true
                    edit: true
                    delete: false
                    admin: false
                  team:
                    view: true
                    edit: true
                    delete: false
                    admin: false
                  settings:
                    view: true
                    edit: true
                    delete: false
                    admin: false
        '401':
          description: не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Это единственный способ клиенту узнать реальные права — фронтенд
        использует этот ответ, чтобы скрывать недоступные действия в UI.
  "/me/password":
    post:
      operationId: post_me_password
      summary: Меняет пароль текущего пользователя, предварительно проверив текущий
        пароль.
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                current_password:
                  type: string
                  description: "(string) должен совпадать с текущим паролем, иначе
                    401"
                new_password:
                  type: string
                  description: "(string) минимум 6 символов, иначе 400"
              required:
              - current_password
              - new_password
            example:
              current_password: OldPass123
              new_password: NewSecurePass456
      responses:
        '200':
          description: пароль изменён
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
                  token:
                    type: string
                    description: свежий JWT с новым token_version — прежний токен
                      (включая использованный в этом запросе) отозван; поле может
                      отсутствовать, если переподписание не удалось
              example:
                status: ok
                token: eyJhbGciOiJIUzI1NiIs...
        '400':
          description: невалидный JSON, либо new_password короче 6 символов
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: current_password не совпадает
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: пользователь не найден
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка хэширования или UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Смена пароля инкрементирует token_version пользователя — все ранее
        выданные JWT (включая токен, которым был сделан этот запрос) немедленно отзываются.
        Чтобы текущая сессия не разлогинилась, в ответе возвращается свежий token
        с новой версией.
  "/api-keys":
    get:
      operationId: get_api_keys
      summary: Список API-ключей текущего workspace (без секрета — только id/имя/prefix/scopes).
      tags:
      - API-ключи
      x-permission: view:settings
      responses:
        '200':
          description: ok, пустой массив если ключей нет
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    name:
                      type: string
                      description: ''
                    key_prefix:
                      type: string
                      description: первые 12 символов ключа ("ak_" + 9 hex), для опознавания
                        в UI
                    scopes:
                      type: array
                      description: например ["read","write"]
                    user_id:
                      type: string
                      description: omitempty — кто создал
                    last_used_at:
                      type: string
                      description: omitempty
                    created_at:
                      type: string
                      description: ''
              example:
              - id: c1a2b3c4-0000-4000-8000-000000000010
                name: CRM integration
                key_prefix: ak_1a2b3c4d5
                scopes:
                - read
                - write
                last_used_at: '2026-07-27T09:00:00Z'
                created_at: '2026-06-01T00:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Секрет ("ak_...") нигде, кроме ответа на Create, не хранится и
        не возвращается — в БД только hash + prefix.
    post:
      operationId: post_api_keys
      summary: Создаёт новый API-ключ для Click API, привязанный к текущему workspace.
        Секрет показывается один раз в этом ответе.
      tags:
      - API-ключи
      x-permission: admin:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) обязательно, иначе 400"
                scopes:
                  type: array
                  description: (array) если не передано или пусто — по умолчанию ["read","write"]
              required:
              - name
            example:
              name: CRM integration
              scopes:
              - write
      responses:
        '201':
          description: ключ создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ''
                  name:
                    type: string
                    description: ''
                  key_prefix:
                    type: string
                    description: ''
                  scopes:
                    type: array
                    description: ''
                  created_at:
                    type: string
                    description: ''
                  secret:
                    type: string
                    description: полный секрет вида "ak_<64 hex>" — показывается ТОЛЬКО
                      в этом ответе, сохраните сразу
              example:
                id: c1a2b3c4-0000-4000-8000-000000000010
                name: CRM integration
                key_prefix: ak_1a2b3c4d5
                scopes:
                - write
                created_at: '2026-07-28T10:00:00Z'
                secret: ak_1a2b3c4d5e6f78901a2b3c4d5e6f78901a2b3c4d5e6f78901a2b3c4d5e6f7890
        '400':
          description: невалидный JSON, пустое name, либо ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Секрет генерируется как 32 случайных байта (crypto/rand), хешируется
        SHA-256 для хранения; используется как Authorization: Bearer <secret> в POST
        /click-api/v3. Пишется audit log (create/api_key).'
  "/api-keys/{id}":
    delete:
      operationId: delete_api_keys_id
      summary: Отзывает (удаляет) API-ключ.
      tags:
      - API-ключи
      x-permission: admin:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid ключа
      responses:
        '200':
          description: удалён
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не парсится как uuid, либо ошибка выполнения DELETE в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: ключ не найден или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Немедленный эффект — все последующие вызовы Click API с этим ключом
        получат 401. Пишется audit log (delete/api_key).
  "/domains":
    get:
      operationId: get_domains
      summary: Список доменов текущего workspace.
      tags:
      - Домены
      x-permission: view:domains
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    host:
                      type: string
                      description: ''
                    type:
                      type: string
                      description: tracking | landing | primary
                    notes:
                      type: string
                      description: ''
                    created_at:
                      type: string
                      description: ''
              example:
              - id: d1000000-0000-4000-8000-000000000001
                host: track.example.com
                type: tracking
                notes: ''
                created_at: '2026-01-01T00:00:00Z'
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Массив может быть пустым, никогда не null.
    post:
      operationId: post_domains
      summary: Регистрирует новый домен.
      tags:
      - Домены
      x-permission: edit:domains
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                host:
                  type: string
                  description: "(string) нормализуется в lower-case и trim; проверяется
                    как валидный hostname"
                type:
                  type: string
                  description: "(string) tracking (по умолчанию) | landing | primary
                    — невалидное значение → 400"
                notes:
                  type: string
                  description: "(string)"
              required:
              - host
            example:
              host: track.example.com
              type: tracking
              notes: main tracking domain
      responses:
        '201':
          description: создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ''
                  host:
                    type: string
                    description: ''
                  type:
                    type: string
                    description: ''
                  notes:
                    type: string
                    description: ''
                  created_at:
                    type: string
                    description: ''
              example:
                id: d1000000-0000-4000-8000-000000000001
                host: track.example.com
                type: tracking
                notes: main tracking domain
                created_at: '2026-07-28T10:00:00Z'
        '400':
          description: невалидный JSON, невалидный type, невалидный hostname, либо
            конфликт INSERT (например, дубликат host)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'После создания в фоне (goroutine) запускается WarmTLS — best-effort
        прогрев edge-сертификата через Caddy on-demand TLS (TLS-handshake на TLS_WARM_ADDR,
        до трёх попыток: сразу, через 1 и 5 минут), чтобы первый визитор не ждал
        выпуска. Внутренний ACME-сертификат (domain_certs) при этом не выпускается
        — для него отдельный вызов POST /domains/{id}/ssl/issue.'
  "/domains/{id}":
    put:
      operationId: put_domains_id
      summary: Обновляет домен.
      tags:
      - Домены
      x-permission: edit:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid домена
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                host:
                  type: string
                  description: "(string)"
                type:
                  type: string
                  description: "(string)"
                notes:
                  type: string
                  description: "(string)"
              required:
              - host
              - type
            example:
              host: track.example.com
              type: tracking
              notes: updated
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid, невалидный JSON, либо ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден или принадлежит другому воркспейсу (RowsAffected == 0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: UPDATE скоупится по workspace (WHERE id=$4 AND workspace_id=$5)
        и проверяет RowsAffected — если строка не найдена или принадлежит другому
        воркспейсу, возвращается 404 "not found", а не тихий 200.
    delete:
      operationId: delete_domains_id
      summary: Удаляет домен.
      tags:
      - Домены
      x-permission: delete:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid домена
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid или ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден или принадлежит другому воркспейсу (RowsAffected == 0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Кампании, ссылающиеся на этот домен, не проверяются на существование
        — на уровне API нет запрета удалить используемый домен.
  "/traffic-sources":
    get:
      operationId: get_traffic_sources
      summary: Список источников трафика workspace.
      tags:
      - Источники трафика
      x-permission: view:settings
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    name:
                      type: string
                      description: ''
                    postback_url:
                      type: string
                      description: ''
                    has_secret:
                      type: boolean
                      description: true, если секрет постбека сохранён; сам секрет
                        никогда не возвращается
                    parameters:
                      type: array
                      description: массив объектов {name, param, ...}
                    notes:
                      type: string
                      description: ''
                    created_at:
                      type: string
                      description: ''
              example:
              - id: s1000000-0000-4000-8000-000000000001
                name: Facebook Ads
                postback_url: https://facebook.example/pb
                has_secret: true
                parameters:
                - name: sub1
                  param: clickid
                notes: ''
                created_at: '2026-01-01T00:00:00Z'
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
    post:
      operationId: post_traffic_sources
      summary: Создаёт источник трафика.
      tags:
      - Источники трафика
      x-permission: edit:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string)"
                postback_url:
                  type: string
                  description: "(string) исходящий S2S: URL источника, куда трекер
                    шлёт каждую конверсию кампаний с этим источником (если у кампании
                    не задан свой s2s_postback_url)"
                postback_secret:
                  type: string
                  description: "(string) шифруется перед сохранением, если задан Cipher"
                parameters:
                  type: array
                  description: "(array) массив {name, param}; если не передано — пустой
                    массив"
                notes:
                  type: string
                  description: "(string)"
            example:
              name: Facebook Ads
              postback_url: https://facebook.example/pb
              postback_secret: secret123
              parameters:
              - name: sub1
                param: clickid
      responses:
        '201':
          description: создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  "...":
                    type: object
                    description: полный TrafficSource с id/created_at; вместо postback_secret
                      возвращается has_secret
              example:
                id: s1000000-0000-4000-8000-000000000001
                name: Facebook Ads
                postback_url: https://facebook.example/pb
                has_secret: true
                parameters:
                - name: sub1
                  param: clickid
                notes: ''
                created_at: '2026-07-28T10:00:00Z'
        '400':
          description: невалидный JSON или ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
  "/traffic-sources/{id}":
    put:
      operationId: put_traffic_sources_id
      summary: Обновляет источник трафика.
      tags:
      - Источники трафика
      x-permission: edit:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid источника
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string)"
                postback_url:
                  type: string
                  description: "(string)"
                postback_secret:
                  type: string
                  description: "(string) пустое значение или отсутствие поля оставляет
                    прежний секрет без изменений (клиент не получает plaintext и не
                    может вернуть его при сохранении); непустое шифруется перед сохранением,
                    если задан Cipher"
                parameters:
                  type: array
                  description: "(array)"
                notes:
                  type: string
                  description: "(string)"
            example:
              name: Facebook Ads
              postback_url: https://facebook.example/pb
              parameters: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid, невалидный JSON, ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: источник не найден или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
    delete:
      operationId: delete_traffic_sources_id
      summary: Удаляет источник трафика.
      tags:
      - Источники трафика
      x-permission: delete:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid источника
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid или ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: источник не найден или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
  "/affiliate-networks":
    get:
      operationId: get_affiliate_networks
      summary: Список партнёрских сетей workspace.
      tags:
      - Партнёрские сети
      x-permission: view:settings
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    name:
                      type: string
                      description: ''
                    postback_url_template:
                      type: string
                      description: шаблон URL постбека сети со своими макросами
                    status_mapping:
                      type: object
                      description: мапа статус сети -> внутренний статус конверсии
                    default_currency:
                      type: string
                      description: ''
                    notes:
                      type: string
                      description: ''
                    created_at:
                      type: string
                      description: ''
              example:
              - id: n1000000-0000-4000-8000-000000000001
                name: MyAffNet
                postback_url_template: ''
                status_mapping:
                  '1': approved
                  '2': rejected
                default_currency: USD
                notes: ''
                created_at: '2026-01-01T00:00:00Z'
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
    post:
      operationId: post_affiliate_networks
      summary: Создаёт партнёрскую сеть.
      tags:
      - Партнёрские сети
      x-permission: edit:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string)"
                postback_url_template:
                  type: string
                  description: "(string)"
                status_mapping:
                  type: object
                  description: "(object) если не передано — {}"
                default_currency:
                  type: string
                  description: (string) по умолчанию "USD"
                notes:
                  type: string
                  description: "(string)"
            example:
              name: MyAffNet
              status_mapping:
                '1': approved
                '2': rejected
              default_currency: USD
      responses:
        '201':
          description: создана
          content:
            application/json:
              schema:
                type: object
                properties:
                  "...":
                    type: object
                    description: полный AffiliateNetwork с id/created_at
              example:
                id: n1000000-0000-4000-8000-000000000001
                name: MyAffNet
                postback_url_template: ''
                status_mapping:
                  '1': approved
                  '2': rejected
                default_currency: USD
                notes: ''
                created_at: '2026-07-28T10:00:00Z'
        '400':
          description: невалидный JSON или ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
  "/affiliate-networks/{id}":
    put:
      operationId: put_affiliate_networks_id
      summary: Обновляет партнёрскую сеть.
      tags:
      - Партнёрские сети
      x-permission: edit:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid сети
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string)"
                postback_url_template:
                  type: string
                  description: "(string)"
                status_mapping:
                  type: object
                  description: "(object)"
                default_currency:
                  type: string
                  description: "(string)"
                notes:
                  type: string
                  description: "(string)"
            example:
              name: MyAffNet
              status_mapping:
                '1': approved
              default_currency: USD
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid, невалидный JSON, ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: сеть не найдена или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
    delete:
      operationId: delete_affiliate_networks_id
      summary: Удаляет партнёрскую сеть.
      tags:
      - Партнёрские сети
      x-permission: delete:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid сети
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid или ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: сеть не найдена или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Офферы, ссылающиеся на network_id, не проверяются — удаление не
        блокируется.
  "/landings":
    get:
      operationId: get_landings
      summary: Список лендингов workspace.
      tags:
      - Лендинги
      x-permission: view:landings
      parameters:
      - name: archived
        in: query
        required: false
        schema:
          type: string
        description: (string ("true")) если "true" — включает архивные лендинги; по
          умолчанию только активные
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    name:
                      type: string
                      description: ''
                    type:
                      type: string
                      description: redirect | preload | local | proxy (см. type в
                        LP-редакторе; local — файлы хостятся в minik, proxy — reverse-proxy
                        на url)
                    url:
                      type: string
                      description: ''
                    local_index:
                      type: string
                      description: 'всегда пустая строка: колонка не входит в SELECT
                        списка, но поле сериализуется (нет omitempty)'
                    tags:
                      type: array
                      description: ''
                    notes:
                      type: string
                      description: ''
                    archived_at:
                      type: string
                      description: omitempty
                    created_at:
                      type: string
                      description: ''
              example:
              - id: l1000000-0000-4000-8000-000000000001
                name: Main LP
                type: local
                url: ''
                local_index: ''
                tags:
                - weight-loss
                notes: ''
                created_at: '2026-01-01T00:00:00Z'
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: local_index присутствует в JSON каждого элемента, но всегда пустой
        строкой — ListLandings не читает эту колонку из БД, а у поля models.Landing
        нет omitempty. Реальное значение local_index не отдаётся ни одним admin-эндпоинтом;
        выбранный индекс виден только в поле index ответа POST /landings/{id}/upload-zip.
    post:
      operationId: post_landings
      summary: Создаёт лендинг.
      tags:
      - Лендинги
      x-permission: edit:landings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string)"
                type:
                  type: string
                  description: (string) по умолчанию "redirect"
                url:
                  type: string
                  description: "(string) для type=redirect — внешний URL лендинга"
                tags:
                  type: array
                  description: "(array)"
                notes:
                  type: string
                  description: "(string)"
            example:
              name: Main LP
              type: redirect
              url: https://cdn.example.com/lp/index.html
              tags:
              - weight-loss
      responses:
        '201':
          description: создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  "...":
                    type: object
                    description: полный Landing с id/created_at
              example:
                id: l1000000-0000-4000-8000-000000000001
                name: Main LP
                type: redirect
                url: https://cdn.example.com/lp/index.html
                tags:
                - weight-loss
                notes: ''
                created_at: '2026-07-28T10:00:00Z'
        '400':
          description: невалидный JSON или ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Асинхронно триггерит перезагрузку in-memory кэша кампаний/флоу
        (h.reload(), в фоновой горутине) — новый лендинг доступен для выбора во флоу
        почти сразу, без ожидания ответа.
  "/landings/{id}":
    put:
      operationId: put_landings_id
      summary: Обновляет лендинг.
      tags:
      - Лендинги
      x-permission: edit:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid лендинга
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string)"
                type:
                  type: string
                  description: "(string)"
                url:
                  type: string
                  description: "(string)"
                tags:
                  type: array
                  description: "(array)"
                notes:
                  type: string
                  description: "(string)"
            example:
              name: Main LP v2
              type: redirect
              url: https://cdn.example.com/lp/v2.html
              tags: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid, невалидный JSON, ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лендинг не найден или принадлежит другому workspace (UPDATE
            не затронул ни одной строки)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Тоже триггерит фоновую перезагрузку кэша.
  "/landings/{id}/archive":
    post:
      operationId: post_landings_id_archive
      summary: Архивирует лендинг (мягкое удаление — ставит archived_at).
      tags:
      - Лендинги
      x-permission: edit:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid лендинга
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid или ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лендинг не найден или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Нет отдельного unarchive для лендингов/офферов (в отличие от кампаний),
        и UpdateLanding archived_at не трогает (в его SET только name/type/url/tags/notes)
        — снять архив через API нельзя, только напрямую в БД.
  "/offers":
    get:
      operationId: get_offers
      summary: Список офферов workspace.
      tags:
      - Офферы
      x-permission: view:offers
      parameters:
      - name: archived
        in: query
        required: false
        schema:
          type: string
        description: (string ("true")) включить архивные
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    name:
                      type: string
                      description: ''
                    url:
                      type: string
                      description: ''
                    network_id:
                      type: string
                      description: omitempty
                    payout:
                      type: number
                      description: ''
                    payout_formula:
                      type: string
                      description: всегда пустая строка — колонка не входит в SELECT,
                        но ключ сериализуется (нет omitempty)
                    currency:
                      type: string
                      description: ''
                    country:
                      type: string
                      description: ''
                    cap_hour:
                      type: number
                      description: 0 = без лимита
                    cap_day:
                      type: number
                      description: ''
                    cap_total:
                      type: number
                      description: ''
                    overflow_offer_id:
                      type: string
                      description: omitempty — куда переливать трафик при достижении
                        капа
                    state:
                      type: string
                      description: всегда пустая строка — колонка не входит в SELECT,
                        но ключ сериализуется (нет omitempty)
                    tags:
                      type: array
                      description: ''
                    notes:
                      type: string
                      description: ''
                    archived_at:
                      type: string
                      description: omitempty
                    created_at:
                      type: string
                      description: ''
              example:
              - id: o1000000-0000-4000-8000-000000000001
                name: Weight Loss US
                url: https://net.example/click?aff=123
                payout: 25.5
                currency: USD
                country: US
                cap_hour: 0
                cap_day: 500
                cap_total: 0
                tags: []
                notes: ''
                created_at: '2026-01-01T00:00:00Z'
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: payout_formula и state (models.Offer) не входят в этот SELECT —
        список отдаёт только фиксированный payout; при этом оба ключа присутствуют
        в JSON как пустые строки, так как у полей нет omitempty.
    post:
      operationId: post_offers
      summary: Создаёт оффер.
      tags:
      - Офферы
      x-permission: edit:offers
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string)"
                url:
                  type: string
                  description: "(string)"
                network_id:
                  type: string
                  description: "(uuid)"
                payout:
                  type: number
                  description: "(number)"
                currency:
                  type: string
                  description: (string) по умолчанию "USD"
                country:
                  type: string
                  description: "(string)"
                cap_hour:
                  type: number
                  description: "(number)"
                cap_day:
                  type: number
                  description: "(number)"
                cap_total:
                  type: number
                  description: "(number)"
                overflow_offer_id:
                  type: string
                  description: "(uuid)"
                tags:
                  type: array
                  description: "(array)"
                notes:
                  type: string
                  description: "(string)"
            example:
              name: Weight Loss US
              url: https://net.example/click?aff=123
              payout: 25.5
              currency: USD
              country: US
              cap_day: 500
      responses:
        '201':
          description: создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  "...":
                    type: object
                    description: полный Offer с id/created_at
              example:
                id: o1000000-0000-4000-8000-000000000001
                name: Weight Loss US
                url: https://net.example/click?aff=123
                payout: 25.5
                currency: USD
                country: US
                cap_day: 500
                tags: []
                notes: ''
                created_at: '2026-07-28T10:00:00Z'
        '400':
          description: невалидный JSON или ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Триггерит фоновую перезагрузку кэша (h.reload()). Декодер принимает
        любые поля models.Offer — в том числе payout_formula и state, — но в INSERT
        они не входят и молча игнорируются (400 «неизвестное поле» на них не возникает).
  "/offers/{id}":
    put:
      operationId: put_offers_id
      summary: Обновляет оффер.
      tags:
      - Офферы
      x-permission: edit:offers
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid оффера
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string)"
                url:
                  type: string
                  description: "(string)"
                network_id:
                  type: string
                  description: "(uuid)"
                payout:
                  type: number
                  description: "(number)"
                currency:
                  type: string
                  description: "(string)"
                country:
                  type: string
                  description: "(string)"
                cap_hour:
                  type: number
                  description: "(number)"
                cap_day:
                  type: number
                  description: "(number)"
                cap_total:
                  type: number
                  description: "(number)"
                overflow_offer_id:
                  type: string
                  description: "(uuid)"
                tags:
                  type: array
                  description: "(array)"
                notes:
                  type: string
                  description: "(string)"
            example:
              name: Weight Loss US
              payout: 27
              currency: USD
              country: US
              cap_day: 600
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid, невалидный JSON, ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: оффер не найден или принадлежит другому workspace (RowsAffected
            == 0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Триггерит фоновую перезагрузку кэша. Декодер принимает любые поля
        models.Offer — в том числе payout_formula и state, — но в UPDATE они не входят
        и молча игнорируются (400 «неизвестное поле» на них не возникает).
  "/offers/{id}/archive":
    post:
      operationId: post_offers_id_archive
      summary: Архивирует оффер (мягкое удаление).
      tags:
      - Офферы
      x-permission: edit:offers
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid оффера
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid или ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: оффер не найден или принадлежит другому workspace (RowsAffected
            == 0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Триггерит фоновую перезагрузку кэша, чтобы заархивированный оффер
        сразу перестал выбираться флоу.
  "/campaigns":
    get:
      operationId: get_campaigns
      summary: Список кампаний workspace с готовыми tracking_url для каждой.
      tags:
      - Кампании и флоу
      x-permission: view:campaigns
      parameters:
      - name: archived
        in: query
        required: false
        schema:
          type: string
        description: (string ("true")) включить архивные (state='archived'); по умолчанию
          исключены
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    "...":
                      type: object
                      description: полная модель Campaign БЕЗ вложенных flows (flows
                        подгружаются только в GetCampaign), плюс вычисленные tracking_url/tracking_scheme/domain_host
              example:
              - id: c1000000-0000-4000-8000-000000000001
                alias: abc123
                name: US FB traffic
                rotation_mode: by_weight
                uniqueness_method: cookie
                uniqueness_ttl_hours: 24
                cost_mode: cpc
                cost_value: 0.1
                currency: USD
                tags: []
                state: active
                created_at: '2026-01-01T00:00:00Z'
                updated_at: '2026-01-01T00:00:00Z'
                tracking_url: https://track.example.com/r/abc123
                tracking_scheme: https
                domain_host: track.example.com
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: tracking_scheme="https" только если у привязанного домена активен
        Let's Encrypt сертификат; без привязанного домена URL строится от PublicBase
        инстанса (или от Host входящего запроса, если PublicBase не настроен).
    post:
      operationId: post_campaigns
      summary: Создаёт кампанию (без флоу — флоу создаются отдельными вызовами).
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                alias:
                  type: string
                  description: "(string) если не передан — генерируется случайный
                    6-символьный"
                name:
                  type: string
                  description: "(string)"
                notes:
                  type: string
                  description: "(string)"
                domain_id:
                  type: string
                  description: "(uuid) должен существовать в этом workspace, иначе
                    422"
                traffic_source_id:
                  type: string
                  description: "(uuid) должен существовать в этом workspace, иначе
                    422"
                parameters:
                  type: array
                  description: "(array)"
                rotation_mode:
                  type: string
                  description: (string) по умолчанию "by_weight"
                bind_visitor:
                  type: string
                  description: (string) по умолчанию "none"
                uniqueness_method:
                  type: string
                  description: (string) по умолчанию "cookie"
                uniqueness_ttl_hours:
                  type: number
                  description: "(number) по умолчанию 24 (0 трактуется как «не задано»);
                    допустимы целые от 1 до 8760, иначе 400"
                cost_mode:
                  type: string
                  description: (string) по умолчанию "cpc"
                cost_value:
                  type: number
                  description: "(number)"
                currency:
                  type: string
                  description: (string) по умолчанию "USD"
                s2s_postback_url:
                  type: string
                  description: "(string)"
                s2s_statuses:
                  type: array
                  description: "(array) какие статусы конверсий отправляются в постбэке
                    источнику; если nil — []"
                tags:
                  type: array
                  description: "(array)"
                state:
                  type: string
                  description: (string) по умолчанию "active"
            example:
              name: US FB traffic
              domain_id: d1000000-0000-4000-8000-000000000001
              traffic_source_id: s1000000-0000-4000-8000-000000000001
              cost_mode: cpc
              cost_value: 0.1
              currency: USD
      responses:
        '201':
          description: создана
          content:
            application/json:
              schema:
                type: object
                properties:
                  "...":
                    type: object
                    description: полный Campaign (без flows) с id/created_at/updated_at
                      и вычисленным tracking_url
              example:
                id: c1000000-0000-4000-8000-000000000001
                alias: x7f2a1
                name: US FB traffic
                rotation_mode: by_weight
                uniqueness_method: cookie
                uniqueness_ttl_hours: 24
                cost_mode: cpc
                cost_value: 0.1
                currency: USD
                tags: []
                state: active
                created_at: '2026-07-28T10:00:00Z'
                updated_at: '2026-07-28T10:00:00Z'
                tracking_url: https://track.example.com/r/x7f2a1
        '400':
          description: невалидный JSON, uniqueness_ttl_hours вне диапазона 1–8760,
            или ошибка INSERT (например, дубликат alias)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '422':
          description: domain_id или traffic_source_id указывают на несуществующую
            (или чужую) запись
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Триггерит фоновую перезагрузку кэша.
  "/campaigns/{id}":
    get:
      operationId: get_campaigns_id
      summary: Возвращает кампанию целиком, включая её флоу.
      tags:
      - Кампании и флоу
      x-permission: view:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid кампании
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  "...":
                    type: object
                    description: 'полный Campaign, включая flows: массив вложенных
                      Flow-объектов'
              example:
                id: c1000000-0000-4000-8000-000000000001
                alias: x7f2a1
                name: US FB traffic
                state: active
                tracking_url: https://track.example.com/r/x7f2a1
                flows:
                - id: f1000000-0000-4000-8000-000000000001
                  campaign_id: c1000000-0000-4000-8000-000000000001
                  name: Default
                  position: 0
                  type: regular
                  weight: 100
                  state: active
                  scheme: landings_offers
                  redirect_type: '302'
                  filters: []
                  filters_logic: AND
                  landings:
                  - landing_id: l1000000-0000-4000-8000-000000000001
                    weight: 100
                  offers:
                  - offer_id: o1000000-0000-4000-8000-000000000001
                    weight: 100
                  save_traffic: true
                  created_at: '2026-01-01T00:00:00Z'
        '400':
          description: id не uuid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: не найдена (в т.ч. если принадлежит другому workspace)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
    put:
      operationId: put_campaigns_id
      summary: Обновляет кампанию (флоу этим вызовом не меняются).
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid кампании
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ? alias, name, notes, domain_id, traffic_source_id, parameters, rotation_mode,
                  bind_visitor, uniqueness_method, uniqueness_ttl_hours, cost_mode,
                  cost_value, currency, s2s_postback_url, s2s_statuses, tags, state
                : type: object
                  description: "(mixed) тот же набор полей, что и в CreateCampaign
                    — это полный PUT, отсутствующие поля запишутся как zero-value,
                    а не останутся прежними. Исключение — uniqueness_ttl_hours: 0
                    нормализуется в 24, а значения вне 1–8760 дают 400"
            example:
              alias: x7f2a1
              name: US FB traffic v2
              rotation_mode: by_weight
              uniqueness_method: cookie
              uniqueness_ttl_hours: 24
              cost_mode: cpc
              cost_value: 0.12
              currency: USD
              tags: []
              state: active
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid, невалидный JSON, uniqueness_ttl_hours вне диапазона
            1–8760, ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: кампания не существует либо принадлежит другому workspace
            (RowsAffected==0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '422':
          description: domain_id/traffic_source_id указывают на запись другого workspace
            либо на несуществующую ("domain not in this workspace" / "traffic source
            not in this workspace")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Как и Flow-эндпоинты, проверяет RowsAffected==0 -> 404: обновление
        чужого/несуществующего id возвращает 404 «not found», а не молчаливый успех.
        Пишется audit log (update/campaign); триггерит фоновую перезагрузку кэша.'
  "/campaigns/{id}/archive":
    post:
      operationId: post_campaigns_id_archive
      summary: Архивирует кампанию (state='archived', archived_at=now()).
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid кампании
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: id не uuid или ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: кампания не найдена либо принадлежит другому workspace (RowsAffected==0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Обратная операция — POST /campaigns/{id}/unarchive. Триггерит фоновую
        перезагрузку кэша.
  "/flows":
    get:
      operationId: get_flows
      summary: Лёгкий список всех флоу workspace (id, name, campaign_id) — для резолва
        flow_id в отчётах, а не для редактирования.
      tags:
      - Кампании и флоу
      x-permission: view:campaigns
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    name:
                      type: string
                      description: ''
                    campaign_id:
                      type: string
                      description: ''
              example:
              - id: f1000000-0000-4000-8000-000000000001
                name: Default
                campaign_id: c1000000-0000-4000-8000-000000000001
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Полные флоу с фильтрами/скриптами — только через GET /campaigns/{id}/flows
        или вложенно в GET /campaigns/{id}.
  "/campaigns/{id}/flows":
    get:
      operationId: get_campaigns_id_flows
      summary: Полный список флоу конкретной кампании.
      tags:
      - Кампании и флоу
      x-permission: view:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid кампании
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    "...":
                      type: array
                      description: массив полных Flow-объектов
              example:
              - id: f1000000-0000-4000-8000-000000000001
                campaign_id: c1000000-0000-4000-8000-000000000001
                name: Default
                position: 0
                type: regular
                weight: 100
                state: active
                scheme: landings_offers
                redirect_type: '302'
                filters: []
                filters_logic: AND
                landings: []
                offers: []
                save_traffic: true
                created_at: '2026-01-01T00:00:00Z'
        '400':
          description: id не uuid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: кампания не найдена или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
    post:
      operationId: post_campaigns_id_flows
      summary: Создаёт флоу внутри кампании.
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid кампании
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string)"
                position:
                  type: number
                  description: "(number) порядок проверки флоу в кампании"
                type:
                  type: string
                  description: (string) по умолчанию "regular"
                weight:
                  type: number
                  description: "(number) по умолчанию 100"
                state:
                  type: string
                  description: (string) по умолчанию "active"
                scheme:
                  type: string
                  description: (string) по умолчанию "landings_offers"; также "action"
                    (выполнить action_script), "redirect" (прямой redirect_url) либо
                    терминальная схема (do_nothing/404/show_text/show_html/send_to_campaign)
                redirect_type:
                  type: string
                  description: (string) по умолчанию "302"
                redirect_url:
                  type: string
                  description: "(string) используется при scheme=send_to_campaign
                    (целевой alias) или как прямой redirect-URL"
                filters:
                  type: array
                  description: "(array) плоский список условий (устаревший формат,
                    см. filter_tree)"
                filters_logic:
                  type: string
                  description: "(string) AND | OR, по умолчанию AND"
                filter_tree:
                  type: object
                  description: "(object) RuleNode — вложенное AND/OR дерево условий;
                    если задано и непусто, имеет приоритет над filters/filters_logic"
                landings:
                  type: array
                  description: "(array) массив {landing_id, weight}, должны существовать
                    в этом workspace, иначе 422"
                offers:
                  type: array
                  description: "(array) массив {offer_id, weight}, должны существовать
                    в этом workspace, иначе 422"
                action_script:
                  type: string
                  description: "(string) PHP-скрипт флоу; линтится через PHP-FPM при
                    сохранении только когда scheme=\"action\" (при show_text/show_html
                    в этой же колонке хранится тело страницы, оно не линтится); если
                    PHP-FPM не настроен или линт недоступен, сохранение не блокируется"
                save_traffic:
                  type: boolean
                  description: "(boolean) по умолчанию true, если поле не передано
                    в JSON"
            example:
              name: Default
              scheme: landings_offers
              redirect_type: '302'
              weight: 100
              landings:
              - landing_id: l1000000-0000-4000-8000-000000000001
                weight: 100
              offers:
              - offer_id: o1000000-0000-4000-8000-000000000001
                weight: 100
              save_traffic: true
      responses:
        '201':
          description: создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  "...":
                    type: object
                    description: полный Flow с id/created_at
              example:
                id: f1000000-0000-4000-8000-000000000001
                campaign_id: c1000000-0000-4000-8000-000000000001
                name: Default
                position: 0
                type: regular
                weight: 100
                state: active
                scheme: landings_offers
                redirect_type: '302'
                filters: []
                filters_logic: AND
                landings:
                - landing_id: l1000000-0000-4000-8000-000000000001
                  weight: 100
                offers:
                - offer_id: o1000000-0000-4000-8000-000000000001
                  weight: 100
                save_traffic: true
                created_at: '2026-07-28T10:00:00Z'
        '400':
          description: id кампании не uuid, невалидный JSON, ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: кампания не найдена / чужая
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '422':
          description: action_script не проходит линт, либо landings/offers ссылаются
            на несуществующие записи
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Триггерит фоновую перезагрузку кэша, чтобы флоу сразу участвовал
        в выборе.
  "/campaigns/{id}/flows/{flowId}":
    put:
      operationId: put_campaigns_id_flows_flowId
      summary: Обновляет флоу.
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid кампании (в URL, но не используется для скоупинга — скоуп
          идёт через flowId)
      - name: flowId
        in: path
        required: true
        schema:
          type: string
        description: uuid флоу
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                "...":
                  type: object
                  description: "(mixed) тот же набор полей, что и CreateFlow (полный
                    PUT)"
            example:
              name: Default
              scheme: landings_offers
              redirect_type: '302'
              weight: 100
              landings:
              - landing_id: l1000000-0000-4000-8000-000000000001
                weight: 100
              offers:
              - offer_id: o1000000-0000-4000-8000-000000000001
                weight: 100
              save_traffic: true
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: flowId не uuid, невалидный JSON, ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: флоу не существует либо принадлежит кампании из другого workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '422':
          description: action_script не проходит линт, либо landings/offers невалидны
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Скоуп мутации идёт через JOIN c campaigns.workspace_id (у flows
        нет своего workspace_id) — целенаправленно закрывает IDOR на чужие флоу по
        угаданному flowId. Триггерит фоновую перезагрузку кэша.
    delete:
      operationId: delete_campaigns_id_flows_flowId
      summary: Удаляет флоу.
      tags:
      - Кампании и флоу
      x-permission: delete:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid кампании (не используется для скоупинга)
      - name: flowId
        in: path
        required: true
        schema:
          type: string
        description: uuid флоу
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: flowId не uuid или ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: флоу не существует либо принадлежит кампании из другого workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Тоже скоупится через JOIN с campaigns (IDOR-защита). Дополнительно
        удаляет материализованный action_script файл флоу (Scripts.RemoveFlow) и триггерит
        фоновую перезагрузку кэша.
  "/me/onboarding":
    get:
      operationId: get_me_onboarding
      summary: 'Возвращает текущее состояние онбординг-чеклиста: вычисленный на лету
        прогресс по шагам (домен, SSL, источник, лендинг, оффер, кампания, первый
        клик, первая конверсия) и флаги welcome/dismissed.'
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      responses:
        '200':
          description: успешно, состояние вычислено
          content:
            application/json:
              schema:
                type: object
                properties:
                  welcome_seen:
                    type: boolean
                    description: показывался ли приветственный модал
                  checklist_dismissed:
                    type: boolean
                    description: скрыт ли чеклист пользователем
                  steps:
                    type: array
                    description: массив шагов {key, done, count(omitempty)}
                  progress:
                    type: number
                    description: процент выполненных шагов 0..100
                  completed:
                    type: boolean
                    description: все ли обязательные шаги выполнены
              example:
                welcome_seen: true
                checklist_dismissed: false
                steps:
                - key: domain_added
                  done: true
                  count: 2
                - key: ssl_active
                  done: true
                  count: 1
                - key: source_added
                  done: true
                  count: 3
                - key: landing_added
                  done: true
                  count: 5
                - key: offer_added
                  done: true
                  count: 4
                - key: campaign_created
                  done: true
                  count: 2
                - key: first_click
                  done: true
                  count: 150
                - key: first_conversion
                  done: false
                progress: 87
                completed: false
        '401':
          description: пользователь не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Шаги не хранятся в БД, а вычисляются каждый раз запросами к Postgres
        и ClickHouse (first_click/first_conversion); ошибки отдельных запросов молча
        трактуются как count=0, поэтому эндпоинт никогда не отдаёт 500 из-за проблем
        со счётчиками.
  "/me/onboarding/welcome-seen":
    post:
      operationId: post_me_onboarding_welcome_seen
      summary: Помечает приветственный модал онбординга как показанный для текущего
        пользователя.
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      responses:
        '200':
          description: успешно установлен флаг
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '401':
          description: пользователь не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка обновления записи в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Использует COALESCE(welcome_seen_at, now()) — повторные вызовы
        идемпотентны и не сдвигают время первого просмотра.
  "/me/onboarding/dismiss":
    post:
      operationId: post_me_onboarding_dismiss
      summary: Скрывает блок чеклиста онбординга на дашборде текущего пользователя.
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      responses:
        '200':
          description: успешно скрыт чеклист
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '401':
          description: пользователь не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка обновления записи в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Отмена действия выполняется через POST /me/onboarding/reset.
  "/me/onboarding/reset":
    post:
      operationId: post_me_onboarding_reset
      summary: Сбрасывает флаги welcome_seen и checklist_dismissed, возвращая чеклист
        онбординга на дашборд.
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      responses:
        '200':
          description: успешно сброшено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '401':
          description: пользователь не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка обновления записи в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Обнуляет оба поля (welcome_seen_at и checklist_dismissed_at) в
        одном UPDATE.
  "/me/2fa/setup":
    post:
      operationId: post_me_2fa_setup
      summary: Генерирует новый TOTP-секрет для текущего пользователя и возвращает
        его вместе с otpauth:// URI и QR-кодом в виде data:URI (2FA при этом ещё не
        активируется).
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                password:
                  type: string
                  description: "(string) текущий пароль пользователя: привязка второго
                    фактора требует ре-аутентификации, чтобы угнанная сессия не могла
                    подключить свой authenticator"
              required:
              - password
            example:
              password: MySecurePass123
      responses:
        '200':
          description: секрет сгенерирован и сохранён
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret:
                    type: string
                    description: base32 TOTP-секрет
                  otpauth_uri:
                    type: string
                    description: otpauth:// URI для добавления в authenticator-приложение
                  uri:
                    type: string
                    description: алиас otpauth_uri для старых версий фронтенда
                  qr_url:
                    type: string
                    description: PNG QR-код в виде data:image/png;base64,... для <img
                      src>
              example:
                secret: JBSWY3DPEHPK3PXP
                otpauth_uri: otpauth://totp/minik:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=minik
                uri: otpauth://totp/minik:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=minik
                qr_url: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
        '400':
          description: невалидный JSON
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: неверный пароль
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '409':
          description: 2FA уже включена — сначала нужно вызвать /me/2fa/disable
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка генерации секрета/шифрования/QR/записи в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Секрет сохраняется в БД с mfa_enabled=false; если сконфигурирован
        Cipher, секрет хранится в БД в зашифрованном виде, но в ответе всегда возвращается
        открытым текстом. Setup работает только при выключенной 2FA (перезаписывая
        предыдущий незавершённый setup); при активной 2FA возвращается 409 — угнанная
        сессия не может молча сбросить секрет без пароля и кода.
  "/me/2fa/enable":
    post:
      operationId: post_me_2fa_enable
      summary: Активирует 2FA после подтверждения TOTP-кода, сгенерированного от секрета
        из /me/2fa/setup, и выдаёт набор backup-кодов.
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                code:
                  type: string
                  description: "(string) 6-значный TOTP-код от authenticator-приложения"
                secret:
                  type: string
                  description: "(string) принимается, но игнорируется сервером (для
                    совместимости со старым фронтендом, использует секрет, сохранённый
                    на шаге setup)"
              required:
              - code
            example:
              code: '482913'
      responses:
        '200':
          description: 2FA успешно включена
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "enabled"
                  backup_codes:
                    type: array
                    description: 10 одноразовых резервных кодов в формате XXXXX-XXXXX,
                      показываются один раз
              example:
                status: enabled
                backup_codes:
                - 3F2A1-9C0E7
                - 7B4D2-1A8F0
                - "..."
        '400':
          description: невалидный JSON, либо у пользователя нет ранее сгенерированного
            секрета (не вызывался /setup)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: неверный TOTP-код
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка генерации backup-кодов или записи в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Требует, чтобы перед этим был вызван POST /me/2fa/setup; при успехе
        перезаписывает набор backup-кодов пользователя новыми.
  "/me/2fa/disable":
    post:
      operationId: post_me_2fa_disable
      summary: Отключает 2FA для текущего пользователя после проверки пароля и текущего
        TOTP-кода.
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                password:
                  type: string
                  description: "(string) текущий пароль пользователя для подтверждения"
                code:
                  type: string
                  description: "(string) текущий 6-значный TOTP-код либо одноразовый
                    backup-код формата NNNNN-NNNNN (backup-код при использовании потребляется)"
              required:
              - password
              - code
            example:
              password: MySecurePass123
              code: '482913'
      responses:
        '200':
          description: 2FA отключена (или уже была отключена)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"disabled" при успехе, либо "not enabled" если 2FA
                      и так была выключена'
              example:
                status: disabled
        '400':
          description: невалидный JSON тела запроса
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: неверный пароль или неверный TOTP-код
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: пользователь не найден
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка обновления записи в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Если 2FA уже выключена, хендлер возвращает 200 {"status":"not enabled"}
        сразу, не проверяя пароль и код. Как и на логине, вместо TOTP-кода принимается
        backup-код — пароль при этом всё равно обязателен, потерянный телефон не запирает
        пользователя в 2FA.
  "/me/2fa/backup-codes":
    post:
      operationId: post_me_2fa_backup_codes
      summary: Перегенерирует набор резервных кодов 2FA текущего пользователя, полностью
        заменяя старые.
      tags:
      - Авторизация
      x-permission: none (authenticated only)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                code:
                  type: string
                  description: "(string) текущий 6-значный TOTP-код (backup-код здесь
                    не принимается)"
                secret:
                  type: string
                  description: "(string) принимается, но игнорируется сервером (для
                    совместимости со старым фронтендом)"
              required:
              - code
            example:
              code: '482913'
      responses:
        '200':
          description: коды успешно перегенерированы
          content:
            application/json:
              schema:
                type: object
                properties:
                  backup_codes:
                    type: array
                    description: 10 новых одноразовых резервных кодов в формате XXXXX-XXXXX
              example:
                backup_codes:
                - 3F2A1-9C0E7
                - 7B4D2-1A8F0
                - "..."
        '400':
          description: невалидный JSON тела запроса, либо 2FA не включена у пользователя
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: неверный TOTP-код
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: пользователь не найден
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка генерации кодов или записи в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Требует, чтобы 2FA уже была включена (mfa_enabled=true), иначе
        400; старые коды становятся недействительными сразу после перезаписи. Перегенерация
        требует действующий TOTP-код — угнанная сессия без второго фактора не может
        выпустить себе новые backup-коды.
  "/search":
    get:
      operationId: get_search
      summary: Глобальный поиск по кампаниям, флоу, лендингам, офферам, доменам, источникам
        трафика и партнёрским сетям текущего workspace для командной палитры.
      tags:
      - Авторизация
      x-permission: RequireMember (членство в активном workspace, перечитывается
        на каждый запрос) -- handler internally filters per-entity by HasPerm
      parameters:
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: "(string) поисковая строка; при длине менее 2 символов сразу
          возвращается пустой массив без запросов к БД"
      responses:
        '200':
          description: успешно, массив результатов (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    type:
                      type: string
                      description: 'тип сущности: campaign|flow|landing|offer|domain|source|network'
                    id:
                      type: string
                      description: идентификатор найденной сущности
                    name:
                      type: string
                      description: отображаемое имя
                    sub:
                      type: string
                      description: вторичная строка (alias/url), omitempty
                    campaign_id:
                      type: string
                      description: для type=flow — id родительской кампании, omitempty
              example:
              - type: campaign
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                name: Push RU GEO
                sub: push-ru
              - type: flow
                id: 6e1a2b3c-...
                name: Main flow
                campaign_id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
              - type: landing
                id: 9c0e7...
                name: LP Casino
                sub: https://example.com/lp1
        '401':
          description: пользователь не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: вызывающий не является участником активного workspace (RequireMember)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Каждая группа сущностей ограничена LIMIT 5 и возвращается только
        если у пользователя есть право view на соответствующий ресурс (campaigns/landings/offers/domains/settings
        через HasPerm); поиск выполняется через ILIKE с экранированием спецсимволов
        %, _ и \; для flows workspace-скоуп идёт через JOIN на campaigns, так как
        у flows нет собственного workspace_id.
  "/invitations/{token}":
    get:
      operationId: get_invitations_token
      summary: Возвращает публичную информацию о приглашении (email, роль, название
        workspace, срок действия) по токену для страницы принятия приглашения.
      tags:
      - Команда
      x-permission: public, no auth -- token is the auth, rate-limited
      parameters:
      - name: token
        in: path
        required: true
        schema:
          type: string
        description: сырой токен приглашения из ссылки /invite/{token}
      responses:
        '200':
          description: приглашение найдено, метаданные возвращены
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: id приглашения
                  workspace_id:
                    type: string
                    description: id workspace, в который приглашают
                  workspace_name:
                    type: string
                    description: название workspace
                  email:
                    type: string
                    description: email приглашённого
                  role:
                    type: string
                    description: роль, которая будет назначена
                  expires_at:
                    type: string
                    description: срок действия приглашения (ISO-время)
                  accepted:
                    type: boolean
                    description: уже принято ли приглашение
                  expired:
                    type: boolean
                    description: истёк ли срок действия
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                workspace_id: 7c1e2a3b-...
                workspace_name: Acme Media
                email: newuser@example.com
                role: manager
                expires_at: '2026-08-04T12:00:00Z'
                accepted: false
                expired: false
        '400':
          description: токен отсутствует в пути (практически недостижимо, т.к. маршрут
            требует сегмент)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: приглашение с таким токеном не найдено
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Токен сопоставляется в БД по SHA-256 хешу (сырой токен нигде не
        хранится); эндпоинт публичный и не требует JWT — по описанию задачи должен
        быть rate-limited на уровне роутера.
      security: []
  "/invitations/{token}/accept":
    post:
      operationId: post_invitations_token_accept
      summary: 'Принимает приглашение по токену: создаёт нового пользователя (или
        использует существующего по email), добавляет членство в workspace и помечает
        приглашение принятым.'
      tags:
      - Команда
      x-permission: public, no auth -- token is the auth, rate-limited
      parameters:
      - name: token
        in: path
        required: true
        schema:
          type: string
        description: сырой токен приглашения из ссылки /invite/{token}
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) отображаемое имя пользователя (используется
                    только при создании нового аккаунта, не валидируется)"
                password:
                  type: string
                  description: "(string) пароль для нового аккаунта, минимум 6 символов"
              required:
              - password
            example:
              name: Ivan Petrov
              password: MySecurePass123
      responses:
        '200':
          description: приглашение принято
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
                  email:
                    type: string
                    description: email принявшего приглашение пользователя
              example:
                status: ok
                email: newuser@example.com
        '400':
          description: токен отсутствует / невалидный JSON / пароль короче 6 символов
            / ошибка создания пользователя
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: роль в приглашении превышает текущий ранг пригласившего в workspace
            (пригласивший разжалован или удалён) — нужно новое приглашение от действующего
            участника
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: приглашение с таким токеном не найдено
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '409':
          description: приглашение уже было принято ранее
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '410':
          description: срок действия приглашения истёк
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка хеширования пароля, транзакции БД или коммита
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: "Если пользователь с таким email (регистронезависимо) уже существует,
        используется он, а присланный пароль игнорируется; иначе создаётся новый пользователь:
        глобальная users.role всегда 'manager' (globalDefaultRole — роль из приглашения
        глобальный уровень не повышает), права берутся из приглашения, default_workspace_id
        ставится на приглашающий workspace; роль из приглашения действует только внутри
        workspace через workspace_members; всё выполняется в одной транзакции, членство
        добавляется с ON CONFLICT DO NOTHING (идемпотентно); пишется запись в audit
        log (accept_invite)."
      security: []
  "/team/invitations":
    get:
      operationId: get_team_invitations
      summary: Возвращает список приглашений (ожидающих и недавно принятых) текущего
        workspace.
      tags:
      - Команда
      x-permission: admin:team
      responses:
        '200':
          description: успешно, массив приглашений (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: id приглашения
                    workspace_id:
                      type: string
                      description: id workspace
                    email:
                      type: string
                      description: email приглашённого
                    role:
                      type: string
                      description: назначаемая роль
                    permissions:
                      type: object
                      description: переопределения прав, сохранённые в приглашении
                    expires_at:
                      type: string
                      description: срок действия
                    accepted_at:
                      type: string
                      description: время принятия, null если ещё не принято
                    created_at:
                      type: string
                      description: время создания приглашения
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                workspace_id: 7c1e2a3b-...
                email: newuser@example.com
                role: manager
                permissions: {}
                expires_at: '2026-08-04T12:00:00Z'
                accepted_at: 
                created_at: '2026-07-28T09:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Ограничено последними 100 записями (ORDER BY created_at DESC LIMIT
        100), строго в рамках текущего workspace_id.
    post:
      operationId: post_team_invitations
      summary: Создаёт новое приглашение в текущий workspace и возвращает одноразовую
        ссылку-приглашение с токеном.
      tags:
      - Команда
      x-permission: admin:team
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  description: "(string) email приглашаемого (нормализуется в lower-case,
                    обязателен)"
                role:
                  type: string
                  description: (string) роль для приглашённого, по умолчанию "manager";
                    должна быть известной ролью (affiliate/analyst/manager/admin/owner),
                    иначе 400 'invalid role'; нельзя пригласить роль выше собственной
                    в workspace
                permissions:
                  type: object
                  description: "(object) переопределения прав по сущностям"
                ttl_hours:
                  type: number
                  description: "(number) срок действия приглашения в часах, по умолчанию
                    168 (7 дней)"
              required:
              - email
            example:
              email: newuser@example.com
              role: manager
              permissions: {}
              ttl_hours: 168
      responses:
        '201':
          description: приглашение создано
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: id созданного приглашения
                  token:
                    type: string
                    description: сырой токен приглашения — показывается только в этом
                      ответе, в БД хранится только его SHA-256 хеш
                  invite_url:
                    type: string
                    description: готовая ссылка-приглашение для отправки пользователю
                  expires_at:
                    type: string
                    description: срок действия
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                token: a1b2c3d4e5f6...
                invite_url: https://tracker.example.com/invite/a1b2c3d4e5f6...
                expires_at: '2026-08-04T12:00:00Z'
        '400':
          description: невалидный JSON, пустой email, неизвестная роль ('invalid role'),
            либо ошибка вставки в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: запрошенная роль выше роли пригласившего в workspace ('cannot
            invite a role above your own')
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Ссылка invite_url строится из PublicBase конфигурации, либо (fallback)
        из схемы/хоста текущего запроса, включая заголовок X-Forwarded-Proto; пишет
        запись в audit log (invite).
  "/team/invitations/{id}":
    delete:
      operationId: delete_team_invitations_id
      summary: Удаляет ещё не принятое приглашение текущего workspace.
      tags:
      - Команда
      x-permission: admin:team
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid приглашения
      responses:
        '200':
          description: приглашение удалено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: невалидный uuid или ошибка удаления
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: приглашение не найдено, принадлежит другому workspace, либо
            уже принято
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: DELETE ограничен условием workspace_id=$2 AND accepted_at IS NULL
        — защита от IDOR и от удаления уже принятых приглашений.
  "/notifications":
    get:
      operationId: get_notifications
      summary: Возвращает список уведомлений текущего пользователя (включая общесистемные
        с user_id=NULL), отсортированный по дате создания по убыванию.
      tags:
      - Уведомления
      x-permission: none (authenticated only)
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) максимум записей, по умолчанию 50, максимум 500; при
          отсутствии/невалидном/отрицательном значении применяется значение по умолчанию
          без ошибки"
      - name: unread
        in: query
        required: false
        schema:
          type: string
        description: (string) если равно "true", возвращаются только непрочитанные
          уведомления
      responses:
        '200':
          description: успешно, массив уведомлений (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: id уведомления
                    user_id:
                      type: string
                      description: получатель, omitempty (пусто для общесистемных)
                    level:
                      type: string
                      description: уровень важности уведомления
                    title:
                      type: string
                      description: заголовок
                    body:
                      type: string
                      description: текст уведомления
                    href:
                      type: string
                      description: ссылка перехода при клике
                    read_at:
                      type: string
                      description: время прочтения, omitempty если не прочитано
                    created_at:
                      type: string
                      description: время создания
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                level: warning
                title: SSL сертификат скоро истечёт
                body: Сертификат для example.com истекает через 3 дня
                href: "/domains"
                created_at: '2026-07-28T09:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: limit подставляется в SQL напрямую как строка (intToStr), но безопасно,
        так как значение уже провалидировано как целое число в диапазоне [1, max].
  "/notifications/unread-count":
    get:
      operationId: get_notifications_unread_count
      summary: Возвращает количество непрочитанных уведомлений текущего пользователя
        (включая общесистемные) для бейджа в интерфейсе.
      tags:
      - Уведомления
      x-permission: none (authenticated only)
      responses:
        '200':
          description: успешно, count возвращён (или 0 при ошибке БД)
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: number
                    description: число непрочитанных уведомлений
              example:
                count: 3
      description: Ошибка запроса к БД не приводит к 500 — она молча игнорируется,
        и count остаётся равным 0.
  "/notifications/{id}/read":
    post:
      operationId: post_notifications_id_read
      summary: Помечает одно уведомление текущего пользователя как прочитанное.
      tags:
      - Уведомления
      x-permission: none (authenticated only)
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid уведомления
      responses:
        '200':
          description: запрос выполнен (даже если ни одна строка не была затронута)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '400':
          description: невалидный uuid или ошибка выполнения UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: RowsAffected не проверяется, поэтому запрос на чужой или несуществующий
        id молча вернёт 200 "ok" без изменений; от IDOR защищает условие WHERE user_id=$2
        OR user_id IS NULL, не позволяющее пометить чужое персональное уведомление.
  "/notifications/read-all":
    post:
      operationId: post_notifications_read_all
      summary: Помечает все непрочитанные уведомления текущего пользователя (включая
        общесистемные) как прочитанные.
      tags:
      - Уведомления
      x-permission: none (authenticated only)
      responses:
        '200':
          description: успешно, все уведомления помечены прочитанными
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '500':
          description: ошибка выполнения UPDATE в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Одним UPDATE помечает и персональные, и общесистемные непрочитанные
        уведомления пользователя.
  "/domains/{id}/ssl":
    get:
      operationId: get_domains_id_ssl
      summary: Возвращает текущее состояние SSL-сертификата домена (без приватного
        ключа) — статус, даты выпуска/истечения, последнюю ошибку.
      tags:
      - Домены
      x-permission: view:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID домена. Невалидный UUID → 400 "invalid domain id".
      responses:
        '200':
          description: статус получен
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain_id:
                    type: string
                    description: UUID домена.
                  host:
                    type: string
                    description: Хост домена.
                  status:
                    type: string
                    description: 'Статус сертификата: none | issuing | active | failed.'
                  issued_at:
                    type: string
                    description: Дата-время выпуска сертификата (ISO-8601) или null,
                      если ещё не выпускался.
                  expires_at:
                    type: string
                    description: Дата-время истечения срока действия (ISO-8601) или
                      null.
                  last_error:
                    type: string
                    description: Текст последней ошибки ACME; пустая строка, если
                      ошибок не было.
                  last_attempt:
                    type: string
                    description: Дата-время последней попытки выпуска/продления (ISO-8601)
                      или null.
                  issuer:
                    type: string
                    description: Издатель сертификата, всегда "letsencrypt".
                  days_to_expiry:
                    type: number
                    description: Количество дней до истечения срока действия сертификата.
              example:
                domain_id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                host: track.example.com
                status: active
                issued_at: '2026-06-01T10:00:00Z'
                expires_at: '2026-08-30T10:00:00Z'
                last_error: ''
                last_attempt: '2026-06-01T10:00:00Z'
                issuer: letsencrypt
                days_to_expiry: 33
        '400':
          description: невалидный UUID домена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден или принадлежит другому воркспейсу
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: domainOwned проверяет workspace_id перед доступом (защита от IDOR);
        если для домена ещё не было попыток выпуска, возвращается «теневая» запись
        со status="none" вместо ошибки.
  "/domains/{id}/ssl/issue":
    post:
      operationId: post_domains_id_ssl_issue
      summary: Синхронно выпускает новый SSL-сертификат для домена через ACME (Let's
        Encrypt) и сохраняет его в БД.
      tags:
      - Домены
      x-permission: edit:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID домена. Невалидный UUID → 400 "invalid domain id".
      responses:
        '200':
          description: сертификат успешно выпущен
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain_id:
                    type: string
                    description: UUID домена.
                  host:
                    type: string
                    description: Хост домена.
                  status:
                    type: string
                    description: Статус после операции, обычно "active".
                  issued_at:
                    type: string
                    description: Дата-время выпуска (ISO-8601) или null.
                  expires_at:
                    type: string
                    description: Дата-время истечения (ISO-8601) или null.
                  last_error:
                    type: string
                    description: Текст последней ошибки ACME; пустая строка при успехе.
                  last_attempt:
                    type: string
                    description: Дата-время последней попытки (ISO-8601) или null.
                  issuer:
                    type: string
                    description: Издатель, всегда "letsencrypt".
                  days_to_expiry:
                    type: number
                    description: Количество дней до истечения.
              example:
                domain_id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                host: track.example.com
                status: active
                issued_at: '2026-07-28T12:00:00Z'
                expires_at: '2026-10-26T12:00:00Z'
                last_error: ''
                last_attempt: '2026-07-28T12:00:00Z'
                issuer: letsencrypt
                days_to_expiry: 90
        '400':
          description: невалидный UUID домена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден или принадлежит другому воркспейсу
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: внутренняя ошибка при сохранении сертификата в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '502':
          description: ошибка получения сертификата от ACME-провайдера (Let's Encrypt
            вернул ошибку)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '503':
          description: ACME не настроен на сервере (не задан ACME_EMAIL)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Блокирующий вызов (5–30с ожидания ACME); перед запросом сохраняет
        промежуточный статус "issuing"; приватный ключ шифруется через Cipher перед
        сохранением (если настроен); при успехе обновляет domains.ssl_status и пишет
        запись audit-лога "ssl_issue", при неудаче — ssl_status='failed'.
  "/domains/{id}/ssl/renew":
    post:
      operationId: post_domains_id_ssl_renew
      summary: Продлевает (переиздаёт) SSL-сертификат домена — на уровне кода полный
        алиас Issue, оставленный отдельным маршрутом только ради разных подписей в
        UI.
      tags:
      - Домены
      x-permission: edit:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID домена. Невалидный UUID → 400 "invalid domain id".
      responses:
        '200':
          description: сертификат успешно переиздан
          content:
            application/json:
              schema:
                type: object
                properties:
                  domain_id:
                    type: string
                    description: UUID домена.
                  host:
                    type: string
                    description: Хост домена.
                  status:
                    type: string
                    description: Статус после операции, обычно "active".
                  issued_at:
                    type: string
                    description: Дата-время выпуска (ISO-8601) или null.
                  expires_at:
                    type: string
                    description: Дата-время истечения (ISO-8601) или null.
                  last_error:
                    type: string
                    description: Текст последней ошибки ACME; пустая строка при успехе.
                  last_attempt:
                    type: string
                    description: Дата-время последней попытки (ISO-8601) или null.
                  issuer:
                    type: string
                    description: Издатель, всегда "letsencrypt".
                  days_to_expiry:
                    type: number
                    description: Количество дней до истечения.
              example:
                domain_id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                host: track.example.com
                status: active
                issued_at: '2026-07-28T12:00:00Z'
                expires_at: '2026-10-26T12:00:00Z'
                last_error: ''
                last_attempt: '2026-07-28T12:00:00Z'
                issuer: letsencrypt
                days_to_expiry: 90
        '400':
          description: невалидный UUID домена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден или принадлежит другому воркспейсу
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: внутренняя ошибка при сохранении сертификата в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '502':
          description: ошибка получения сертификата от ACME-провайдера
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '503':
          description: ACME не настроен на сервере (не задан ACME_EMAIL)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Реализация — прямой вызов h.Issue(w, r): своей логики и своей
        записи в audit-лог (тип события всё равно "ssl_issue", а не "ssl_renew") у
        этого хендлера нет, ACME-библиотека при Issue всегда переиздаёт сертификат
        заново.'
  "/domains/{id}/ssl/download":
    get:
      operationId: get_domains_id_ssl_download
      summary: Отдаёт ZIP-архив с файлами сертификата домена (cert.pem, chain.pem,
        fullchain.pem, key.pem) для подключения к внешнему прокси (Caddy/nginx).
      tags:
      - Домены
      x-permission: admin:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID домена. Невалидный UUID → 400 "invalid domain id".
      responses:
        '200':
          description: ZIP-архив с сертификатом успешно отдан
          content:
            application/json:
              schema:
                type: object
                properties: {}
              example: '(не JSON — бинарный ZIP-поток; Content-Type: application/zip;
                Content-Disposition: attachment; filename="<host>-cert.zip"; внутри
                архива: <host>/cert.pem, <host>/chain.pem, <host>/fullchain.pem, <host>/key.pem)'
        '400':
          description: невалидный UUID домена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден/чужой воркспейс, либо сертификат ещё ни разу
            не выпускался (cert_pem пуст)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Приватный ключ расшифровывается через Cipher перед упаковкой
        в архив — чувствительная операция, поэтому маршрут гейтится на admin:domains
        (RequirePerm PermAdmin, та же планка, что и управление API-ключами): роль
        analyst (только просмотр) скачать приватный ключ не может.'
  "/templates/traffic-sources":
    get:
      operationId: get_templates_traffic_sources
      summary: Возвращает список встроенных пресетов источников трафика (Facebook,
        TikTok, push-сети и т.д.) для быстрого создания.
      tags:
      - Шаблоны
      x-permission: view:settings
      responses:
        '200':
          description: список получен
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    key:
                      type: string
                      description: Стабильный ключ шаблона, например "facebook".
                    name:
                      type: string
                      description: Человекочитаемое название.
                    category:
                      type: string
                      description: 'Категория: ads | push | native | sms | other.'
                    logo:
                      type: string
                      description: Эмодзи или URL логотипа.
                    description:
                      type: string
                      description: Короткое описание источника.
                    docs_url:
                      type: string
                      description: Ссылка на документацию источника.
                    postback_url:
                      type: string
                      description: Шаблон исходящего postback URL (может быть пустой
                        строкой).
                    parameters:
                      type: array
                      description: Список параметров вида {name, param, token}, специфичных
                        для источника.
                    notes:
                      type: string
                      description: Заметки к шаблону.
              example:
              - key: facebook
                name: Facebook Ads
                category: ads
                logo: "\U0001F4D8"
                description: Facebook/Meta Ads traffic source preset
                docs_url: https://www.facebook.com/business/help
                postback_url: ''
                parameters:
                - name: Click ID
                  param: sub1
                  token: "{{clickid}}"
                notes: ''
        '500':
          description: ошибка чтения встроенных JSON-шаблонов (на практике недостижимо
            — файлы вшиты в бинарник)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Данные статичны и встроены в бинарник через go:embed, БД не используется;
        список отсортирован по name.
  "/templates/affiliate-networks":
    get:
      operationId: get_templates_affiliate_networks
      summary: Возвращает список встроенных пресетов партнёрских сетей (MyLead, AdCombo
        и т.д.) для быстрого создания.
      tags:
      - Шаблоны
      x-permission: view:settings
      responses:
        '200':
          description: список получен
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    key:
                      type: string
                      description: Стабильный ключ шаблона, например "mylead".
                    name:
                      type: string
                      description: Человекочитаемое название.
                    category:
                      type: string
                      description: 'Категория: cpa | crypto | gambling | nutra | adult
                        | other.'
                    logo:
                      type: string
                      description: Эмодзи или URL логотипа.
                    description:
                      type: string
                      description: Короткое описание сети.
                    docs_url:
                      type: string
                      description: Ссылка на документацию сети.
                    postback_url_template:
                      type: string
                      description: Шаблон входящего postback URL от сети.
                    status_mapping:
                      type: object
                      description: Соответствие статусов сети своим (lead|sale|rejected|trash),
                        ключ — статус сети, значение — статус minik.
                    default_currency:
                      type: string
                      description: Валюта по умолчанию для этой сети.
                    notes:
                      type: string
                      description: Заметки к шаблону.
              example:
              - key: mylead
                name: MyLead
                category: cpa
                logo: "\U0001F7E2"
                description: MyLead affiliate network preset
                docs_url: https://mylead.eu/docs
                postback_url_template: https://your-tracker.com/postback?clickid={subid}&status={status}&payout={payout}
                status_mapping:
                  approved: sale
                  pending: lead
                  declined: rejected
                default_currency: USD
                notes: ''
        '500':
          description: ошибка чтения встроенных JSON-шаблонов (на практике недостижимо)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Данные статичны и встроены в бинарник через go:embed, БД не используется;
        список отсортирован по name.
  "/traffic-sources/from-template/{key}":
    post:
      operationId: post_traffic_sources_from_template_key
      summary: Создаёт новый источник трафика в текущем воркспейсе на основе встроенного
        шаблона по его ключу, с опциональным переопределением имени/заметок/postback
        URL.
      tags:
      - Источники трафика
      x-permission: edit:settings
      parameters:
      - name: key
        in: path
        required: true
        schema:
          type: string
        description: Ключ шаблона источника трафика (SourceTemplate.Key), например
          "facebook". Если шаблон с таким ключом не найден — 404.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) Переопределяет имя создаваемого источника;
                    если пусто или тело запроса отсутствует — берётся имя из шаблона."
                notes:
                  type: string
                  description: "(string) Переопределяет заметки; если пусто — берутся
                    из шаблона."
                postback_url:
                  type: string
                  description: "(string) Переопределяет postback URL; если пусто —
                    берётся из шаблона."
            example:
              name: My FB Ads
              notes: основной рекламный кабинет
              postback_url: https://track.example.com/pb
      responses:
        '201':
          description: источник трафика создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID созданной записи traffic_sources.
                  name:
                    type: string
                    description: Итоговое имя (переопределённое либо взятое из шаблона).
                  key:
                    type: string
                    description: Ключ использованного шаблона.
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                name: My FB Ads
                key: facebook
        '400':
          description: некорректное тело запроса (невалидный JSON или неизвестное
            поле) либо ошибка вставки в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: шаблон с указанным key не найден
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Тело запроса читается только если Content-Length > 0, то есть запрос
        вообще без тела допустим; параметры (parameters) всегда берутся из шаблона
        без возможности переопределения; после успешной вставки в фоне (goroutine)
        перезагружается in-memory кэш.
  "/affiliate-networks/from-template/{key}":
    post:
      operationId: post_affiliate_networks_from_template_key
      summary: Создаёт новую партнёрскую сеть в текущем воркспейсе на основе встроенного
        шаблона по его ключу, с опциональным переопределением имени/заметок/postback-шаблона/валюты.
      tags:
      - Партнёрские сети
      x-permission: edit:settings
      parameters:
      - name: key
        in: path
        required: true
        schema:
          type: string
        description: Ключ шаблона партнёрской сети (NetworkTemplate.Key), например
          "mylead". Если шаблон с таким ключом не найден — 404.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) Переопределяет имя создаваемой сети; если
                    пусто или тело запроса отсутствует — берётся имя из шаблона."
                notes:
                  type: string
                  description: "(string) Переопределяет заметки; если пусто — берутся
                    из шаблона."
                postback_url_template:
                  type: string
                  description: "(string) Переопределяет шаблон входящего postback
                    URL; если пусто — берётся из шаблона."
                default_currency:
                  type: string
                  description: "(string) Переопределяет валюту по умолчанию; если
                    пусто — берётся из шаблона."
            example:
              name: MyLead RU
              notes: ''
              postback_url_template: https://track.example.com/postback?clickid={subid}&status={status}
              default_currency: EUR
      responses:
        '201':
          description: партнёрская сеть создана
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID созданной записи affiliate_networks.
                  name:
                    type: string
                    description: Итоговое имя (переопределённое либо взятое из шаблона).
                  key:
                    type: string
                    description: Ключ использованного шаблона.
              example:
                id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                name: MyLead RU
                key: mylead
        '400':
          description: некорректное тело запроса (невалидный JSON или неизвестное
            поле) либо ошибка вставки в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: шаблон с указанным key не найден
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: В отличие от CreateSourceFromTemplate этот хендлер НЕ перезагружает
        кэш после создания (вызова reload() нет), хотя структурно у него тоже есть
        доступ к Cache через дублирующий на handler-уровне h.Cache — потенциальная
        несогласованность, если affiliate_networks где-то кэшируются в памяти; status_mapping
        всегда берётся из шаблона без переопределения.
  "/domains/bulk":
    post:
      operationId: post_domains_bulk
      summary: Создаёт несколько доменов за один запрос, обрабатывая каждый independently
        — ошибка по одному хосту не откатывает остальные.
      tags:
      - Домены
      x-permission: edit:domains
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                items:
                  type: array
                  description: "(array) Список доменов для создания."
                items[].host:
                  type: string
                  description: "(string) Хост домена; приводится к нижнему регистру
                    и обрезается по пробелам. Если после этого пуст — элемент молча
                    пропускается (не попадает ни в created, ни в errors). Должен пройти
                    проверку по regex hostname (с точкой/TLD, до 253 символов), иначе
                    попадёт в errors."
                items[].type:
                  type: string
                  description: (string) tracking | landing | primary. Если пусто —
                    по умолчанию "tracking"; любое другое значение приводит к записи
                    в errors.
                items[].notes:
                  type: string
                  description: "(string) Произвольная заметка к домену."
              required:
              - items
              - items[].host
            example:
              items:
              - host: track1.example.com
                type: tracking
                notes: ''
              - host: lp.example.com
                type: landing
                notes: лендинг-домен
      responses:
        '200':
          description: запрос обработан (даже если часть элементов завершилась ошибкой
            — итоговый статус всё равно 200)
          content:
            application/json:
              schema:
                type: object
                properties:
                  created:
                    type: number
                    description: Количество успешно созданных доменов.
                  errors:
                    type: array
                    description: Список ошибок по конкретным хостам (может быть пустым
                      массивом).
                  errors[].host:
                    type: string
                    description: Хост, на котором произошла ошибка.
                  errors[].error:
                    type: string
                    description: Текст ошибки (invalid type, invalid hostname, либо
                      текст ошибки БД, например дубликат).
              example:
                created: 1
                errors:
                - host: bad_host!
                  error: invalid hostname
        '400':
          description: некорректное тело запроса целиком (невалидный JSON или неизвестное
            поле верхнего уровня)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Каждая строка вставляется отдельным INSERT (без общей транзакции);
        для каждого успешно созданного домена в фоне (goroutine) запускается WarmTLS
        — best-effort прогрев edge-сертификата, как в POST /domains; после обработки
        в фоне (goroutine) перезагружается кэш доменов; пишется одна сводная запись
        audit-лога "bulk_create" со счётчиками created/errors, а не по каждому домену
        отдельно.
  "/domains/bulk-action":
    post:
      operationId: post_domains_bulk_action
      summary: Выполняет массовое действие (выпуск SSL, продление SSL или удаление)
        над списком доменов по их ID.
      tags:
      - Домены
      x-permission: edit:domains
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                action:
                  type: string
                  description: '(string) issue_ssl | renew_ssl | delete. Любое другое
                    значение → 400 "unknown action: ...".'
                ids:
                  type: array
                  description: (array) Список UUID доменов. Пустой массив → 400 "no
                    ids selected".
              required:
              - action
              - ids
            example:
              action: issue_ssl
              ids:
              - 3fa85f64-5717-4562-b3fc-2c963f66afa6
              - 7c9e6679-7425-40de-944b-e07fc1f90ae7
      responses:
        '200':
          description: запрос обработан (общий статус всегда 200, ошибки по отдельным
            доменам возвращаются внутри results)
          content:
            application/json:
              schema:
                type: object
                properties:
                  action:
                    type: string
                    description: Эхо выполненного действия.
                  results:
                    type: array
                    description: Результат по каждому переданному ID.
                  results[].id:
                    type: string
                    description: UUID домена.
                  results[].host:
                    type: string
                    description: Хост домена (пусто, если id не найден в воркспейсе
                      вызывающего).
                  results[].ok:
                    type: boolean
                    description: Успешно ли выполнено действие для этого домена.
                  results[].error:
                    type: string
                    description: Текст ошибки, поле отсутствует в JSON (omitempty)
                      при успехе.
                  total:
                    type: number
                    description: Общее количество переданных ID.
                  ok:
                    type: number
                    description: Количество успешно обработанных доменов.
                  failed:
                    type: number
                    description: Количество доменов с ошибкой.
              example:
                action: issue_ssl
                results:
                - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  host: track1.example.com
                  ok: true
                - id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                  host: ''
                  ok: false
                  error: domain not found
                total: 2
                ok: 1
                failed: 1
        '400':
          description: некорректное тело запроса, неизвестное значение action, либо
            пустой ids
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Принадлежность домена воркспейсу проверяется только через предзагрузку
        hosts (SELECT ... WHERE id = ANY($1) AND workspace_id=$2) — если id принадлежит
        другому воркспейсу, host останется пустым и issue_ssl/renew_ssl вернёт per-item
        ошибку "domain not found"; для delete чужой/несуществующий id просто не будет
        затронут DELETE-запросом (WHERE workspace_id=$2), при этом RowsAffected не
        проверяется, поэтому item.ok=true выставляется даже при 0 затронутых строк;
        SSL-операции выполняются последовательно с таймаутом 60с на домен (запрос
        на много доменов может занять минуты); кэш доменов перезагружается фоном только
        при action=delete; пишется одна сводная запись audit-лога "bulk_"+action.
  "/campaigns/{id}/clone":
    post:
      operationId: post_campaigns_id_clone
      summary: Клонирует кампанию вместе со всеми её флоу под новым алиасом, новая
        копия создаётся в состоянии "paused".
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID кампании-источника; при невалидном UUID — 400 "invalid id"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: (string) Имя новой кампании; если пусто — берётся "<имя
                    оригинала> (copy)"
                alias:
                  type: string
                  description: "(string) Алиас новой кампании; если пусто — генерируется
                    случайный 6-символьный алиас"
            example:
              name: Facebook UK — copy
              alias: fbuk2
      responses:
        '201':
          description: кампания и её флоу успешно склонированы
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: UUID созданной копии кампании
              example:
                id: 6c9d2b0a-1f3e-4a2b-9c7d-1a2b3c4d5e6f
        '400':
          description: невалидный id ИЛИ кампания с таким id не найдена в рабочем
            пространстве (INSERT...SELECT вернул 0 строк) ИЛИ конфликт алиаса
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка начала/коммита транзакции
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Тело запроса читается через readJSON, но ошибка декодирования игнорируется
        (`_ = readJSON(...)`) — в отличие от остальных ручек, здесь DisallowUnknownFields
        фактически не действует и битый JSON просто оставляет поля пустыми, а не даёт
        400. Отсутствие кампании в чужом workspace возвращает 400, а не 404, так как
        ошибка приходит из QueryRow.Scan(pgx.ErrNoRows). Ответ содержит только id,
        а не полный объект кампании (отличие от общей конвенции create-ручек). Успешный
        клон запускает фоновый h.reload() (перезагрузка кэша роутинга).
  "/campaigns/bulk/archive":
    post:
      operationId: post_campaigns_bulk_archive
      summary: Массово переводит указанные кампании в состояние archived и проставляет
        archived_at.
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  description: "(array) Массив UUID кампаний для архивации; при пустом
                    массиве запрос — no-op, сразу возвращается updated:0"
              required:
              - ids
            example:
              ids:
              - 6c9d2b0a-1f3e-4a2b-9c7d-1a2b3c4d5e6f
              - a1b2c3d4-e5f6-47a8-9b0c-1d2e3f405060
      responses:
        '200':
          description: ok, включая случай updated:0 при пустом ids или отсутствии
            совпадений
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated:
                    type: number
                    description: Количество реально обновлённых строк (RowsAffected)
              example:
                updated: 2
        '400':
          description: невалидный JSON тела запроса или ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: UPDATE ограничен WHERE workspace_id=$2 — кампании чужого workspace
        просто не попадут в RowsAffected, без явной ошибки. Успешное обновление запускает
        фоновый h.reload().
  "/campaigns/bulk/unarchive":
    post:
      operationId: post_campaigns_bulk_unarchive
      summary: Массово возвращает указанные кампании из архива в состояние paused,
        сбрасывая archived_at.
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  description: "(array) Массив UUID кампаний для разархивации; при
                    пустом массиве — no-op, updated:0"
              required:
              - ids
            example:
              ids:
              - 6c9d2b0a-1f3e-4a2b-9c7d-1a2b3c4d5e6f
      responses:
        '200':
          description: ok, включая updated:0
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated:
                    type: number
                    description: Количество реально обновлённых строк
              example:
                updated: 1
        '400':
          description: невалидный JSON тела запроса или ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: UPDATE ограничен WHERE workspace_id=$2. Успешное обновление запускает
        фоновый h.reload().
  "/campaigns/bulk/state":
    post:
      operationId: post_campaigns_bulk_state
      summary: Массово меняет операционное состояние (active/paused/draft) у указанных
        кампаний.
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  description: "(array) Массив UUID кампаний; при пустом массиве —
                    no-op, updated:0"
                state:
                  type: string
                  description: (string) Новое состояние; допустимы только active,
                    paused, draft — иначе 400 "invalid state" (archived сюда не входит,
                    для архивации есть отдельные ручки)
              required:
              - ids
              - state
            example:
              ids:
              - 6c9d2b0a-1f3e-4a2b-9c7d-1a2b3c4d5e6f
              state: active
      responses:
        '200':
          description: ok, включая updated:0
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated:
                    type: number
                    description: Количество реально обновлённых строк
              example:
                updated: 1
        '400':
          description: невалидный JSON, недопустимое значение state, или ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Проверка state выполняется до проверки на пустой ids. UPDATE ограничен
        WHERE workspace_id=$3. Успех запускает фоновый h.reload().
  "/campaigns/bulk/tags":
    post:
      operationId: post_campaigns_bulk_tags
      summary: Массово добавляет и/или удаляет теги у указанных кампаний.
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  description: "(array) Массив UUID кампаний; при пустом массиве —
                    no-op, updated:0"
                add:
                  type: array
                  description: "(array) Теги, которые нужно добавить (объединяются
                    с существующими через UNION/DISTINCT)"
                drop:
                  type: array
                  description: "(array) Теги, которые нужно удалить из списка тегов
                    кампаний"
              required:
              - ids
            example:
              ids:
              - 6c9d2b0a-1f3e-4a2b-9c7d-1a2b3c4d5e6f
              add:
              - promo
              drop:
              - old-tag
      responses:
        '200':
          description: ok, включая updated:0
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated:
                    type: number
                    description: RowsAffected последнего выполненного UPDATE (add
                      или drop, смотря что было передано)
              example:
                updated: 1
        '400':
          description: невалидный JSON или ошибка одного из UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Если и add, и drop переданы, выполняется два отдельных UPDATE,
        и в ответе updated отражает только результат ВТОРОГО (drop) запроса — результат
        add-запроса теряется/перезаписывается. Если оба массива пустые (но ids непустой),
        tag остаётся нулевым CommandTag и updated всегда 0, хотя формально запрос
        не был no-op. UPDATE ограничен workspace_id. Успех запускает фоновый h.reload().
  "/campaigns/bulk/group":
    post:
      operationId: post_campaigns_bulk_group
      summary: Массово переносит указанные кампании в другую группу (group_name).
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ids:
                  type: array
                  description: "(array) Массив UUID кампаний; при пустом массиве —
                    no-op, updated:0"
                group:
                  type: string
                  description: "(string) Новое имя группы (обрезается пробелы через
                    strings.TrimSpace); пустая строка допустима и очищает группу"
              required:
              - ids
            example:
              ids:
              - 6c9d2b0a-1f3e-4a2b-9c7d-1a2b3c4d5e6f
              group: Q3 Push
      responses:
        '200':
          description: ok, включая updated:0
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated:
                    type: number
                    description: Количество реально обновлённых строк
              example:
                updated: 1
        '400':
          description: невалидный JSON или ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: UPDATE ограничен workspace_id. Успех запускает фоновый h.reload().
  "/campaigns/{id}/unarchive":
    post:
      operationId: post_campaigns_id_unarchive
      summary: Разархивирует одну кампанию, переводя её в состояние paused и сбрасывая
        archived_at (обратная операция архивации).
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID кампании; при невалидном UUID — 400 "invalid id"
      responses:
        '200':
          description: ok — возвращается даже если id существует, но принадлежит другому
            workspace, или вообще не найден (RowsAffected не проверяется)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда "ok" при успехе
              example:
                status: ok
        '400':
          description: невалидный id или ошибка выполнения UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: В отличие от аналогичных ручек, здесь RowsAffected не проверяется
        — запрос к несуществующему или чужому id молча вернёт 200 "ok", хотя фактически
        ничего не изменилось. UPDATE всё же ограничен WHERE workspace_id=$2, так что
        чужие данные не модифицируются. Успех запускает фоновый h.reload().
  "/campaigns/{id}/export":
    get:
      operationId: get_campaigns_id_export
      summary: Экспортирует кампанию вместе со всеми флоу в самодостаточный JSON-бандл
        для последующего импорта (в т.ч. в другой инстанс).
      tags:
      - Кампании и флоу
      x-permission: view:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID кампании; при невалидном UUID — 400 "invalid id"
      responses:
        '200':
          description: 'ok — отдаётся с заголовком Content-Disposition: attachment
            для скачивания как файл'
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:
                    type: number
                    description: Версия формата бандла, сейчас всегда 1
                  exported_at:
                    type: string
                    description: Момент экспорта в UTC (RFC3339)
                  campaign:
                    type: object
                    description: Объект Campaign (частичный набор колонок — вычисляемые
                      tracking_url/tracking_scheme/domain_host и вложенные flows не
                      заполняются)
                  flows:
                    type: array
                    description: Полный список Flow этой кампании, отсортированный
                      по position
              example:
                version: 1
                exported_at: '2026-07-28T10:15:00Z'
                campaign:
                  id: 6c9d2b0a-1f3e-4a2b-9c7d-1a2b3c4d5e6f
                  alias: fbuk1
                  name: Facebook UK
                  notes: ''
                  group_name: ''
                  domain_id: 3b2a1c0d-9e8f-47a6-b5c4-d3e2f1a09876
                  traffic_source_id: 
                  parameters: []
                  rotation_mode: weight
                  bind_visitor: none
                  uniqueness_method: click_id
                  uniqueness_ttl_hours: 24
                  cost_mode: auto
                  cost_value: 0
                  currency: USD
                  s2s_postback_url: ''
                  s2s_statuses: 
                  tags: []
                  state: active
                  created_at: '2026-06-01T09:00:00Z'
                  updated_at: '2026-07-20T12:00:00Z'
                flows:
                - id: 9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a
                  campaign_id: 6c9d2b0a-1f3e-4a2b-9c7d-1a2b3c4d5e6f
                  name: Main
                  position: 0
                  type: regular
                  weight: 100
                  state: active
                  scheme: landings_offers
                  redirect_type: '302'
                  redirect_url: ''
                  filters: []
                  filters_logic: AND
                  landings: []
                  offers: []
                  action_script: ''
                  save_traffic: true
                  created_at: '2026-06-01T09:00:00Z'
        '400':
          description: невалидный id
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: кампания не найдена (в т.ч. принадлежит другому workspace)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка чтения кампании или флоу из БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Ответ пишется напрямую через json.Encoder (не через writeJSON)
        и содержит заголовок Content-Disposition: attachment; filename="campaign-<alias>.json"
        — браузер предложит скачать файл. Вычисляемые поля tracking_url/tracking_scheme/domain_host
        в campaign не заполняются (enrichTrackingURLs не вызывается).'
  "/campaigns/import":
    post:
      operationId: post_campaigns_import
      summary: Создаёт новую кампанию и её флоу из ранее экспортированного JSON-бандла,
        генерируя новые ID.
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                version:
                  type: number
                  description: (number) Версия бандла — должна быть равна 1, иначе
                    400 "unsupported bundle version"
                exported_at:
                  type: string
                  description: "(string) Дата экспорта из исходного бандла; декодируется,
                    но нигде не используется"
                campaign:
                  type: object
                  description: '(object) Объект Campaign-источник. Используемые поля:
                    alias (если пусто — генерируется случайный), name (к нему добавляется
                    суффикс " (imported)"), notes, domain_id, traffic_source_id, parameters
                    (если nil — []), rotation_mode, bind_visitor, uniqueness_method,
                    uniqueness_ttl_hours, cost_mode, cost_value, currency (если пусто
                    — USD), s2s_postback_url, tags (если nil — []). Поле state из
                    бандла игнорируется и всегда принудительно ставится в "paused"'
                flows:
                  type: array
                  description: "(array) Массив Flow для импорта; для каждого campaign_id
                    перезаписывается новым ID кампании, а пустые type/state/scheme/redirect_type/filters_logic/weight/filters/landings/offers
                    получают значения по умолчанию через defaultFlow()"
              required:
              - version
            example:
              version: 1
              exported_at: '2026-07-28T10:15:00Z'
              campaign:
                alias: ''
                name: Facebook UK
                notes: ''
                domain_id: 
                traffic_source_id: 
                parameters: []
                rotation_mode: weight
                bind_visitor: none
                uniqueness_method: click_id
                uniqueness_ttl_hours: 24
                cost_mode: auto
                cost_value: 0
                currency: USD
                s2s_postback_url: ''
                tags: []
                state: active
              flows:
              - name: Main
                position: 0
                type: regular
                weight: 100
                state: active
                scheme: landings_offers
                redirect_type: '302'
                redirect_url: ''
                filters: []
                filters_logic: AND
                landings: []
                offers: []
                action_script: ''
                save_traffic: true
      responses:
        '201':
          description: кампания и её флоу успешно импортированы
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: UUID новой (импортированной) кампании
              example:
                id: 1a2b3c4d-5e6f-4708-9a0b-1c2d3e4f5061
        '400':
          description: битый JSON бандла, неподдерживаемая версия, domain_id/traffic_source_id
            кампании или landings/offers флоу не принадлежат этому workspace, ошибка
            INSERT кампании или одного из флоу
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '413':
          description: тело запроса превышает 4 MiB (общий лимит /admin_api/v1) —
            крупный бандл с большим числом флоу/скриптов не пройдёт; chunked-тело
            без Content-Length обрезается на 4 MiB и возвращает 400 на декодировании
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка начала/коммита транзакции
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: К имени кампании автоматически добавляется суффикс " (imported)",
        а state всегда принудительно "paused" независимо от значения в бандле — так
        свежеимпортированная кампания никогда не запускается сама. Ответ содержит
        только id, а не полный объект (как и Clone). Бандл — клиентские данные, поэтому
        все его внешние ключи проходят ту же tenant-проверку, что и create/update;
        в отличие от POST /campaigns здесь это 400, а не 422. Успех запускает фоновый
        h.reload().
  "/import/keitaro":
    post:
      operationId: post_import_keitaro
      summary: Импортирует трекер Keitaro в текущий workspace — сущности (домены,
        источники, сети, лендинги, офферы, кампании, флоу, статусы конверсий) из mysqldump
        конфигурационной БД MariaDB и, опционально, историю кликов/конверсий из TSV-экспорта
        ClickHouse-таблицы keitaro.events.
      tags:
      - Кампании и флоу
      x-permission: 'admin:settings + роль owner/admin в workspace, rate-limited 0.05rps/burst3
        на IP (~3 запроса в минуту)'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                dump:
                  type: string
                  description: '(file) mysqldump конфигурационной БД Keitaro — полный
                    дамп (схема + данные, без --no-create-info); gzip распознаётся
                    автоматически; распакованное содержимое до 2GB — таблицы кликов/конверсий
                    исключайте (mysqldump --ignore-table=<db>.keitaro_clicks ...)'
                events:
                  type: string
                  description: '(file) история кликов/конверсий: clickhouse-client
                    -q "SELECT * FROM keitaro.events FORMAT TSV"; gzip распознаётся
                    автоматически; распакованное содержимое до 16GB'
              required:
              - dump
            example: 'multipart/form-data; поле "dump": keitaro.sql.gz, поле "events":
              events.tsv.gz (всё тело запроса — до 1GB)'
      responses:
        '200':
          description: импорт выполнен; проблемы этапа событий (нечитаемый файл, недоступный
            ClickHouse, обрыв) не валят запрос, а попадают в warnings — повторный запуск
            докатит события без дублей
          content:
            application/json:
              schema:
                type: object
                properties:
                  domains:
                    type: number
                    description: создано доменов
                  traffic_sources:
                    type: number
                    description: создано источников трафика
                  affiliate_networks:
                    type: number
                    description: создано партнёрских сетей
                  landings:
                    type: number
                    description: создано лендингов
                  offers:
                    type: number
                    description: создано офферов
                  campaigns:
                    type: number
                    description: создано кампаний
                  flows:
                    type: number
                    description: создано флоу (из стримов Keitaro)
                  statuses:
                    type: number
                    description: создано статусов конверсий (кастомные conversion
                      types)
                  clicks:
                    type: number
                    description: импортировано кликов в ClickHouse
                  conversions:
                    type: number
                    description: импортировано конверсий в ClickHouse
                  imported_deleted:
                    type: number
                    description: сущностей импортировано из корзины Keitaro (тег keitaro-deleted,
                      paused/архив)
                  skipped_deleted:
                    type: number
                    description: удалённых сущностей пропущено (домены из корзины
                      не импортируются)
                  already_existed:
                    type: number
                    description: сущностей уже существовало от прошлого запуска (идемпотентный
                      пропуск)
                  skipped_events:
                    type: number
                    description: строк events пропущено (битые строки, cancel-строки
                      collapsing-движка, кампании вне дампа, неизвестный тип)
                  excluded_events:
                    type: number
                    description: конверсий отброшено — их тип исключён из тоталов
                      самим Keitaro (is_included_in_conversions=0)
                  duplicate_events:
                    type: number
                    description: дубликатов событий отброшено (повторные версии, повторный
                      запуск)
                  warnings:
                    type: array
                    description: до 50 уникальных строк-предупреждений
              example:
                domains: 2
                traffic_sources: 5
                affiliate_networks: 3
                landings: 12
                offers: 20
                campaigns: 8
                flows: 19
                statuses: 2
                clicks: 184223
                conversions: 3110
                imported_deleted: 4
                skipped_deleted: 1
                already_existed: 0
                skipped_events: 12
                excluded_events: 3
                duplicate_events: 0
                warnings:
                - 7 stream filters are not imported — recreate flow filters manually
        '400':
          description: невалидная multipart-форма или тело больше 1GB; отсутствует
            поле dump; нечитаемый gzip; ошибка парсинга дампа; распакованный дамп
            больше 2GB; в дампе нет таблиц Keitaro; дамп с данными без CREATE TABLE
            (--no-create-info)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: у вызывающего нет роли owner/admin в workspace (handler проверяет
            membership поверх admin:settings)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '429':
          description: 'превышен rate limit — text/plain "rate limit exceeded", заголовок
            Retry-After: 1'
        '500':
          description: ошибка импорта сущностей — PG-транзакция откатывается целиком
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Импорт идемпотентен: id сущностей детерминированы (UUIDv5 от workspace
        + keitaro id), существующие строки идут в already_existed; клики дедуплицируются
        по click_id, конверсии — по ключу (click_id, source_event_id) + version. Сущности
        пишутся в одной PG-транзакции, события — батчами по 2000 в ClickHouse после
        коммита. Из settings дампа берутся валюта трекера и default_timezone (времена
        событий → UTC). Conversion types 1–4 → sale/lead/rejected/trash, 5/6 → reg/dep,
        кастомные → статусы workspace; is_included_in_revenue=0 → revenue 0. cost/revenue
        конвертируются в базовую валюту workspace по FX-курсу на дату клика. Не переносятся
        (warning): фильтры стримов, файлы локальных лендингов, postback-макросы без
        minik-эквивалента. Коллизия alias кампании → суффикс; домен другого workspace
        → пропуск. last_click_meta для sub_id-токенов заполняется только если вызывающий
        — участник всех workspace инстанса, иначе warning (держите постбэк-URL Keitaro
        живым). Клики старше 2 лет / конверсии старше 5 лет удалит ближайший TTL-merge
        ClickHouse (warning). Audit-лог (action="import", entity="keitaro") с полным
        отчётом; фоновая перезагрузка кэша.'
  "/script/test":
    post:
      operationId: post_script_test
      summary: Тестово выполняет action_script (сохранённый или ещё не сохранённый
        черновик) против синтетического клика и возвращает результат/лог, не затрагивая
        реальный трафик.
      tags:
      - Кампании и флоу
      x-permission: edit:campaigns
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                script:
                  type: string
                  description: (string) Текст PHP-скрипта для выполнения; пустая строка
                    (после trim) — 400 "script required"
                click:
                  type: object
                  description: (object) Переопределения поверх синтетического клика
                    по умолчанию, например {"country":"DE"}; ключ "subs" мержится
                    вглубь в подкарту subs синтетического клика, а не заменяет её
                    целиком
              required:
              - script
            example:
              script: "<?php if ($click['country'] === 'DE') { $action = 'redirect';
                $url = 'https://example.com/de'; } ?>"
              click:
                country: DE
                subs:
                  sub_id_1: zone42
      responses:
        '200':
          description: ok — включая случай логической ошибки скрипта (ok:false с описанием
            error/line внутри тела 200)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    description: Успешно ли выполнился скрипт (без синтаксической/рантайм
                      ошибки)
                  error:
                    type: string
                    description: Текст ошибки (omitempty)
                  line:
                    type: number
                    description: Номер строки синтаксической ошибки, если она есть
                      (omitempty)
                  action:
                    type: string
                    description: Решённое скриптом действие, например redirect (omitempty)
                  url:
                    type: string
                    description: URL, выбранный скриптом (omitempty)
                  type:
                    type: string
                    description: Тип редиректа/действия (omitempty)
                  status:
                    type: number
                    description: HTTP-статус, выставленный скриптом (omitempty)
                  subs:
                    type: object
                    description: Итоговые sub-параметры после работы скрипта (omitempty)
                  cost:
                    type: number
                    description: Стоимость клика, выставленная скриптом, nullable
                      (omitempty)
                  log:
                    type: string
                    description: Консольный вывод скрипта (omitempty)
                  duration_ms:
                    type: number
                    description: Время выполнения в миллисекундах
              example:
                ok: true
                action: redirect
                url: https://example.com/de
                type: '302'
                subs:
                  sub_id_1: zone42
                  sub_id_2: test2
                log: ''
                duration_ms: 4.2
        '400':
          description: битый JSON тела запроса или пустой script
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '503':
          description: PHP-FPM движок скриптов не настроен на сервере (h.Scripts.Enabled()
            == false)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Не обращается к БД и не привязан к конкретной кампании/флоу — использует
        полностью синтетический клик (см. syntheticClick()), поэтому не требует явной
        проверки workspace. Сначала выполняется отдельный линт-проход (h.Scripts.Lint)
        — синтаксическая ошибка возвращается как 200 с ok:false, а не 400, чтобы не
        путать с ошибкой самого HTTP-запроса.
  "/campaigns/{id}/simulate":
    post:
      operationId: post_campaigns_id_simulate
      summary: Прогоняет синтетический клик с заданными параметрами через активные
        флоу кампании и показывает, какой флоу победит и как отработали фильтры каждого
        флоу.
      tags:
      - Кампании и флоу
      x-permission: view:campaigns
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID кампании, чьи флоу симулируются; при невалидном UUID — 400
          "invalid id"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ip:
                  type: string
                  description: "(string) IP-адрес для симуляции"
                user_agent:
                  type: string
                  description: "(string) User-Agent для симуляции"
                country:
                  type: string
                  description: "(string) Код страны"
                region:
                  type: string
                  description: "(string) Регион"
                city:
                  type: string
                  description: "(string) Город"
                language:
                  type: string
                  description: "(string) Язык браузера"
                device:
                  type: string
                  description: "(string) Тип устройства"
                os:
                  type: string
                  description: "(string) ОС"
                browser:
                  type: string
                  description: "(string) Браузер"
                isp:
                  type: string
                  description: "(string) Интернет-провайдер"
                referrer:
                  type: string
                  description: "(string) Реферер (полный URL)"
                referrer_host:
                  type: string
                  description: "(string) Хост реферера"
                is_bot:
                  type: boolean
                  description: "(boolean) Флаг бота"
                is_unique:
                  type: boolean
                  description: "(boolean) Флаг уникальности клика"
                is_proxy:
                  type: boolean
                  description: "(boolean) Флаг прокси"
                is_tor:
                  type: boolean
                  description: "(boolean) Флаг Tor"
                is_ipv6:
                  type: boolean
                  description: "(boolean) Флаг IPv6"
                is_datacenter:
                  type: boolean
                  description: "(boolean) Флаг дата-центра"
                is_vpn:
                  type: boolean
                  description: "(boolean) Флаг VPN"
                keyword:
                  type: string
                  description: "(string) Поисковый ключевик"
                search_engine:
                  type: string
                  description: "(string) Поисковая система"
                sub_ids:
                  type: object
                  description: "(object) Карта sub-параметров (sub_id_1..N) для фильтров"
                timezone:
                  type: string
                  description: "(string) Часовой пояс"
            example:
              ip: 203.0.113.42
              user_agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)
              country: DE
              region: BE
              city: Berlin
              language: de
              device: mobile
              os: iOS
              browser: Safari
              isp: Deutsche Telekom
              referrer: ''
              referrer_host: ''
              is_bot: false
              is_unique: true
              is_proxy: false
              is_tor: false
              is_ipv6: false
              is_datacenter: false
              is_vpn: false
              keyword: ''
              search_engine: ''
              sub_ids:
                sub_id_1: zone42
              timezone: Europe/Berlin
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  matched_flow_id:
                    type: string
                    description: UUID выигравшего флоу, null если ни один флоу не
                      подошёл и нет default-флоу
                  matched_flow_name:
                    type: string
                    description: Имя выигравшего флоу
                  redirect_type:
                    type: string
                    description: Тип редиректа выигравшего флоу
                  flows:
                    type: array
                    description: 'Результат по каждому активному флоу: flow_id, flow_name,
                      flow_type, matched(bool), filters_passed(bool), filters(массив
                      {type,operator,values,param,passed})'
              example:
                matched_flow_id: 9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a
                matched_flow_name: DE Mobile
                redirect_type: '302'
                flows:
                - flow_id: 9f8e7d6c-5b4a-4392-8170-6f5e4d3c2b1a
                  flow_name: DE Mobile
                  flow_type: regular
                  matched: true
                  filters_passed: true
                  filters:
                  - type: country
                    operator: is
                    values:
                    - DE
                    passed: true
                - flow_id: 2c3d4e5f-6071-4839-a0b1-c2d3e4f50617
                  flow_name: Default
                  flow_type: default
                  matched: false
                  filters_passed: true
                  filters: []
        '400':
          description: невалидный id пути или битый JSON тела запроса
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: кампания не существует либо принадлежит другому workspace ("not
            found")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка чтения флоу из БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Ничего не пишет и не логирует — чистый прогон фильтров. Симулируются
        только флоу в состоянии active; побеждает первый совпавший regular-флоу, иначе
        первый default. Принадлежность кампании воркспейсу проверяется до чтения флоу
        — иначе структуру чужих флоу можно было бы прочитать по UUID кампании.
  "/offers/cap-state":
    get:
      operationId: get_offers_cap_state
      summary: Возвращает текущее использование капов (часовой/дневной/общий) по всем
        неархивированным офферам рабочего пространства одним запросом.
      tags:
      - Офферы
      x-permission: view:offers
      responses:
        '200':
          description: ok, включая пустой массив если офферов нет
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    offer_id:
                      type: string
                      description: UUID оффера
                    cap_hour:
                      type: number
                      description: Настроенный часовой лимит (0 — не задан)
                    cap_day:
                      type: number
                      description: Настроенный дневной лимит (0 — не задан)
                    cap_total:
                      type: number
                      description: Настроенный общий лимит (0 — не задан)
                    used_hour:
                      type: number
                      description: Фактически использовано за текущий час
                    used_day:
                      type: number
                      description: Фактически использовано за текущие сутки (UTC)
                    used_total:
                      type: number
                      description: Фактически использовано всего
                    hour_reset_at:
                      type: string
                      description: Момент сброса часового окна (UTC)
                    day_reset_at:
                      type: string
                      description: Момент сброса дневного окна (UTC)
                    overflow_offer_id:
                      type: string
                      description: UUID оффера-оверфлоу, если настроен (omitempty)
                    status:
                      type: string
                      description: ok | warming (≥80% любого капа) | capped (100%
                        любого капа)
              example:
              - offer_id: 3d2c1b0a-9f8e-47d6-b5c4-a3b2c1d0e9f8
                cap_hour: 100
                cap_day: 1000
                cap_total: 0
                used_hour: 82
                used_day: 430
                used_total: 430
                hour_reset_at: '2026-07-28T11:00:00Z'
                day_reset_at: '2026-07-29T00:00:00Z'
                status: warming
        '500':
          description: 'ошибка выполнения SQL-запроса или сканирования строки, либо
            недоступность Redis-счётчиков при включённом hot-cache ("offer cap counters
            unavailable: ...")'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Архивные офферы (archived_at IS NOT NULL) исключаются из выборки.
        Если настроен Redis (h.Hot != nil), used_hour/used_day/used_total переписывается
        живыми счётчиками из Redis вместо PG-таблицы offer_counters — именно Redis
        является источником истины на проде с включённым hot-cache.
  "/offers/{id}/cap-state":
    get:
      operationId: get_offers_id_cap_state
      summary: Возвращает текущее использование капов для одного конкретного оффера
        — та же структура данных, что и в общей ручке cap-state.
      tags:
      - Офферы
      x-permission: view:offers
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID оффера; при невалидном UUID — 400 "invalid id"
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  offer_id:
                    type: string
                    description: UUID оффера
                  cap_hour:
                    type: number
                    description: Настроенный часовой лимит
                  cap_day:
                    type: number
                    description: Настроенный дневной лимит
                  cap_total:
                    type: number
                    description: Настроенный общий лимит
                  used_hour:
                    type: number
                    description: Фактически использовано за текущий час
                  used_day:
                    type: number
                    description: Фактически использовано за текущие сутки (UTC)
                  used_total:
                    type: number
                    description: Фактически использовано всего
                  hour_reset_at:
                    type: string
                    description: Момент сброса часового окна
                  day_reset_at:
                    type: string
                    description: Момент сброса дневного окна
                  overflow_offer_id:
                    type: string
                    description: UUID оффера-оверфлоу, если настроен (omitempty)
                  status:
                    type: string
                    description: ok | warming | capped
              example:
                offer_id: 3d2c1b0a-9f8e-47d6-b5c4-a3b2c1d0e9f8
                cap_hour: 100
                cap_day: 1000
                cap_total: 0
                used_hour: 82
                used_day: 430
                used_total: 430
                hour_reset_at: '2026-07-28T11:00:00Z'
                day_reset_at: '2026-07-29T00:00:00Z'
                status: warming
        '400':
          description: невалидный id
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: оффер не найден в workspace ИЛИ любая другая ошибка выполнения
            запроса — обработчик возвращает 404 с текстом err.Error() для любой ошибки
            QueryRow/Scan, не только для отсутствия строки
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: 'Redis-счётчики недоступны при включённом hot-cache — "offer
            cap counters unavailable: ..." (ошибка Redis намеренно пробрасывается,
            а не глотается)'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'WHERE o.id=$1 AND o.workspace_id=$2 — корректный guard от IDOR.
        Обратите внимание: любая ошибка БД (не только pgx.ErrNoRows) здесь маппится
        на 404, а не на 500, как это принято в остальных ручках — потенциально маскирует
        реальные инфраструктурные сбои под "не найдено". Redis-счётчики применяются
        так же, как в State.'
  "/ip-lists":
    get:
      operationId: get_ip_lists
      summary: Возвращает список всех IP-листов рабочего пространства (для использования
        в фильтрах флоу), отсортированный по имени.
      tags:
      - Списки IP/UA
      x-permission: view:ip_lists
      responses:
        '200':
          description: ok, включая пустой массив
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: UUID листа
                    name:
                      type: string
                      description: Название листа
                    description:
                      type: string
                      description: Описание
                    values:
                      type: array
                      description: Массив значений (IP/CIDR)
                    created_at:
                      type: string
                      description: Дата создания
                    updated_at:
                      type: string
                      description: Дата последнего обновления
              example:
              - id: 4e5f6071-8394-4a5b-9c0d-1e2f30415263
                name: Datacenter blacklist
                description: Known DC ranges
                values:
                - 203.0.113.0/24
                - 198.51.100.5
                created_at: '2026-06-10T08:00:00Z'
                updated_at: '2026-06-10T08:00:00Z'
        '500':
          description: ошибка выполнения SQL-запроса или сканирования строки
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
    post:
      operationId: post_ip_lists
      summary: Создаёт новый именованный IP-лист в рабочем пространстве.
      tags:
      - Списки IP/UA
      x-permission: edit:ip_lists
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: (string) Название листа (обрезается пробелы); пустое
                    — 400 "name is required"
                description:
                  type: string
                  description: "(string) Описание листа"
                values:
                  type: array
                  description: "(array) Значения IP/CIDR; очищаются через cleanValues
                    — обрезка пробелов, отбрасывание пустых и дублей с сохранением
                    порядка"
              required:
              - name
            example:
              name: Datacenter blacklist
              description: Known DC ranges
              values:
              - 203.0.113.0/24
              - 198.51.100.5
      responses:
        '201':
          description: лист создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: UUID созданного листа
                  name:
                    type: string
                    description: Название
                  description:
                    type: string
                    description: Описание
                  values:
                    type: array
                    description: Очищенные значения
                  created_at:
                    type: string
                    description: Дата создания
                  updated_at:
                    type: string
                    description: Дата обновления (равна created_at при создании)
              example:
                id: 4e5f6071-8394-4a5b-9c0d-1e2f30415263
                name: Datacenter blacklist
                description: Known DC ranges
                values:
                - 203.0.113.0/24
                - 198.51.100.5
                created_at: '2026-07-28T10:00:00Z'
                updated_at: '2026-07-28T10:00:00Z'
        '400':
          description: битый JSON, пустое name, или ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Успешное создание запускает фоновый h.reload() — листы используются
        в кэше фильтров флоу.
  "/ip-lists/{id}":
    put:
      operationId: put_ip_lists_id
      summary: Обновляет название, описание и значения существующего IP-листа.
      tags:
      - Списки IP/UA
      x-permission: edit:ip_lists
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID листа; при невалидном UUID — 400 "invalid id"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) Новое название (обрезается пробелы). В отличие
                    от Create, здесь НЕТ проверки на пустоту — пустое имя допускается
                    и будет сохранено"
                description:
                  type: string
                  description: "(string) Новое описание"
                values:
                  type: array
                  description: "(array) Новые значения, проходят через ту же очистку
                    cleanValues"
            example:
              name: Datacenter blacklist v2
              description: Updated DC ranges
              values:
              - 203.0.113.0/24
      responses:
        '200':
          description: обновлено успешно
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok" при успехе'
              example:
                status: ok
        '400':
          description: невалидный id, битый JSON, или ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лист не найден в workspace (RowsAffected == 0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: UPDATE ограничен WHERE id=$4 AND workspace_id=$5 — корректный guard
        от IDOR. Успех запускает фоновый h.reload().
    delete:
      operationId: delete_ip_lists_id
      summary: Удаляет IP-лист рабочего пространства.
      tags:
      - Списки IP/UA
      x-permission: delete:ip_lists
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID листа; при невалидном UUID — 400 "invalid id"
      responses:
        '200':
          description: ok — в т.ч. если ничего не было удалено (RowsAffected не проверяется)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok" — возвращается независимо от того, была ли
                      фактически удалена строка'
              example:
                status: ok
        '400':
          description: невалидный id или ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: RowsAffected не проверяется — удаление несуществующего/чужого id
        молча возвращает 200 "ok". DELETE всё же ограничен WHERE workspace_id=$2,
        так что чужие данные не удаляются. Успех запускает фоновый h.reload(); если
        лист используется в фильтрах флоу, ссылки на удалённый лист не проверяются
        и не чистятся этой ручкой.
  "/ua-lists":
    get:
      operationId: get_ua_lists
      summary: Возвращает список всех User-Agent-листов рабочего пространства, отсортированный
        по имени.
      tags:
      - Списки IP/UA
      x-permission: view:ua_lists
      responses:
        '200':
          description: ok, включая пустой массив
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: UUID листа
                    name:
                      type: string
                      description: Название листа
                    description:
                      type: string
                      description: Описание
                    values:
                      type: array
                      description: Массив значений (подстроки/шаблоны User-Agent)
                    created_at:
                      type: string
                      description: Дата создания
                    updated_at:
                      type: string
                      description: Дата последнего обновления
              example:
              - id: 5f607182-9405-4b6c-8d1e-2f3041526374
                name: Bot UAs
                description: Known bot signatures
                values:
                - bot
                - crawler
                - spider
                created_at: '2026-06-10T08:00:00Z'
                updated_at: '2026-06-10T08:00:00Z'
        '500':
          description: ошибка выполнения SQL-запроса или сканирования строки
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Использует общую реализацию listLists() с таблицей ua_lists — полностью
        идентична по поведению GET /ip-lists.
    post:
      operationId: post_ua_lists
      summary: Создаёт новый именованный User-Agent-лист в рабочем пространстве.
      tags:
      - Списки IP/UA
      x-permission: edit:ua_lists
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: (string) Название листа (обрезается пробелы); пустое
                    — 400 "name is required"
                description:
                  type: string
                  description: "(string) Описание листа"
                values:
                  type: array
                  description: "(array) Значения UA-подстрок; очищаются через cleanValues"
              required:
              - name
            example:
              name: Bot UAs
              description: Known bot signatures
              values:
              - bot
              - crawler
              - spider
      responses:
        '201':
          description: лист создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: UUID созданного листа
                  name:
                    type: string
                    description: Название
                  description:
                    type: string
                    description: Описание
                  values:
                    type: array
                    description: Очищенные значения
                  created_at:
                    type: string
                    description: Дата создания
                  updated_at:
                    type: string
                    description: Дата обновления
              example:
                id: 5f607182-9405-4b6c-8d1e-2f3041526374
                name: Bot UAs
                description: Known bot signatures
                values:
                - bot
                - crawler
                - spider
                created_at: '2026-07-28T10:00:00Z'
                updated_at: '2026-07-28T10:00:00Z'
        '400':
          description: битый JSON, пустое name, или ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Использует общую реализацию createList() с таблицей ua_lists —
        идентична по поведению POST /ip-lists, включая фоновый h.reload().
  "/ua-lists/{id}":
    put:
      operationId: put_ua_lists_id
      summary: Обновляет название, описание и значения существующего User-Agent-листа.
      tags:
      - Списки IP/UA
      x-permission: edit:ua_lists
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID листа; при невалидном UUID — 400 "invalid id"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) Новое название (обрезается пробелы); проверка
                    на пустоту отсутствует, как и в UpdateIPList"
                description:
                  type: string
                  description: "(string) Новое описание"
                values:
                  type: array
                  description: "(array) Новые значения, проходят через cleanValues"
            example:
              name: Bot UAs v2
              description: Updated bot signatures
              values:
              - bot
              - crawler
      responses:
        '200':
          description: обновлено успешно
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok" при успехе'
              example:
                status: ok
        '400':
          description: невалидный id, битый JSON, или ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лист не найден в workspace (RowsAffected == 0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Использует общую реализацию updateList() с таблицей ua_lists —
        идентична по поведению PUT /ip-lists/{id}, включая фоновый h.reload().
    delete:
      operationId: delete_ua_lists_id
      summary: Удаляет User-Agent-лист рабочего пространства.
      tags:
      - Списки IP/UA
      x-permission: delete:ua_lists
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID листа; при невалидном UUID — 400 "invalid id"
      responses:
        '200':
          description: ok — в т.ч. если ничего не было удалено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok" — возвращается независимо от того, была ли
                      фактически удалена строка'
              example:
                status: ok
        '400':
          description: невалидный id или ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Использует общую реализацию deleteList() с таблицей ua_lists —
        идентична по поведению DELETE /ip-lists/{id}, включая отсутствие проверки
        RowsAffected и фоновый h.reload().
  "/landings/{id}/files":
    get:
      operationId: get_landings_id_files
      summary: Возвращает список всех файлов лендинга (метаданные без содержимого),
        отсортированный по пути по возрастанию.
      tags:
      - Лендинги
      x-permission: view:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID лендинга
      responses:
        '200':
          description: успешно — возвращён (возможно пустой) список файлов
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ID записи файла
                    landing_id:
                      type: string
                      description: ID лендинга-владельца
                    path:
                      type: string
                      description: Путь файла внутри лендинга
                    mime:
                      type: string
                      description: MIME-тип файла
                    size:
                      type: number
                      description: Размер файла в байтах
                    is_binary:
                      type: boolean
                      description: Признак бинарного файла
                    updated_at:
                      type: string
                      description: Время последнего изменения файла
              example:
              - id: 3f3a1c2e-1111-4a2b-9c3d-000000000001
                landing_id: 9b1c2d3e-2222-4a2b-9c3d-000000000002
                path: index.html
                mime: text/html; charset=utf-8
                size: 2048
                is_binary: false
                updated_at: '2026-07-20T10:00:00Z'
              - id: 3f3a1c2e-1111-4a2b-9c3d-000000000003
                landing_id: 9b1c2d3e-2222-4a2b-9c3d-000000000002
                path: img/logo.png
                mime: image/png
                size: 15872
                is_binary: true
                updated_at: '2026-07-19T08:30:00Z'
        '400':
          description: невалидный UUID лендинга в {id}
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лендинг не найден или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса или чтения результата из БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Предварительная проверка landingInWorkspace защищает от IDOR по
        чужому landing_id, так как landing_files не хранит workspace_id напрямую.
  "/landings/{id}/file":
    get:
      operationId: get_landings_id_file
      summary: 'Возвращает содержимое одного файла лендинга по указанному пути: JSON
        с полем content для текстовых файлов или сырые байты с заголовком Content-Type
        для бинарных.'
      tags:
      - Лендинги
      x-permission: view:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID лендинга
      - name: path
        in: query
        required: true
        schema:
          type: string
        description: "(string) Путь к запрашиваемому файлу внутри лендинга"
      responses:
        '200':
          description: успешно — JSON с содержимым (текст) или сырые байты файла (бинарный)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID записи файла
                  landing_id:
                    type: string
                    description: ID лендинга-владельца
                  path:
                    type: string
                    description: Путь файла
                  mime:
                    type: string
                    description: MIME-тип файла
                  size:
                    type: number
                    description: Размер файла в байтах
                  is_binary:
                    type: boolean
                    description: Признак бинарного файла
                  updated_at:
                    type: string
                    description: Время последнего изменения
                  content:
                    type: string
                    description: Текстовое содержимое файла (только для нетекстовых
                      файлов поле отсутствует — тело ответа тогда сырые байты)
              example:
                id: 3f3a1c2e-1111-4a2b-9c3d-000000000001
                landing_id: 9b1c2d3e-2222-4a2b-9c3d-000000000002
                path: index.html
                mime: text/html; charset=utf-8
                size: 2048
                is_binary: false
                updated_at: '2026-07-20T10:00:00Z'
                content: "<html><body>Hello</body></html>"
        '400':
          description: невалидный UUID лендинга или отсутствует query-параметр path
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лендинг не найден в workspace, либо файл с таким path не найден,
            либо произошла ошибка БД (любая ошибка QueryRow трактуется как 404)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Если is_binary=true, ответ — не JSON, а сырые байты файла с Content-Type=mime;
        любая ошибка чтения строки из БД (включая реальные сбои БД, не только отсутствие
        строки) отдаётся клиенту как 404.
    put:
      operationId: put_landings_id_file
      summary: Создаёт новый текстовый файл лендинга или перезаписывает существующий
        по указанному пути (инлайн-редактор).
      tags:
      - Лендинги
      x-permission: edit:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID лендинга
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                path:
                  type: string
                  description: (string) Путь к файлу; нормализуется функцией cleanPath,
                    попытки выхода за пределы каталога лендинга ("..") отклоняются
                    с 400
                mime:
                  type: string
                  description: "(string) MIME-тип; если не передан или пуст, определяется
                    автоматически по расширению пути (detectMime)"
                content:
                  type: string
                  description: "(string) Текстовое содержимое файла; при отсутствии
                    поля используется пустая строка"
              required:
              - path
            example:
              path: index.html
              mime: text/html; charset=utf-8
              content: "<html><body>Hello</body></html>"
      responses:
        '200':
          description: файл создан или обновлён
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда "ok" при успехе
              example:
                status: ok
        '400':
          description: невалидный UUID лендинга, некорректный JSON тела запроса, path
            пуст после нормализации, либо ошибка записи в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лендинг не найден или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '413':
          description: тело запроса больше 4 MiB (общий maxBody на /admin_api/v1;
            chunked-тело без Content-Length обрезается ридером и даёт 400 на разборе
            JSON)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Файл всегда сохраняется как текстовый (is_binary=false) — через
        этот эндпоинт нельзя загрузить бинарное содержимое; содержимое best-effort
        дублируется на диск для PHP-FPM; если путь — корневой index.html/htm/php/shtml
        или default.html и текущий local_index лендинга ещё пуст/дефолтный/указывает
        на несуществующий файл, local_index обновляется автоматически; пишется запись
        в audit-лог (action="update", entity="landing_file"). Лимит тела — общий 4
        MiB maxBody админ-API; лимит выбран как раз под этот эндпоинт: исходники лендинга
        передаются инлайн в JSON-строке content.'
    delete:
      operationId: delete_landings_id_file
      summary: Удаляет файл лендинга по указанному пути из БД и с диска.
      tags:
      - Лендинги
      x-permission: delete:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID лендинга
      - name: path
        in: query
        required: true
        schema:
          type: string
        description: (string) Путь к удаляемому файлу; нормализуется cleanPath (отклоняет
          попытки "..")
      responses:
        '200':
          description: запрос выполнен (даже если файл с таким path не существовал)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда "ok" при успехе
              example:
                status: ok
        '400':
          description: невалидный UUID лендинга, path пуст после нормализации, либо
            ошибка DELETE в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лендинг не найден или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Handler не проверяет RowsAffected — DELETE по несуществующему path
        всё равно вернёт 200 "ok"; удаляет best-effort зеркало файла с диска (deleteDiskMirror);
        пишется audit-лог (action="delete", entity="landing_file").
  "/landings/{id}/upload-zip":
    post:
      operationId: post_landings_id_upload_zip
      summary: Загружает ZIP-архив (multipart/form-data) и распаковывает его файлы
        в файловый набор лендинга, автоматически подбирая local_index по эвристике.
      tags:
      - Лендинги
      x-permission: edit:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID лендинга
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                file:
                  type: string
                  description: "(file) Поле multipart-формы с ZIP-архивом файлов лендинга"
              required:
              - file
            example: 'multipart/form-data; поле "file": landing.zip (бинарные данные
              ZIP-архива)'
      responses:
        '200':
          description: архив обработан, файлы записаны (files может быть 0, если все
            записи отфильтрованы)
          content:
            application/json:
              schema:
                type: object
                properties:
                  files:
                    type: number
                    description: Количество файлов, успешно записанных из архива
                  index:
                    type: string
                    description: Путь файла, выбранного как local_index (пустая строка,
                      если ни один подходящий файл не найден)
              example:
                files: 12
                index: index.html
        '400':
          description: невалидный UUID лендинга, ошибка разбора multipart-формы, отсутствует
            поле file, ошибка чтения тела, тело не является валидным ZIP, либо превышены
            суммарные лимиты распаковки (10 000 файлов / 256MB)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лендинг не найден или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка транзакции БД (Begin/Commit)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Лимиты: всё тело запроса до 128MB (http.MaxBytesReader перед разбором
        формы; общий 4 MiB-maxBody админ-API этот маршрут пропускает), in-memory порог
        формы 64MB (ParseMultipartForm), каждая запись архива до 16MB (превышающие
        пропускаются молча); дополнительно суммарные лимиты распаковки — не более
        10 000 записанных файлов и 256MB распакованного содержимого, при превышении
        весь аплоад отклоняется с 400 и откатом транзакции (уже вставленные файлы
        не сохраняются); пути записей нормализуются cleanPath и дополнительно проверяются
        на "..", директории пропускаются;
        local_index выбирается по приоритету index.html/htm > index.php > любой корневой
        .html по эвристическому счёту; после commit транзакции ВЕСЬ набор файлов лендинга
        полностью зеркалируется на диск для PHP-FPM (mirrorLanding), не только загруженные
        файлы; пишется audit-лог (action="upload_zip") с именем архива, числом записанных
        файлов и выбранным index.'
  "/landings/{id}/file/versions":
    get:
      operationId: get_landings_id_file_versions
      summary: Возвращает до 10 последних версий указанного файла лендинга (метаданные
        без содержимого), от новых к старым.
      tags:
      - Лендинги
      x-permission: view:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID лендинга
      - name: path
        in: query
        required: true
        schema:
          type: string
        description: "(string) Путь файла, для которого запрашивается история версий"
      responses:
        '200':
          description: успешно — возвращён (возможно пустой) список версий
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ID записи версии
                    landing_id:
                      type: string
                      description: ID лендинга-владельца
                    path:
                      type: string
                      description: Путь файла
                    mime:
                      type: string
                      description: MIME-тип на момент версии
                    size:
                      type: number
                      description: Размер версии в байтах
                    is_binary:
                      type: boolean
                      description: Признак бинарной версии
                    created_at:
                      type: string
                      description: Время создания версии (снимка)
              example:
              - id: 5a5a1c2e-1111-4a2b-9c3d-000000000010
                landing_id: 9b1c2d3e-2222-4a2b-9c3d-000000000002
                path: index.html
                mime: text/html; charset=utf-8
                size: 2000
                is_binary: false
                created_at: '2026-07-20T09:00:00Z'
        '400':
          description: невалидный UUID лендинга или отсутствует query-параметр path
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: лендинг не найден или принадлежит другому workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса или чтения результата из БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Список жёстко ограничен 10 последними версиями (LIMIT 10 в SQL-запросе),
        пагинации нет.
  "/landings/{id}/file/versions/{vid}":
    get:
      operationId: get_landings_id_file_versions_vid
      summary: 'Возвращает полное содержимое конкретной исторической версии файла
        лендинга по её id: JSON с content для текстовых версий или сырые байты для
        бинарных.'
      tags:
      - Лендинги
      x-permission: view:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: 'UUID лендинга из URL — фактически handler''ом не используется:
          код не проверяет, что найденная версия принадлежит именно этому лендингу'
      - name: vid
        in: path
        required: true
        schema:
          type: string
        description: UUID записи версии файла (landing_file_versions.id)
      responses:
        '200':
          description: успешно — JSON с содержимым (текст) или сырые байты (бинарная
            версия)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID записи версии
                  landing_id:
                    type: string
                    description: ID лендинга, к которому фактически относится версия
                  path:
                    type: string
                    description: Путь файла
                  mime:
                    type: string
                    description: MIME-тип на момент версии
                  size:
                    type: number
                    description: Размер версии в байтах
                  is_binary:
                    type: boolean
                    description: Признак бинарной версии
                  created_at:
                    type: string
                    description: Время создания версии
                  content:
                    type: string
                    description: Текстовое содержимое версии (для бинарных версий
                      поле отсутствует — тело ответа тогда сырые байты)
              example:
                id: 5a5a1c2e-1111-4a2b-9c3d-000000000010
                landing_id: 9b1c2d3e-2222-4a2b-9c3d-000000000002
                path: index.html
                mime: text/html; charset=utf-8
                size: 2000
                is_binary: false
                created_at: '2026-07-20T09:00:00Z'
                content: "<html>старая версия</html>"
        '400':
          description: невалидный UUID версии (vid)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: версия с таким id не найдена в рамках workspace вызывающего
            (либо любая другая ошибка БД)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Принадлежность к workspace проверяется JOIN'ом landing_file_versions
        -> landings по workspace_id, используя ТОЛЬКО vid; параметр {id} из URL нигде
        не сверяется, поэтому landing_id в ответе может отличаться от {id} в пути,
        но межтенантный доступ всё равно исключён благодаря JOIN по workspace; для
        бинарных версий ответ — сырые байты с Content-Type=mime, а не JSON.
  "/landings/{id}/file/versions/{vid}/restore":
    post:
      operationId: post_landings_id_file_versions_vid_restore
      summary: Восстанавливает текущий файл лендинга из указанной исторической версии,
        перезаписывая его текущее содержимое.
      tags:
      - Лендинги
      x-permission: edit:landings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: 'UUID лендинга из URL — фактически handler''ом не используется:
          landing_id для восстановления берётся из самой найденной версии, а не из
          URL'
      - name: vid
        in: path
        required: true
        schema:
          type: string
        description: UUID записи версии файла (landing_file_versions.id), которую
          нужно восстановить
      responses:
        '200':
          description: файл успешно восстановлен из версии
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда "ok" при успехе
              example:
                status: ok
        '400':
          description: невалидный UUID версии (vid) либо ошибка UPSERT в landing_files
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: версия с таким id не найдена в рамках workspace вызывающего
            (либо любая другая ошибка БД)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Принадлежность к workspace проверяется JOIN'ом по workspace_id
        с использованием только vid (аналогично GetVersion, {id} из URL не используется);
        восстановление делает UPSERT в landing_files содержимым найденной версии,
        что по описанию в коде срабатывает через БД-триггер, снимающий снапшот текущего
        состояния файла в историю версий перед перезаписью; после успешного восстановления
        файл best-effort зеркалируется на диск; пишется audit-лог (action="restore",
        entity="landing_file") с path и version_id.
  "/reports/dashboard":
    get:
      operationId: get_reports_dashboard
      summary: Возвращает агрегированные KPI дашборда (клики, уникальные клики, конверсии,
        доход, расход, прибыль, CR, ROI, EPC) за выбранный период.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Начало периода; по умолчанию — сейчас минус
          24 часа. Невалидное значение молча игнорируется (остаётся дефолт)."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Конец периода; по умолчанию — текущее время.
          Невалидное значение молча игнорируется."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Учитывать ли строки, сгенерированные
          симулятором (is_simulated=1), наравне с реальным трафиком.
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  clicks:
                    type: number
                    description: Число кликов за период.
                  unique_clicks:
                    type: number
                    description: Число уникальных посетителей.
                  conversions:
                    type: number
                    description: Число конверсий (дедуп по conversions FINAL).
                  revenue:
                    type: number
                    description: Суммарный доход.
                  cost:
                    type: number
                    description: Суммарный расход.
                  profit:
                    type: number
                    description: revenue - cost.
                  cr:
                    type: number
                    description: Conversion rate, % (conversions/clicks*100).
                  roi:
                    type: number
                    description: ROI, % (profit/cost*100).
                  epc:
                    type: number
                    description: Доход на клик (revenue/clicks).
              example:
                clicks: 15234
                unique_clicks: 12890
                conversions: 412
                revenue: 8240.5
                cost: 3050
                profit: 5190.5
                cr: 2.7
                roi: 170.2
                epc: 0.54
        '500':
          description: ошибка выполнения запроса к ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Клики/расход читаются из часовой агрегатной таблицы clicks_hourly
        (countMerge/uniqMerge) для скорости, конверсии/доход — из conversions FINAL;
        workspace_id берётся из JWT, отдельного параметра нет.
  "/reports/timeseries":
    get:
      operationId: get_reports_timeseries
      summary: Возвращает временной ряд кликов, дохода и расхода для построения графика
        на дашборде.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Начало периода; по умолчанию — сейчас минус
          24 часа."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Конец периода; по умолчанию — текущее время."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Учитывать симулированный трафик.
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    t:
                      type: string
                      description: Метка времени бакета.
                    clicks:
                      type: number
                      description: Клики в бакете.
                    revenue:
                      type: number
                      description: Доход в бакете.
                    cost:
                      type: number
                      description: Расход в бакете.
              example:
              - t: '2026-07-27T14:00:00Z'
                clicks: 320
                revenue: 180.25
                cost: 64
        '500':
          description: ошибка выполнения запроса к ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Бакетирование по часам; при диапазоне больше 72 часов автоматически
        переключается на бакетирование по суткам, чтобы не отдавать сотни точек в
        SVG-график.
  "/reports/cohort":
    get:
      operationId: get_reports_cohort
      summary: Возвращает матрицу 7×24 (день недели × час) с числом кликов для тепловой
        карты дашборда.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Начало периода."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Конец периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Учитывать симулированный трафик.
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    day_of_week:
                      type: number
                      description: 1=понедельник … 7=воскресенье (ISO).
                    hour:
                      type: number
                      description: Час суток 0..23, серверная таймзона.
                    clicks:
                      type: number
                      description: Число кликов в ячейке.
              example:
              - day_of_week: 1
                hour: 14
                clicks: 58
        '500':
          description: ошибка выполнения запроса к ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Это не то же самое, что POST /reports/cohort — здесь простая тепловая
        карта день×час без анализа лага конверсий; ячейки с нулевым числом кликов
        в ответе отсутствуют, UI подставляет 0 сам.
    post:
      operationId: post_reports_cohort
      summary: Строит матрицу когорт привлечения по клику × лаг до конверсии (в днях
        или часах) с фильтрами отчёта.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Резервный источник периода через общий parseRange."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Резервный источник периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Резервный флаг учёта симулированного
          трафика.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                from:
                  type: string
                  description: "(string (RFC3339)) Начало периода; перекрывает query-параметр."
                to:
                  type: string
                  description: "(string (RFC3339)) Конец периода; перекрывает query-параметр."
                filters:
                  type: array
                  description: "(array of object {field,op,values}) Фильтры по измерениям."
                granularity:
                  type: string
                  description: (string ("day" | "hour")) Гранулярность бакета когорты;
                    по умолчанию day.
                lag_buckets:
                  type: number
                  description: (number) Число колонок лага; по умолчанию 8, максимум
                    31, хвост сворачивается в последний бакет "≥N".
                include_simulated:
                  type: boolean
                  description: "(boolean) Учитывать симулированный трафик."
            example:
              from: '2026-07-01T00:00:00Z'
              to: '2026-07-28T00:00:00Z'
              granularity: day
              lag_buckets: 10
              filters: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  granularity:
                    type: string
                    description: Фактически применённая гранулярность (day|hour).
                  lag_buckets:
                    type: number
                    description: Число колонок лага.
                  rows:
                    type: array
                    description: 'Одна строка на когорту: cohort, clicks, unique,
                      conversions, revenue, cells[].'
                  truncated:
                    type: boolean
                    description: Не используется в текущей реализации Go-кода как
                      флаг успеха (при переполнении >2000 строк возвращается ошибка,
                      а не truncated=true).
              example:
                granularity: day
                lag_buckets: 10
                rows:
                - cohort: '2026-07-20'
                  clicks: 1200
                  unique: 1050
                  conversions: 80
                  revenue: 640
                  cells:
                  - lag: 0
                    conversions: 30
                    revenue: 240
                  - lag: 1
                    conversions: 20
                    revenue: 160
                truncated: false
        '400':
          description: ошибка декодирования JSON, невалидный фильтр или более 2000
            строк когорт (запрос отклоняется целиком)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Окно [from,to] всегда фильтрует клики по click_timestamp (дата
        привлечения определяет когорту); конверсии относятся к когорте своего клика
        независимо от даты постбэка, что и делает лаг осмысленным.
  "/reports/funnel":
    get:
      operationId: get_reports_funnel
      summary: Возвращает агрегированные счётчики базовой воронки клики → уникальные
        → конверсии → продажи для графика на дашборде.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Начало периода."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Конец периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Учитывать симулированный трафик.
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  clicks:
                    type: number
                    description: Всего кликов.
                  unique:
                    type: number
                    description: Уникальные посетители.
                  conversions:
                    type: number
                    description: Всего конверсий.
                  sales:
                    type: number
                    description: Конверсии в бакете статуса sale.
              example:
                clicks: 15234
                unique: 12890
                conversions: 412
                sales: 205
        '500':
          description: ошибка выполнения запроса к ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: '"sales" считается по бакету статусов sale, который включает встроенный
        статус sale и любые кастомные conversion_statuses с counts_as=sale (StatusBuckets).'
    post:
      operationId: post_reports_funnel
      summary: Выполняет настраиваемый многоступенчатый анализ воронки с необязательной
        сегментацией по измерению и фильтрами отчёта.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Резервный источник периода, если не задан
          в теле; читается общим parseRange."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Резервный источник периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Резервный флаг учёта симулированного
          трафика (объединяется через OR с полем тела).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                from:
                  type: string
                  description: "(string (RFC3339)) Начало периода; перекрывает query-параметр
                    from."
                to:
                  type: string
                  description: "(string (RFC3339)) Конец периода; перекрывает query-параметр
                    to."
                filters:
                  type: array
                  description: "(array of object {field,op,values}) Фильтры по измерениям
                    (те же поля/операторы, что и в /reports/query)."
                date_mode:
                  type: string
                  description: (string ("click" | "conversion")) По какой временной
                    метке привязывать конверсии; по умолчанию click.
                breakdown:
                  type: string
                  description: "(string) Имя измерения для сегментации воронки на
                    несколько под-воронок; пусто = одна общая воронка."
                stages:
                  type: array
                  description: "(array of object {key,label,type,status}) Список ступеней
                    воронки (type: clicks|unique|status). По умолчанию клики→уникальные→lead→sale."
                include_simulated:
                  type: boolean
                  description: "(boolean) Учитывать симулированный трафик."
            example:
              from: '2026-07-21T00:00:00Z'
              to: '2026-07-28T00:00:00Z'
              breakdown: campaign
              date_mode: click
              stages:
              - key: clicks
                label: Клики
                type: clicks
              - key: lead
                label: Лиды
                type: status
                status: lead
              - key: sale
                label: Продажи
                type: status
                status: sale
              filters:
              - field: country
                op: eq
                values:
                - US
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  breakdown:
                    type: string
                    description: Эхо запрошенного измерения сегментации (omitempty).
                  segments:
                    type: array
                    description: 'Один элемент на сегмент: key, label, stages[].'
                  truncated:
                    type: boolean
                    description: true, если число сегментов было обрезано по MaxSegments.
              example:
                breakdown: campaign
                segments:
                - key: 11111111-1111-1111-1111-111111111111
                  label: 11111111-1111-1111-1111-111111111111
                  stages:
                  - key: clicks
                    label: Клики
                    count: 5000
                    pct_of_top: 100
                    pct_of_prev: 100
                    drop_prev: 0
                  - key: lead
                    label: Лиды
                    count: 320
                    pct_of_top: 6.4
                    pct_of_prev: 6.4
                    drop_prev: 4680
                  - key: sale
                    label: Продажи
                    count: 150
                    pct_of_top: 3
                    pct_of_prev: 46.9
                    drop_prev: 170
                truncated: false
        '400':
          description: ошибка декодирования JSON (в т.ч. неизвестное поле), некорректное
            измерение breakdown или невалидный фильтр
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Сегменты сортируются по значению первой ступени и обрезаются до
        MaxSegments (по умолчанию 50); ключи сегментов — сырые значения измерения
        (например UUID кампании), человекочитаемые имена резолвит фронтенд.
  "/reports/group-by":
    get:
      operationId: get_reports_group_by
      summary: Возвращает агрегированный отчёт по кликам и конверсиям, сгруппированный
        по заданным измерениям (упрощённый движок без пагинации и кастомных метрик).
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: dims
        in: query
        required: false
        schema:
          type: string
        description: "(string (список через запятую)) Измерения для группировки; пустое
          значение → группировка по date; максимум MaxDimensions=16."
      - name: campaign_id
        in: query
        required: false
        schema:
          type: string
        description: "(string) Фильтр по campaign_id (сырое сравнение, формат не валидируется)."
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Начало периода."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Конец периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Учитывать симулированный трафик.
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    key:
                      type: object
                      description: Значения измерений группы.
                    clicks:
                      type: number
                      description: Клики.
                    unique_clicks:
                      type: number
                      description: Уникальные посетители.
                    conversions:
                      type: number
                      description: Конверсии.
                    leads:
                      type: number
                      description: Конверсии в бакете lead.
                    sales:
                      type: number
                      description: Конверсии в бакете sale.
                    rejected:
                      type: number
                      description: Конверсии в бакете rejected.
                    revenue:
                      type: number
                      description: Доход.
                    cost:
                      type: number
                      description: Расход.
                    profit:
                      type: number
                      description: Прибыль.
                    cr:
                      type: number
                      description: Conversion rate, %.
                    epc:
                      type: number
                      description: Доход на клик.
                    roi:
                      type: number
                      description: ROI, %.
                    roas:
                      type: number
                      description: revenue/cost.
                    cpa:
                      type: number
                      description: cost/conversions.
                    cpl:
                      type: number
                      description: cost/leads.
                    approval_rate:
                      type: number
                      description: "(conversions-rejected)/conversions*100."
              example:
              - key:
                  campaign: c1
                clicks: 500
                unique_clicks: 420
                conversions: 30
                leads: 20
                sales: 10
                rejected: 2
                revenue: 300
                cost: 100
                profit: 200
                cr: 6
                epc: 0.6
                roi: 200
                roas: 3
                cpa: 3.33
                cpl: 5
                approval_rate: 93.33
        '400':
          description: некорректное имя измерения в dims, превышен MaxDimensions=16
            измерений либо MaxGroupedRows=500000 групп
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: В отличие от /reports/query здесь нет лимита строк, пагинации,
        сортировки и кастомных метрик; ошибка агрегации конверсий не глотается — запрос
        завершается ошибкой целиком (conversions query failed), частичный результат
        (клики без конверсий) невозможен.
  "/reports/dimensions":
    get:
      operationId: get_reports_dimensions
      summary: Возвращает список всех допустимых имён измерений, которые можно использовать
        в group-by/query/funnel/cohort фильтрах и breakdown.
      tags:
      - Отчёты
      x-permission: view:reports
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    "(элемент массива)":
                      type: string
                      description: Имя измерения (campaign, country, device, sub_id_1..sub_id_30
                        и т.д.).
              example:
              - ad_campaign_id
              - adset_id
              - browser
              - campaign
              - city
              - country
              - creative_id
              - date
              - day_of_week
              - device
              - flow
              - hour
              - isp
              - keyword
              - landing
              - language
              - offer
              - os
              - referrer
              - region
              - search_engine
              - source
              - source_site
              - sub_id_1
              - sub_id_2
      description: Список статический (whitelist в коде), не зависит от воркспейса;
        включает sub_id_1..sub_id_30.
  "/reports/clicks":
    get:
      operationId: get_reports_clicks
      summary: Возвращает постраничный список сырых событий кликов с фильтрами по
        кампании и sub id.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Начало периода."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Конец периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Учитывать симулированные клики (is_simulated=1)
          наравне с реальными; по умолчанию в списке только реальный трафик.
      - name: campaign_id
        in: query
        required: false
        schema:
          type: string
        description: "(string) Фильтр по campaign_id."
      - name: subid
        in: query
        required: false
        schema:
          type: string
        description: "(string) Полный click_id (36 символов) → точное совпадение;
          более короткая строка → поиск по префиксу click_id."
      - name: country
        in: query
        required: false
        schema:
          type: string
        description: "(string (ISO-2)) Фильтр по коду страны, без учёта регистра (сравнение
          через upper(country_code))."
      - name: bot
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true" | "0" | "false")) "1"/"true" — только боты,
          "0"/"false" — только не-боты; пустое или любое иное значение — без фильтра.
      - name: outcome
        in: query
        required: false
        schema:
          type: string
        description: (string ("routed" | "rejected")) "routed" — только клики, ушедшие
          в редирект (outcome = ''), "rejected" — только отказные (outcome != '', то
          есть no_match/do_nothing); пустое или любое иное значение — без фильтра.
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) Размер страницы; по умолчанию 100, максимум 1000, невалидное/≤0
          значение → дефолт."
      - name: offset
        in: query
        required: false
        schema:
          type: number
        description: "(number) Смещение страницы; по умолчанию 0."
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    description: click_id, visitor_id, campaign_id, flow_id, landing_id,
                      offer_id, ip, user_agent, referrer, country, region, city, language,
                      device, os, browser, isp, is_bot(boolean), is_unique(boolean),
                      destination, cost(number), currency, outcome, sub_ids(object),
                      created_at(time). outcome пустой у клика, ушедшего в редирект,
                      и содержит причину отказа (no_match, do_nothing) у остальных.
                  total:
                    type: number
                    description: Всего строк по фильтру (без limit/offset).
                  limit:
                    type: number
                    description: Применённый лимит.
                  offset:
                    type: number
                    description: Применённое смещение.
              example:
                rows:
                - click_id: a1a1a1a1-1111-1111-1111-111111111111
                  visitor_id: v1
                  campaign_id: c1
                  flow_id: f1
                  landing_id: l1
                  offer_id: o1
                  ip: 1.2.3.4
                  user_agent: Mozilla/5.0
                  referrer: https://example.com
                  country: US
                  region: CA
                  city: Los Angeles
                  language: en
                  device: desktop
                  os: Windows
                  browser: Chrome
                  isp: Comcast
                  is_bot: false
                  is_unique: true
                  destination: https://offer.example.com
                  cost: 0.05
                  currency: USD
                  outcome: ''
                  sub_ids:
                    sub_id_1: ''
                    sub_id_2: ''
                    sub_id_3: ''
                    sub_id_4: ''
                    sub_id_5: ''
                  created_at: '2026-07-28T10:00:00Z'
                total: 1
                limit: 100
                offset: 0
        '500':
          description: ошибка выполнения запроса к ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Как и остальные отчёты, ClickLog фильтрует по is_simulated (предикат
        is_simulated <= ?): по умолчанию отдаются только реальные клики, include_simulated=1|true
        добавляет симулированные.'
  "/reports/conversions":
    get:
      operationId: get_reports_conversions
      summary: Возвращает постраничный список конверсий с фильтрами по статусу, кампании
        и click_id.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Начало периода."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Конец периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Читается, но не влияет на результат (см.
          notes).
      - name: date_field
        in: query
        required: false
        schema:
          type: string
        description: (string ("click" | "conversion")) По какой временной метке фильтровать
          from/to; по умолчанию conversion.
      - name: status
        in: query
        required: false
        schema:
          type: string
        description: "(string) Фильтр по статусу конверсии (lead|sale|rejected|trash
          или кастомный код)."
      - name: campaign_id
        in: query
        required: false
        schema:
          type: string
        description: "(string) Фильтр по campaign_id."
      - name: click_id
        in: query
        required: false
        schema:
          type: string
        description: "(string) Точное совпадение click_id."
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) Размер страницы; по умолчанию 200, максимум 2000."
      - name: offset
        in: query
        required: false
        schema:
          type: number
        description: "(number) Смещение страницы; по умолчанию 0."
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    description: id, click_id, campaign_id, flow_id, offer_id, status,
                      payout(number), currency, transaction_id, click_timestamp(time),
                      created_at(time), delay_seconds(number).
                  total:
                    type: number
                    description: Всего строк по фильтру.
                  limit:
                    type: number
                    description: Применённый лимит.
                  offset:
                    type: number
                    description: Применённое смещение.
              example:
                rows:
                - id: conv-1
                  click_id: a1a1a1a1-1111-1111-1111-111111111111
                  campaign_id: c1
                  flow_id: f1
                  offer_id: o1
                  status: sale
                  payout: 25
                  currency: USD
                  transaction_id: tx-123
                  click_timestamp: '2026-07-28T09:55:00Z'
                  created_at: '2026-07-28T10:02:00Z'
                  delay_seconds: 420
                total: 1
                limit: 200
                offset: 0
        '500':
          description: ошибка выполнения запроса к ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: date_field переключает колонку, по которой фильтруется диапазон
        from/to (cash-flow вид vs attribution вид); include_simulated здесь также
        не применяется — предиката is_simulated в запросе нет.
  "/reports/postbacks":
    get:
      operationId: get_reports_postbacks
      summary: Возвращает постраничный лог входящих/исходящих постбэков кампаний воркспейса.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Начало периода."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Конец периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Читается, но не используется этим эндпоинтом.
      - name: direction
        in: query
        required: false
        schema:
          type: string
        description: "(string) Фильтр направления постбэка (значение не валидируется
          набором допустимых значений)."
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) Размер страницы; по умолчанию 100, максимум 1000."
      - name: offset
        in: query
        required: false
        schema:
          type: number
        description: "(number) Смещение страницы; по умолчанию 0."
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    description: id, direction, url, method, status_code(number),
                      request_body, response_body, click_id, campaign_id, attempt(number),
                      error, created_at(time).
                  total:
                    type: number
                    description: Всего строк по фильтру.
                  limit:
                    type: number
                    description: Применённый лимит.
                  offset:
                    type: number
                    description: Применённое смещение.
              example:
                rows:
                - id: pb-1
                  direction: out
                  url: https://network.example.com/postback?tx=123
                  method: GET
                  status_code: 200
                  request_body: ''
                  response_body: OK
                  click_id: a1a1a1a1-1111-1111-1111-111111111111
                  campaign_id: c1
                  attempt: 1
                  error: ''
                  created_at: '2026-07-28T10:02:05Z'
                total: 1
                limit: 100
                offset: 0
        '500':
          description: ошибка резолва campaign id из Postgres или запроса к ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Таблица postback_log в ClickHouse не хранит workspace_id — список
        ID кампаний воркспейса сначала резолвится через Postgres (SELECT id FROM campaigns
        WHERE workspace_id=$1) и передаётся как IN-фильтр; пустой список кампаний
        → пустой результат, а не «всё».
  "/reports/traffic-log":
    get:
      operationId: get_reports_traffic_log
      summary: Возвращает постраничный листинг отладочного трейса кликов (click_trace,
        TTL 72ч) без блока шагов.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Начало периода."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Конец периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Читается, но не используется этим эндпоинтом.
      - name: campaign_id
        in: query
        required: false
        schema:
          type: string
        description: "(string) Фильтр по campaign_id."
      - name: outcome
        in: query
        required: false
        schema:
          type: string
        description: (string ("redirect" | "do_nothing" | "no_match")) Фильтр по исходу;
          пусто = все.
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) Размер страницы; по умолчанию 100, максимум 1000."
      - name: offset
        in: query
        required: false
        schema:
          type: number
        description: "(number) Смещение страницы; по умолчанию 0."
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    description: click_id, timestamp, campaign_id, ip, user_agent,
                      country_code, device_type, is_bot(number 0/1), is_unique(number
                      0/1), outcome, outcome_code(number), select_reason, flow_id(omitempty),
                      landing_id(omitempty), offer_id(omitempty), destination, duration_us(number),
                      channel(omitempty). Поля steps/sub_ids/referer в листинге не
                      запрашиваются.
                  total:
                    type: number
                    description: Всего строк по фильтру.
                  limit:
                    type: number
                    description: Применённый лимит.
                  offset:
                    type: number
                    description: Применённое смещение.
              example:
                rows:
                - click_id: a1a1a1a1-1111-1111-1111-111111111111
                  timestamp: '2026-07-28T10:00:00Z'
                  campaign_id: c1
                  ip: 1.2.3.4
                  user_agent: Mozilla/5.0
                  country_code: US
                  device_type: desktop
                  is_bot: 0
                  is_unique: 1
                  outcome: redirect
                  outcome_code: 302
                  select_reason: flow_weight
                  flow_id: f1
                  landing_id: l1
                  offer_id: o1
                  destination: https://offer.example.com
                  duration_us: 1450
                  channel: mclient_js
                total: 1
                limit: 100
                offset: 0
        '500':
          description: ошибка выполнения запроса к ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'click_trace — отладочная таблица с TTL 72 часа: старые клики,
        доступные в /reports/clicks, здесь уже не найти; полный блок steps не запрашивается
        — для него нужен GET /clicks/{click_id}/trace.'
  "/clicks/{click_id}/trace":
    get:
      operationId: get_clicks_click_id_trace
      summary: Возвращает полную запись трейса одного клика, включая декодированный
        блок шагов пайплайна.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: click_id
        in: path
        required: true
        schema:
          type: string
        description: UUID клика; при невалидном UUID — 400.
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  click_id:
                    type: string
                    description: ID клика.
                  timestamp:
                    type: string
                    description: Время клика.
                  campaign_id:
                    type: string
                    description: Кампания.
                  ip:
                    type: string
                    description: IP посетителя.
                  user_agent:
                    type: string
                    description: User-Agent.
                  country_code:
                    type: string
                    description: Код страны.
                  device_type:
                    type: string
                    description: Тип устройства.
                  is_bot:
                    type: number
                    description: 0/1.
                  is_unique:
                    type: number
                    description: 0/1.
                  outcome:
                    type: string
                    description: Исход обработки клика.
                  outcome_code:
                    type: number
                    description: HTTP-код исхода.
                  select_reason:
                    type: string
                    description: Причина выбора лендинга/оффера.
                  flow_id:
                    type: string
                    description: Поток (omitempty).
                  landing_id:
                    type: string
                    description: Лендинг (omitempty).
                  offer_id:
                    type: string
                    description: Оффер (omitempty).
                  destination:
                    type: string
                    description: Итоговый URL редиректа.
                  duration_us:
                    type: number
                    description: Длительность обработки, микросекунды.
                  channel:
                    type: string
                    description: Канал приёма клика (mclient_php|mclient_js|click_api),
                      omitempty.
                  steps:
                    type: array
                    description: Сырые шаги пайплайна, omitempty.
                  sub_ids:
                    type: object
                    description: Sub id клика, omitempty.
                  referer:
                    type: string
                    description: Referer, omitempty.
              example:
                click_id: a1a1a1a1-1111-1111-1111-111111111111
                timestamp: '2026-07-28T10:00:00Z'
                campaign_id: c1
                ip: 1.2.3.4
                user_agent: Mozilla/5.0
                country_code: US
                device_type: desktop
                is_bot: 0
                is_unique: 1
                outcome: redirect
                outcome_code: 302
                select_reason: flow_weight
                flow_id: f1
                landing_id: l1
                offer_id: o1
                destination: https://offer.example.com
                duration_us: 1450
                channel: mclient_js
                steps:
                - step: geo_lookup
                  result: US
                sub_ids:
                  sub_id_1: fb123
                referer: https://t.me/channel
        '400':
          description: некорректный click_id (не UUID)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: трейс не найден ("trace not found")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Поиск скоупится по workspace_id из JWT (WHERE workspace_id=? AND
        click_id=?) — трейс из чужого воркспейса вернёт 404, даже если click_id существует.
  "/reports/query":
    post:
      operationId: post_reports_query
      summary: Выполняет универсальный настраиваемый отчёт по кликам и конверсиям
        с измерениями, фильтрами, сортировкой, кастомными метриками и сравнением периодов.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Резервный источник периода через общий parseRange,
          перекрывается полем тела from."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Резервный источник периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Резервный флаг учёта симулированного
          трафика (OR с полем тела).
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                from:
                  type: string
                  description: "(string (RFC3339)) Начало периода."
                to:
                  type: string
                  description: "(string (RFC3339)) Конец периода."
                dimensions:
                  type: array
                  description: "(array of string) Измерения группировки; пусто = одна
                    итоговая строка без GROUP BY."
                filters:
                  type: array
                  description: "(array of object {field,op,values}) Фильтры по измерениям
                    (eq/ne/in/nin/contains/not_contains/starts_with/ends_with/regexp)
                    и по метрикам (gt/gte/lt/lte/eq/ne/between)."
                sort:
                  type: array
                  description: "(array of object {field,dir}) Сортировка по метрике
                    или измерению; dir = asc|desc."
                limit:
                  type: number
                  description: "(number) Размер страницы; по умолчанию 100, максимум
                    MaxExportRows=100000."
                offset:
                  type: number
                  description: "(number) Смещение страницы; отрицательное значение
                    приводится к 0."
                custom_metrics:
                  type: array
                  description: "(array of object {name,expr}) Разовые кастомные метрики;
                    переопределяют одноимённые сохранённые метрики воркспейса только
                    для этого запроса."
                date_mode:
                  type: string
                  description: (string ("click" | "conversion")) По какой временной
                    метке привязывать конверсии; по умолчанию click.
                include_simulated:
                  type: boolean
                  description: "(boolean) Учитывать симулированный трафик."
                compare:
                  type: boolean
                  description: "(boolean) Сравнить с предыдущим периодом такой же
                    длины (если compare_from/compare_to не заданы явно)."
                compare_from:
                  type: string
                  description: "(string (RFC3339)) Явное начало периода сравнения;
                    перекрывает compare."
                compare_to:
                  type: string
                  description: "(string (RFC3339)) Явный конец периода сравнения."
                format:
                  type: string
                  description: "(string) Присутствует в общей схеме тела (используется
                    только /reports/export); этим эндпоинтом игнорируется."
            example:
              from: '2026-07-21T00:00:00Z'
              to: '2026-07-28T00:00:00Z'
              dimensions:
              - campaign
              - country
              filters:
              - field: country
                op: eq
                values:
                - US
              sort:
              - field: revenue
                dir: desc
              limit: 50
              offset: 0
              date_mode: click
              compare: true
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    description: 'GroupByRow: key, clicks, unique_clicks, conversions,
                      leads, sales, rejected, revenue, cost, profit, cr, epc, roi,
                      roas, cpa, cpl, approval_rate, custom(omitempty), prev(omitempty),
                      delta_pct(omitempty).'
                  totals:
                    type: object
                    description: '(object (GroupByRow)) Итоговая строка по всему отфильтрованному
                      результату (а не только текущей странице): аддитивные счётчики
                      просуммированы, производные метрики (cr/epc/roi/roas/cpa/cpl/approval_rate)
                      пересчитаны от сумм; unique_clicks — простая сумма по группам
                      и может двоить посетителей, встречающихся в нескольких группах;
                      кастомные метрики суммируются как есть.'
                  total:
                    type: number
                    description: Число строк после группировки и фильтров по метрикам,
                      до пагинации.
                  limit:
                    type: number
                    description: Применённый лимит.
                  offset:
                    type: number
                    description: Применённое смещение.
              example:
                rows:
                - key:
                    campaign: c1
                    country: US
                  clicks: 500
                  unique_clicks: 420
                  conversions: 30
                  leads: 20
                  sales: 10
                  rejected: 2
                  revenue: 300
                  cost: 100
                  profit: 200
                  cr: 6
                  epc: 0.6
                  roi: 200
                  roas: 3
                  cpa: 3.33
                  cpl: 5
                  approval_rate: 93.33
                  prev:
                    clicks: 400
                    revenue: 250
                  delta_pct:
                    clicks: 25
                    revenue: 20
                total: 1
                limit: 50
                offset: 0
        '400':
          description: ошибка декодирования JSON, некорректное измерение/фильтр, превышен
            MaxDimensions=16 измерений либо MaxGroupedRows=500000 групп
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Кастомные метрики воркспейса автоматически подмешиваются к каждому
        запросу через loadWorkspaceMetrics; метрика с тем же именем в теле запроса
        перекрывает сохранённую формулу только для этого вызова.
  "/reports/export":
    post:
      operationId: post_reports_export
      summary: Стримит тот же отчёт, что и /reports/query, в виде файла CSV или HTML
        для скачивания.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Резервный источник периода через общий parseRange."
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string (RFC3339)) Резервный источник периода."
      - name: include_simulated
        in: query
        required: false
        schema:
          type: string
        description: (string ("1" | "true")) Резервный флаг учёта симулированного
          трафика.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                from:
                  type: string
                  description: "(string (RFC3339)) Начало периода."
                to:
                  type: string
                  description: "(string (RFC3339)) Конец периода."
                dimensions:
                  type: array
                  description: "(array of string) Измерения группировки, становятся
                    первыми колонками файла."
                filters:
                  type: array
                  description: "(array of object {field,op,values}) Фильтры отчёта."
                sort:
                  type: array
                  description: (array of object {field,dir}) Сортировка строк перед
                    выгрузкой; поле обязано быть известной метрикой, кастомной метрикой
                    или одним из измерений запроса — неизвестное имя отклоняется с
                    400 "unknown sort metric" (в отличие от /reports/query, где неизвестное
                    поле сортировки молча игнорируется).
                custom_metrics:
                  type: array
                  description: "(array of object {name,expr}) Разовые кастомные метрики;
                    добавляются как последние колонки."
                date_mode:
                  type: string
                  description: (string ("click" | "conversion")) Привязка конверсий
                    по времени.
                include_simulated:
                  type: boolean
                  description: "(boolean) Учитывать симулированный трафик."
                format:
                  type: string
                  description: (string ("csv" | "html")) Формат файла; по умолчанию
                    csv.
                limit, offset, compare, compare_from, compare_to:
                  type: string
                  description: "(принимаются структурой, но игнорируются) Поля присутствуют
                    в общей JSON-схеме тела (общей с /reports/query), но Export всегда
                    использует Limit=MaxExportRows(100000), Offset=0 и не строит период
                    сравнения."
            example:
              from: '2026-07-01T00:00:00Z'
              to: '2026-07-28T00:00:00Z'
              dimensions:
              - campaign
              filters: []
              format: csv
      responses:
        '200':
          description: 'ok — файл начинает стримиться (Content-Disposition: attachment)'
          content:
            application/json:
              schema:
                type: object
                properties:
                  "(тело — файл, не JSON)":
                    type: string
                    description: Заголовок = измерения + фиксированный набор метрик
                      (clicks, unique_clicks, conversions, leads, sales, rejected,
                      revenue, cost, profit, cr, epc, roi, roas, cpa, cpl, approval_rate)
                      + имена кастомных метрик; далее строки данных.
              example: |
                campaign,clicks,unique_clicks,conversions,leads,sales,rejected,revenue,cost,profit,cr,epc,roi,roas,cpa,cpl,approval_rate
                c1,500,420,30,20,10,2,300.00,100.00,200.00,6.00,0.60,200.00,3.00,3.33,5.00,93.33
        '400':
          description: ошибка декодирования JSON, построения отчёта (некорректное
            измерение/фильтр) либо неизвестное поле сортировки ("unknown sort metric")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: защитная проверка колонок экспорта (drift-guard "export column
            ... has no formatter"; в корректно собранном бинаре недостижимо)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Ответ пишется напрямую в http.ResponseWriter с периодическим Flush
        каждые 1000 строк, поэтому в случае ошибки ПОСЛЕ начала стриминга статус/заголовки
        уже не поменять; жёсткий предел MaxExportRows=100000 строк на одну выгрузку.
  "/reports/clean":
    post:
      operationId: post_reports_clean
      summary: Удаляет данные кликов и/или конверсий из ClickHouse для кампании и/или
        диапазона дат в пределах воркспейса.
      tags:
      - Отчёты
      x-permission: delete:reports
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                campaign_id:
                  type: string
                  description: "(string) UUID кампании для точечной очистки; обязателен,
                    если не задан from."
                from:
                  type: string
                  description: "(string (RFC3339)) Начало диапазона удаления; обязателен,
                    если не задан campaign_id. Невалидное (не-RFC3339) значение →
                    400."
                to:
                  type: string
                  description: "(string (RFC3339)) Конец диапазона удаления. Невалидное
                    значение → 400."
                table:
                  type: string
                  description: (string ("clicks" | "conversions" | "all")) Какие таблицы
                    чистить; по умолчанию all.
            example:
              campaign_id: c1a1a1a1-1111-1111-1111-111111111111
              table: clicks
      responses:
        '200':
          description: ok — мутация(и) поставлены в очередь
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: object
                    description: Имя таблицы -> "mutation enqueued".
              example:
                deleted:
                  clicks: mutation enqueued
        '400':
          description: ошибка декодирования JSON, не указан ни campaign_id ни from,
            некорректное значение table, либо невалидный (не-RFC3339) from/to
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка выполнения ALTER TABLE ... DELETE в ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Удаление всегда явно скоупится по workspace_id из JWT в WHERE —
        без этого campaign_id/from=пусто могло бы стереть чужие данные; ALTER TABLE
        DELETE в ClickHouse асинхронный (мутация ставится в очередь, а не выполняется
        мгновенно).
  "/simulator/status":
    get:
      operationId: get_simulator_status
      summary: Возвращает текущее число симулированных кликов и конверсий (is_simulated=1)
        в воркспейсе.
      tags:
      - Симулятор трафика
      x-permission: view:reports
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  simulated_clicks:
                    type: number
                    description: Число симулированных кликов.
                  simulated_conversions:
                    type: number
                    description: Число симулированных конверсий.
              example:
                simulated_clicks: 15000
                simulated_conversions: 420
        '500':
          description: ошибка подсчёта в ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
  "/simulator/run":
    post:
      operationId: post_simulator_run
      summary: Синхронно генерирует синтетический трафик (клики и конверсии) для кампании
        и записывает его в ClickHouse с флагом is_simulated=1.
      tags:
      - Симулятор трафика
      x-permission: edit:reports
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                campaign_id:
                  type: string
                  description: "(string (uuid)) Кампания, для которой генерируется
                    трафик."
                flow_id:
                  type: string
                  description: "(string (uuid)) Поток; при отсутствии/невалидном значении
                    генерируется случайный UUID."
                offer_id:
                  type: string
                  description: "(string (uuid)) Оффер; невалидный UUID молча игнорируется."
                landing_id:
                  type: string
                  description: "(string (uuid)) Лендинг; невалидный UUID молча игнорируется."
                clicks:
                  type: integer
                  minimum: 1
                  maximum: 100000
                  description: "(integer) Сколько кликов сгенерировать; допустимый
                    диапазон 1..100000 (simulator.MaxSimClicks), выход за него → 400
                    clicks must be 1..100000."
                spread_hours:
                  type: number
                  description: "(number) Растянуть события на N часов назад от текущего
                    момента; ≤0 → 24."
                conversion_rate:
                  type: number
                  description: "(number) Доля кликов (0..1), дающих конверсию."
                status_mix:
                  type: object
                  description: "(object {lead,sale,rejected,trash}) Веса распределения
                    статусов конверсий (не обязаны суммироваться в 100 — нормализуются)."
                device_mix:
                  type: object
                  description: "(object {mobile,desktop,tablet}) Веса распределения
                    устройств."
                geo:
                  type: string
                  enum:
                  - tier1
                  - tier2
                  - tier3
                  - mixed
                  - russia
                  description: (string ("tier1"|"tier2"|"tier3"|"mixed"|"russia"))
                    Гео-пресет.
                cost_per_click:
                  type: number
                  description: "(number) Стоимость клика для симулированного трафика."
                payout_min:
                  type: number
                  description: "(number) Минимальная выплата по конверсии."
                payout_max:
                  type: number
                  description: "(number) Максимальная выплата по конверсии."
                bot_rate:
                  type: number
                  description: "(number) Доля кликов, помечаемых ботами."
              required:
              - campaign_id
              - clicks
            example:
              campaign_id: c1a1a1a1-1111-1111-1111-111111111111
              clicks: 5000
              spread_hours: 24
              conversion_rate: 0.08
              status_mix:
                lead: 60
                sale: 30
                rejected: 10
                trash: 0
              device_mix:
                mobile: 70
                desktop: 25
                tablet: 5
              geo: tier1
              cost_per_click: 0.02
              payout_min: 5
              payout_max: 25
              bot_rate: 0.03
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  clicks:
                    type: number
                    description: Сколько кликов фактически сгенерировано.
                  conversions:
                    type: number
                    description: Сколько конверсий сгенерировано.
                  duration_ms:
                    type: number
                    description: Время выполнения генерации, миллисекунды.
              example:
                clicks: 5000
                conversions: 412
                duration_ms: 1840
        '400':
          description: ошибка декодирования JSON, невалидный campaign_id либо clicks
            вне диапазона 1..100000 (clicks must be 1..100000)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка генерации или записи в ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Вызов блокирующий — сервер ждёт завершения записи в ClickHouse
        (по комментарию в коде: до ~100k кликов обычно <2с); после успеха публикует
        realtime-события clicks/conversions/simulator/dashboard всем клиентам воркспейса
        через realtime.Client.'
  "/simulator/clean":
    post:
      operationId: post_simulator_clean
      summary: Удаляет все строки с is_simulated=1 (клики и конверсии) для текущего
        воркспейса.
      tags:
      - Симулятор трафика
      x-permission: delete:reports
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok".'
              example:
                status: ok
        '500':
          description: ошибка удаления в ClickHouse
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: После успешного удаления публикует realtime-событие dashboard "cleaned"
        всем клиентам воркспейса.
  "/saved-reports":
    get:
      operationId: get_saved_reports
      summary: 'Возвращает список сохранённых отчётов воркспейса: общие (shared) плюс
        собственные приватные отчёты вызывающего пользователя.'
      tags:
      - Отчёты
      x-permission: view:reports
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ID отчёта.
                    name:
                      type: string
                      description: Название.
                    owner_id:
                      type: string
                      description: Владелец, omitempty.
                    config:
                      type: object
                      description: Произвольная конфигурация отчёта (dimensions/filters/sort
                        и т.п.), хранится как JSON.
                    shared:
                      type: boolean
                      description: Виден ли всем в воркспейсе.
                    created_at:
                      type: string
                      description: Дата создания.
                    updated_at:
                      type: string
                      description: Дата обновления.
              example:
              - id: 11111111-1111-1111-1111-111111111111
                name: Weekly ROI
                owner_id: 22222222-2222-2222-2222-222222222222
                config:
                  dimensions:
                  - campaign
                  filters: []
                shared: true
                created_at: '2026-07-01T10:00:00Z'
                updated_at: '2026-07-20T10:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Скоуп по workspace_id обязателен в SQL — без него флаг shared протекал
        бы между воркспейсами (явно упомянуто в комментарии кода).
    post:
      operationId: post_saved_reports
      summary: Создаёт новый сохранённый отчёт, принадлежащий текущему пользователю.
      tags:
      - Отчёты
      x-permission: edit:reports
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) Название отчёта."
                config:
                  type: object
                  description: "(object) Произвольная конфигурация отчёта."
                shared:
                  type: boolean
                  description: "(boolean) Сделать видимым всем в воркспейсе; по умолчанию
                    false."
              required:
              - name
            example:
              name: Weekly ROI
              config:
                dimensions:
                - campaign
                filters: []
              shared: true
      responses:
        '201':
          description: создано
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID созданного отчёта.
                  name:
                    type: string
                    description: Название.
                  owner_id:
                    type: string
                    description: Владелец — всегда текущий пользователь.
                  config:
                    type: object
                    description: Сохранённая конфигурация.
                  shared:
                    type: boolean
                    description: Флаг общего доступа.
                  created_at:
                    type: string
                    description: Дата создания.
                  updated_at:
                    type: string
                    description: Дата обновления.
              example:
                id: 11111111-1111-1111-1111-111111111111
                name: Weekly ROI
                owner_id: 22222222-2222-2222-2222-222222222222
                config:
                  dimensions:
                  - campaign
                  filters: []
                shared: true
                created_at: '2026-07-28T10:00:00Z'
                updated_at: '2026-07-28T10:00:00Z'
        '400':
          description: ошибка декодирования JSON, пустое name, либо ошибка вставки
            в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: owner_id всегда устанавливается сервером = ID текущего пользователя
        (тело запроса не может задать владельца); пишет audit-лог (create/saved_report).
  "/saved-reports/{id}":
    get:
      operationId: get_saved_reports_id
      summary: Возвращает один сохранённый отчёт по ID, если он общий или принадлежит
        текущему пользователю.
      tags:
      - Отчёты
      x-permission: view:reports
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID сохранённого отчёта; при невалидном UUID — 400.
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID отчёта.
                  name:
                    type: string
                    description: Название.
                  owner_id:
                    type: string
                    description: Владелец, omitempty.
                  config:
                    type: object
                    description: Конфигурация отчёта.
                  shared:
                    type: boolean
                    description: Флаг общего доступа.
                  created_at:
                    type: string
                    description: Дата создания.
                  updated_at:
                    type: string
                    description: Дата обновления.
              example:
                id: 11111111-1111-1111-1111-111111111111
                name: Weekly ROI
                owner_id: 22222222-2222-2222-2222-222222222222
                config:
                  dimensions:
                  - campaign
                shared: true
                created_at: '2026-07-01T10:00:00Z'
                updated_at: '2026-07-20T10:00:00Z'
        '400':
          description: некорректный id
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: не найдено — либо чужой приватный отчёт, либо отчёт из другого
            воркспейса
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Скоуп: WHERE id=$1 AND workspace_id=$2 AND (shared=true OR owner_id=$3)
        — доступ к чужому приватному отчёту возвращает 404, а не 403, чтобы не подтверждать
        факт существования записи.'
    put:
      operationId: put_saved_reports_id
      summary: Обновляет название, конфигурацию и флаг shared сохранённого отчёта;
        изменять может только владелец.
      tags:
      - Отчёты
      x-permission: edit:reports
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID сохранённого отчёта; при невалидном UUID — 400.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) Новое название; в отличие от Create, пустое
                    значение не отклоняется и будет сохранено как есть."
                config:
                  type: object
                  description: "(object) Новая конфигурация отчёта (полностью заменяет
                    старую)."
                shared:
                  type: boolean
                  description: "(boolean) Новый флаг общего доступа."
            example:
              name: Weekly ROI v2
              config:
                dimensions:
                - campaign
                - country
              shared: false
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok".'
              example:
                status: ok
        '400':
          description: некорректный id, ошибка декодирования JSON, либо ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: не найдено — не ваш отчёт (owner_id не совпал) или другой воркспейс
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: UPDATE выполняется с условием owner_id=$6 — редактировать может
        только владелец, даже если отчёт shared=true; пишет audit-лог (update/saved_report).
    delete:
      operationId: delete_saved_reports_id
      summary: Удаляет сохранённый отчёт; удалить может только владелец.
      tags:
      - Отчёты
      x-permission: delete:reports
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID сохранённого отчёта; при невалидном UUID — 400.
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok".'
              example:
                status: ok
        '400':
          description: некорректный id или ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: не найдено — не ваш отчёт или другой воркспейс
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: DELETE скоупится по workspace_id и owner_id одновременно; пишет
        audit-лог (delete/saved_report).
  "/custom-metrics":
    get:
      operationId: get_custom_metrics
      summary: Возвращает список кастомных метрик воркспейса, доступных для использования
        в отчётах.
      tags:
      - Отчёты
      x-permission: view:reports
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ID метрики.
                    name:
                      type: string
                      description: Название метрики (уникально в пределах воркспейса).
                    expr:
                      type: string
                      description: Формула метрики на DSL движка отчётов.
                    created_at:
                      type: string
                      description: Дата создания.
                    updated_at:
                      type: string
                      description: Дата обновления.
              example:
              - id: 33333333-3333-3333-3333-333333333333
                name: epc2
                expr: revenue / clicks * 2
                created_at: '2026-07-01T10:00:00Z'
                updated_at: '2026-07-01T10:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
    post:
      operationId: post_custom_metrics
      summary: Создаёт новую кастомную метрику воркспейса, проверяя формулу компиляцией
        через DSL движка отчётов.
      tags:
      - Отчёты
      x-permission: edit:reports
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) Название метрики."
                expr:
                  type: string
                  description: "(string) Формула метрики; должна успешно скомпилироваться
                    через reports.CompileMetrics."
              required:
              - name
              - expr
            example:
              name: epc2
              expr: revenue / clicks * 2
      responses:
        '200':
          description: ok (не 201, несмотря на создание ресурса)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID созданной метрики.
                  name:
                    type: string
                    description: Название.
                  expr:
                    type: string
                    description: Формула.
                  created_at:
                    type: string
                    description: Дата создания.
                  updated_at:
                    type: string
                    description: Дата обновления.
              example:
                id: 33333333-3333-3333-3333-333333333333
                name: epc2
                expr: revenue / clicks * 2
                created_at: '2026-07-28T10:00:00Z'
                updated_at: '2026-07-28T10:00:00Z'
        '400':
          description: пустое имя/формула, невалидная формула, либо нарушение уникальности
            UNIQUE(workspace_id, name)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Формула компилируется через движок метрик DSL до сохранения — битая
        формула отклоняется на этапе записи, а не при первом использовании в отчёте;
        пишет audit-лог (create/custom_metric).
  "/custom-metrics/{id}":
    put:
      operationId: put_custom_metrics_id
      summary: Обновляет название и формулу кастомной метрики воркспейса.
      tags:
      - Отчёты
      x-permission: edit:reports
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID кастомной метрики; при невалидном UUID — 400.
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) Новое название."
                expr:
                  type: string
                  description: "(string) Новая формула; проверяется компиляцией."
              required:
              - name
              - expr
            example:
              name: epc2
              expr: revenue / clicks * 3
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID метрики (проставляется из пути).
                  name:
                    type: string
                    description: Название.
                  expr:
                    type: string
                    description: Формула.
                  created_at:
                    type: string
                    description: Не перечитывается из БД — берётся из декодированного
                      тела запроса; если клиент не прислал это поле, будет нулевым
                      временем.
                  updated_at:
                    type: string
                    description: Аналогично created_at — НЕ равно фактическому updated_at
                      в БД (там записано now()), в ответе — то, что пришло в теле
                      запроса (обычно нулевое время).
              example:
                id: 33333333-3333-3333-3333-333333333333
                name: epc2
                expr: revenue / clicks * 3
                created_at: '0001-01-01T00:00:00Z'
                updated_at: '0001-01-01T00:00:00Z'
        '400':
          description: некорректный id, ошибка декодирования JSON, невалидная формула,
            либо ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: не найдено (RowsAffected=0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Баг/особенность ответа: created_at/updated_at в теле ответа не
        перечитываются из БД после UPDATE (в отличие от Create), а берутся из декодированного
        тела запроса — обычно клиент их не присылает, поэтому в ответе они будут нулевым
        временем "0001-01-01T00:00:00Z"; пишет audit-лог (update/custom_metric).'
    delete:
      operationId: delete_custom_metrics_id
      summary: Удаляет кастомную метрику воркспейса.
      tags:
      - Отчёты
      x-permission: delete:reports
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID кастомной метрики; при невалидном UUID — 400.
      responses:
        '204':
          description: успешно удалено, тело ответа отсутствует
        '400':
          description: некорректный id
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: не найдено (RowsAffected=0)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка выполнения DELETE в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: В отличие от большинства delete-эндпоинтов в этом API, здесь НЕ
        возвращается {"status":"ok"} — используется чистый 204 No Content без JSON-тела;
        пишет audit-лог (delete/custom_metric).
  "/team":
    get:
      operationId: get_team
      summary: Возвращает список всех пользователей, входящих в workspace вызывающего.
      tags:
      - Команда
      x-permission: view:team
      responses:
        '200':
          description: успешно, массив пользователей (пустой массив, если участников
            нет)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ID пользователя
                    email:
                      type: string
                      description: Email пользователя
                    name:
                      type: string
                      description: Имя пользователя
                    role:
                      type: string
                      description: Глобальная роль пользователя (owner/admin/manager/analyst)
                    mfa_enabled:
                      type: boolean
                      description: Включена ли двухфакторная аутентификация
                    locale:
                      type: string
                      description: Локаль пользователя
                    timezone:
                      type: string
                      description: Часовой пояс пользователя
                    last_login_at:
                      type: string
                      description: Время последнего входа (может отсутствовать)
                    permissions:
                      type: object
                      description: Переопределения прав по сущностям
                    created_at:
                      type: string
                      description: Дата создания
                    updated_at:
                      type: string
                      description: Дата последнего обновления
              example:
              - id: 3f9c2e1a-8b7d-4c6f-9a2e-1d5b6c7e8f90
                email: jane@example.com
                name: Jane Doe
                role: manager
                mfa_enabled: false
                locale: en
                timezone: UTC
                last_login_at: '2026-07-27T10:15:00Z'
                permissions: {}
                created_at: '2026-01-10T09:00:00Z'
                updated_at: '2026-07-20T12:30:00Z'
        '403':
          description: нет права team:view в активном workspace (RequirePerm на уровне
            роутера)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса/сканирования строк из БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Список жёстко скоупирован по workspace_id вызывающего через JOIN
        workspace_members (wsID берётся из JWT), поэтому кросс-тенантная утечка исключена;
        доступ гейтится RequirePerm(team, view) на уровне роутера — сам хендлер явных
        проверок не делает.
    post:
      operationId: post_team
      summary: Создаёт нового пользователя и сразу добавляет его участником в workspace
        вызывающего.
      tags:
      - Команда
      x-permission: admin:team
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  description: "(string) Email нового пользователя (обязателен, иначе
                    400)"
                name:
                  type: string
                  description: "(string) Имя пользователя, валидация отсутствует"
                role:
                  type: string
                  description: "(string) Роль участника В ЭТОМ workspace (пишется
                    в workspace_members); если пусто — 'manager'; должна быть известной
                    ролью, иначе 400; глобальная users.role при этом всегда 'manager'
                    (globalDefaultRole) и из этого эндпоинта не задаётся"
                password:
                  type: string
                  description: "(string) Пароль (обязателен и непустой, иначе 400;
                    в отличие от сброса пароля минимальная длина не проверяется)"
                permissions:
                  type: object
                  description: "(object) Переопределения прав по сущностям, сохраняются
                    как есть в JSONB"
              required:
              - email
              - password
            example:
              email: jane@example.com
              name: Jane Doe
              role: manager
              password: S3cureP@ss
              permissions:
                campaigns:
                - view
                - edit
      responses:
        '201':
          description: пользователь создан и добавлен в workspace
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID созданного пользователя
              example:
                id: 3f9c2e1a-8b7d-4c6f-9a2e-1d5b6c7e8f90
        '400':
          description: пустые email/password, невалидный JSON (в т.ч. неизвестное
            поле), неизвестная роль ('invalid role'), либо ошибка INSERT (например
            дубликат email)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: запрошенная роль выше роли вызывающего в workspace ('cannot
            grant a role above your own')
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка хеширования пароля или транзакции БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Создание строки users и добавление в workspace_members выполняется
        в одной транзакции — иначе (т.к. team.List скопирован по workspace) новый
        пользователь был бы невидим и неуправляем; в ответ возвращается только {"id":...},
        а не полный объект User, что отличается от общего соглашения о create-эндпоинтах;
        после коммита пишется запись в audit log (create/user); запрошенная роль действует
        только внутри workspace (workspace_members), глобальная users.role новому
        аккаунту всегда ставится 'manager'.
  "/team/{id}":
    put:
      operationId: put_team_id
      summary: Обновляет имя, роль и права участника команды внутри workspace вызывающего.
      tags:
      - Команда
      x-permission: admin:team
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID пользователя (строка users.id); невалидный UUID -> 400 'invalid
          id'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) Новое имя; валидация отсутствует, пустая
                    строка перезапишет значение"
                role:
                  type: string
                  description: "(string) Принимается для обратной совместимости, но
                    ИГНОРИРУЕТСЯ: глобальная users.role через этот эндпоинт не меняется
                    (это был путь эскалации до глобального owner/admin); роль участника
                    в workspace меняется через POST /workspaces/{id}/members"
                permissions:
                  type: object
                  description: "(object) Новый набор переопределений прав, полностью
                    заменяет старый"
            example:
              name: Jane Doe
              role: analyst
              permissions:
                reports:
                - view
      responses:
        '200':
          description: успешно обновлено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда 'ok'
              example:
                status: ok
        '400':
          description: невалидный id, ошибка декодирования JSON, либо ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: пользователь также состоит в другом workspace (защита глобального
            аккаунта, soleMemberOf), либо целевой участник выше вызывающего по рангу
            в workspace ('cannot modify a member who outranks you')
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: пользователь не является участником workspace вызывающего
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: "Мутация глобальной строки users разрешена только если целевой
        пользователь состоит ИСКЛЮЧИТЕЛЬНО в вызывающем workspace (soleMemberOf) —
        иначе 403, чтобы админ одного тенанта не мог перезаписать роль/права аккаунта,
        живущего также в другом тенанте; после успеха пишется audit log (update/user)
        с телом запроса как diff; дополнительно действует правило рангов (roleRank/outranks):
        участник не может редактировать того, кто выше его по роли в workspace — admin
        не трогает owner; равный ранг разрешён."
    delete:
      operationId: delete_team_id
      summary: Удаляет участника из workspace вызывающего, а глобальный аккаунт —
        только если это было его последнее членство.
      tags:
      - Команда
      x-permission: delete:team
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID пользователя; невалидный UUID -> 400 'invalid id'
      responses:
        '200':
          description: удалено (из workspace, и/или глобально)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда 'ok'
              example:
                status: ok
        '400':
          description: невалидный id или ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: целевой участник выше вызывающего по рангу в workspace ('cannot
            remove a member who outranks you')
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: пользователь не является участником workspace вызывающего
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка транзакции (begin/count/commit)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: "В отличие от Update/ResetPassword здесь нет проверки soleMemberOf,
        но действует правило рангов: нельзя удалить участника выше себя по роли (admin
        не удалит owner); глобальная строка users физически удаляется только если
        после удаления membership у пользователя не осталось других workspace (remaining==0);
        audit log пишет workspace_id и флаг account_removed."
  "/team/{id}/password":
    post:
      operationId: post_team_id_password
      summary: Принудительно сбрасывает пароль участника команды.
      tags:
      - Команда
      x-permission: admin:team
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID пользователя; невалидный UUID -> 400 'invalid id'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                password:
                  type: string
                  description: "(string) Новый пароль, минимум 6 символов, иначе 400
                    'password too short'"
              required:
              - password
            example:
              password: N3wPassw0rd
      responses:
        '200':
          description: пароль сброшен
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда 'ok'
              example:
                status: ok
        '400':
          description: невалидный id, пароль короче 6 символов/ошибка декодирования,
            либо ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: пользователь также состоит в другом workspace (soleMemberOf),
            либо целевой участник выше вызывающего по рангу в workspace ('cannot reset
            the password of a member who outranks you')
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: пользователь не является участником workspace вызывающего
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка хеширования пароля
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Та же защита soleMemberOf, что и в Update: нельзя сбросить пароль
        аккаунта, который живёт также в другом тенанте (иначе это был бы захват чужого
        аккаунта через смену пароля и последующий логин); audit log пишется с action=reset_password
        и diff=nil (сам пароль в лог не попадает); сброс пароля инкрементирует token_version
        пользователя — все ранее выданные ему JWT немедленно перестают действовать,
        активные сессии разлогиниваются.'
  "/team/audit":
    get:
      operationId: get_team_audit
      summary: Возвращает постранично журнал аудита действий внутри workspace вызывающего
        с необязательными фильтрами.
      tags:
      - Команда
      x-permission: admin:team
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) Размер страницы; по умолчанию 200, максимум 2000, невалидное/неположительное
          значение молча заменяется на 200"
      - name: offset
        in: query
        required: false
        schema:
          type: number
        description: "(number) Смещение; по умолчанию 0, невалидное/отрицательное
          значение молча заменяется на 0"
      - name: entity
        in: query
        required: false
        schema:
          type: string
        description: "(string) Точный фильтр по типу сущности (entity = ...)"
      - name: action
        in: query
        required: false
        schema:
          type: string
        description: "(string) Точный фильтр по действию (action = ...)"
      - name: user
        in: query
        required: false
        schema:
          type: string
        description: "(string) Подстрока email пользователя (ILIKE)"
      - name: ip
        in: query
        required: false
        schema:
          type: string
        description: "(string) Подстрока IP-адреса (ILIKE)"
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string) Нижняя граница created_at (created_at >= from), передаётся
          как есть в SQL"
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string) Верхняя граница created_at (created_at < to), передаётся
          как есть в SQL"
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: "(string) Поиск подстроки по entity_id или diff::text (ILIKE)"
      responses:
        '200':
          description: успешно, объект со списком и метаданными пагинации
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    description: 'Массив записей журнала: id, user_id, user_email,
                      entity, entity_id, action, diff(object), ip, created_at'
                  total:
                    type: number
                    description: Общее число записей, удовлетворяющих фильтрам (без
                      учёта limit/offset)
                  limit:
                    type: number
                    description: Применённый limit
                  offset:
                    type: number
                    description: Применённый offset
              example:
                rows:
                - id: a1b2c3d4-0000-0000-0000-000000000001
                  user_id: 3f9c2e1a-8b7d-4c6f-9a2e-1d5b6c7e8f90
                  user_email: jane@example.com
                  entity: campaign
                  entity_id: 7e6d5c4b-0000-0000-0000-000000000002
                  action: update
                  diff:
                    name: New Name
                  ip: 203.0.113.5
                  created_at: '2026-07-28T08:00:00Z'
                total: 1
                limit: 200
                offset: 0
        '500':
          description: ошибка COUNT/SELECT запроса или сканирования строк (в т.ч.
            если from/to не парсятся Postgres как timestamp)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: workspace_id = $1 в WHERE — единственная граница тенантности (audit_log
        общая таблица на все workspace); невалидные from/to не отклоняются на уровне
        Go (нет 400), а пробрасываются как строки в SQL и приведут к 500 от Postgres
        при неверном формате.
  "/workspaces":
    get:
      operationId: get_workspaces
      summary: Возвращает список всех workspace, участником которых является вызывающий
        пользователь.
      tags:
      - Команда
      x-permission: view:settings
      responses:
        '200':
          description: успешно, массив workspace (пустой массив, если участий нет)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ID workspace
                    slug:
                      type: string
                      description: Слаг workspace
                    name:
                      type: string
                      description: Название workspace
                    role:
                      type: string
                      description: Роль вызывающего в этом workspace (из workspace_members)
                    is_current:
                      type: boolean
                      description: true, если это workspace, на который скопирован
                        текущий JWT
                    created_at:
                      type: string
                      description: Дата создания workspace
              example:
              - id: 1a2b3c4d-0000-0000-0000-000000000010
                slug: default
                name: Default
                role: owner
                is_current: true
                created_at: '2026-01-01T00:00:00Z'
        '401':
          description: не удалось получить пользователя из контекста (не аутентифицирован)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса/сканирования строк из БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: is_current вычисляется сравнением ID workspace со значением WorkspaceID,
        зашитым в текущий JWT (u.WorkspaceID), а не хранится в БД.
    post:
      operationId: post_workspaces
      summary: Создаёт новый workspace и делает вызывающего его владельцем (owner).
      tags:
      - Команда
      x-permission: admin:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                slug:
                  type: string
                  description: "(string) Слаг workspace; если пусто после trim, генерируется
                    автоматически из name функцией slugify"
                name:
                  type: string
                  description: "(string) Название workspace; после trim не может быть
                    пустым, иначе 400"
              required:
              - name
            example:
              slug: acme
              name: Acme Inc
      responses:
        '201':
          description: workspace создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ID созданного workspace
                  slug:
                    type: string
                    description: Итоговый слаг
                  name:
                    type: string
                    description: Название
                  role:
                    type: string
                    description: Всегда 'owner' для создателя
                  is_current:
                    type: boolean
                    description: Не проставляется хендлером, всегда false в ответе
                  created_at:
                    type: string
                    description: Дата создания
              example:
                id: 1a2b3c4d-0000-0000-0000-000000000020
                slug: acme
                name: Acme Inc
                role: owner
                is_current: false
                created_at: '2026-07-28T09:00:00Z'
        '400':
          description: пустое name после trim, ошибка декодирования JSON, либо ошибка
            INSERT (например конфликт слага)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: глобальная роль вызывающего не owner и не admin
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка транзакции БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Единственный из /workspaces хендлеров, проверяющий ГЛОБАЛЬНУЮ users.role
        вызывающего (owner/admin), а не роль в конкретном workspace, как это делают
        Update/Delete/Switch/ListMembers/AddMember/RemoveMember через memberCan; создание
        workspace и добавление создателя как 'owner' в workspace_members выполняется
        в одной транзакции; audit log пишется при наличии h.Audit.
  "/workspaces/{id}":
    put:
      operationId: put_workspaces_id
      summary: Переименовывает workspace и/или меняет его слаг.
      tags:
      - Команда
      x-permission: admin:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID workspace; невалидный UUID -> 400 'invalid id'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                slug:
                  type: string
                  description: "(string) Новый слаг; пустая строка не меняет текущее
                    значение (COALESCE/NULLIF в SQL)"
                name:
                  type: string
                  description: "(string) Новое название; пустая строка не меняет текущее
                    значение"
            example:
              slug: acme-2
              name: Acme Inc 2
      responses:
        '200':
          description: успешно обновлено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда 'ok'
              example:
                status: ok
        '400':
          description: невалидный id, ошибка декодирования JSON, либо ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: вызывающий не owner/admin данного конкретного workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Проверка прав здесь идёт через per-workspace membership (memberCan
        owner/admin над конкретным {id}), в отличие от Create, который проверяет глобальную
        users.role — несогласованность модели прав между Create и Update; audit log
        пишется с телом запроса как diff при наличии h.Audit.
    delete:
      operationId: delete_workspaces_id
      summary: Удаляет workspace целиком (кроме защищённого workspace 'default').
      tags:
      - Команда
      x-permission: admin:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID workspace; невалидный UUID -> 400 'invalid id'
      responses:
        '200':
          description: workspace удалён
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда 'ok'
              example:
                status: ok
        '400':
          description: невалидный id, попытка удалить workspace со slug='default',
            либо ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: вызывающий не owner данного workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Комментарий в коде утверждает 'refuses to delete the last one the
        caller has', но фактическая реализация этого НЕ проверяет — единственная защита
        это slug='default'; вызывающий технически может остаться без единого workspace,
        если удалит свой последний не-default workspace.
  "/workspaces/{id}/switch":
    post:
      operationId: post_workspaces_id_switch
      summary: Выдаёт новый JWT, скоуп��рованный на указанный workspace, и делает
        его активным по умолчанию для пользователя.
      tags:
      - Команда
      x-permission: none (authenticated only)
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID workspace, в который переключаются; невалидный UUID -> 400
          'invalid id'
      responses:
        '200':
          description: токен выдан
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: Новый JWT, скоупированный на роль вызывающего в целевом
                      workspace, срок жизни 7 дней
                  workspace_id:
                    type: string
                    description: ID workspace, на который выдан токен
              example:
                token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
                workspace_id: 1a2b3c4d-0000-0000-0000-000000000010
        '400':
          description: невалидный id
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: не аутентифицирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: вызывающий не является участником целевого workspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка обновления default_workspace_id или подписи токена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Побочный эффект: обновляет users.default_workspace_id, поэтому
        следующий логин сразу откроет этот workspace; новая роль в токене берётся
        из workspace_members ЦЕЛЕВОГО workspace (membershipRole), а не из глобальной
        users.role — иначе смена workspace могла бы сохранить излишние права.'
  "/workspaces/{id}/members":
    get:
      operationId: get_workspaces_id_members
      summary: Возвращает список участников указанного workspace.
      tags:
      - Команда
      x-permission: view:team
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID workspace; невалидный UUID -> 400 'invalid id'
      responses:
        '200':
          description: успешно, массив участников
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    user_id:
                      type: string
                      description: ID пользователя
                    email:
                      type: string
                      description: Email пользователя
                    name:
                      type: string
                      description: Имя пользователя
                    role:
                      type: string
                      description: Роль пользователя в этом workspace (workspace_members.role)
                    created_at:
                      type: string
                      description: Дата добавления в workspace
              example:
              - user_id: 3f9c2e1a-8b7d-4c6f-9a2e-1d5b6c7e8f90
                email: jane@example.com
                name: Jane Doe
                role: manager
                created_at: '2026-01-10T09:00:00Z'
        '400':
          description: невалидный id
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: вызывающий не является участником этого workspace ни с одной
            из допустимых ролей
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса/сканирования строк из БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Список допустимых ролей для доступа включает 'affiliate' — роль,
        отсутствующая в общей описанной модели owner/admin/manager/analyst, но явно
        разрешённая в коде memberCan; хендлер игнорирует ok из auth.FromContext (u,
        _ :=...), поэтому при отсутствии пользователя в контексте это приведёт к 403
        через memberCan, а не к явному 401.
    post:
      operationId: post_workspaces_id_members
      summary: Добавляет уже существующего пользователя (состоящего с вызывающим в
        общем workspace) в указанный workspace с заданной ролью.
      tags:
      - Команда
      x-permission: admin:team
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID workspace, в который добавляется участник; невалидный UUID
          -> 400 'invalid id'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  description: "(string) Email пользователя, которого нужно найти
                    среди уже существующих коллег по другим workspace вызывающего"
                role:
                  type: string
                  description: "(string) Роль в новом workspace; если пусто — 'manager';
                    должна быть известной ролью (affiliate/analyst/manager/admin/owner),
                    иначе 400 'invalid role'; нельзя назначить роль выше собственной"
              required:
              - email
            example:
              email: jane@example.com
              role: manager
      responses:
        '200':
          description: пользователь добавлен/обновлена его роль в workspace
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда 'ok'
              example:
                status: ok
        '400':
          description: невалидный id, ошибка декодирования JSON, неизвестная роль
            ('invalid role'), либо ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: вызывающий не owner/admin данного workspace, либо назначаемая
            роль выше его собственной ('cannot assign a role above your own'), либо
            целевой участник уже состоит в workspace с ролью выше вызывающего ('cannot
            modify a member who outranks you')
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: пользователь с таким email не найден среди тех, кто уже состоит
            в общем с вызывающим workspace ('no such user in your team; send an invitation
            instead')
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Ключевая защита от захвата аккаунта: можно добавить только пользователя,
        который УЖЕ состоит хотя бы в одном общем workspace с вызывающим (подзапрос
        m1/m2 по workspace_members) — произвольный email по всей системе не подходит;
        несуществующий email и email ''чужого'' пользователя возвращают одну и ту
        же 404-ошибку, чтобы нельзя было энумерировать аккаунты; при повторном добавлении
        роль обновляется через ON CONFLICT DO UPDATE; поскольку ON CONFLICT DO UPDATE
        позволяет менять роль существующего участника, действует правило рангов: admin
        не может понизить owner и не может выдать роль выше своей.'
  "/workspaces/{id}/members/{userId}":
    delete:
      operationId: delete_workspaces_id_members_userId
      summary: Убирает пользователя из указанного workspace (удаляет только строку
        членства, не глобальный аккаунт).
      tags:
      - Команда
      x-permission: delete:team
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: UUID workspace; невалидный UUID -> 400 'invalid id'
      - name: userId
        in: path
        required: true
        schema:
          type: string
        description: UUID пользователя, которого нужно убрать; невалидный UUID ->
          400 'invalid user id'
      responses:
        '200':
          description: запрос выполнен (даже если удалять было нечего)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: Всегда 'ok'
              example:
                status: ok
        '400':
          description: невалидный id или userId, либо ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: вызывающий не owner/admin данного workspace, либо целевой участник
            выше его по рангу ('cannot remove a member who outranks you') — admin
            не может выселить owner; равный ранг разрешён
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: DELETE не проверяет RowsAffected — вернёт 200 'ok', даже если userId
        не был участником этого workspace; удаляется только строка workspace_members,
        глобальная строка users не трогается (в отличие от team.Delete, где при последнем
        членстве аккаунт удаляется физически); audit log пишет user_id в diff.
  "/conversion-statuses":
    get:
      operationId: get_conversion_statuses
      summary: 'Возвращает список статусов конверсий: глобальные встроенные (workspace_id
        IS NULL) плюс кастомные статусы текущего воркспейса, отсортированные по коду.'
      tags:
      - Статусы конверсий
      x-permission: view:settings
      responses:
        '200':
          description: успешно, массив статусов (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: идентификатор статуса
                    code:
                      type: string
                      description: короткий код статуса
                    name:
                      type: string
                      description: отображаемое имя
                    color:
                      type: string
                      description: цвет для UI
                    counts_as:
                      type: string
                      description: как статус засчитывается (lead/sale/other и т.п.)
                    created_at:
                      type: string
                      description: дата создания (time)
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                code: approved
                name: Approved
                color: "#22c55e"
                counts_as: sale
                created_at: '2026-01-10T12:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: В одном списке смешиваются глобальные built-in статусы (workspace_id
        IS NULL, общие для всех тенантов) и кастомные статусы воркспейса — различить
        их по ответу нельзя, только по последующему поведению Update/Delete (built-in
        нельзя изменить).
    post:
      operationId: post_conversion_statuses
      summary: Создаёт новый кастомный статус конверсии для текущего воркспейса.
      tags:
      - Статусы конверсий
      x-permission: edit:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                code:
                  type: string
                  description: "(string) короткий код статуса, обязателен"
                name:
                  type: string
                  description: "(string) отображаемое имя, обязательно"
                color:
                  type: string
                  description: "(string) цвет для UI, не валидируется, по умолчанию
                    пустая строка"
                counts_as:
                  type: string
                  description: (string) категория засчитывания; если пусто — подставляется
                    "other"
              required:
              - code
              - name
            example:
              code: lead
              name: Lead
              color: "#3b82f6"
              counts_as: lead
      responses:
        '201':
          description: статус успешно создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: сгенерированный id
                  code:
                    type: string
                    description: ''
                  name:
                    type: string
                    description: ''
                  color:
                    type: string
                    description: ''
                  counts_as:
                    type: string
                    description: ''
                  created_at:
                    type: string
                    description: время создания
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                code: lead
                name: Lead
                color: "#3b82f6"
                counts_as: lead
                created_at: '2026-07-28T10:00:00Z'
        '400':
          description: невалидный JSON / неизвестное поле / отсутствуют code или name
            / ошибка вставки в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Пишется запись в audit log (create/status); новая запись всегда
        привязывается к workspace_id вызывающего, глобальным (workspace_id NULL) стать
        не может.
  "/conversion-statuses/{id}":
    put:
      operationId: put_conversion_statuses_id
      summary: Обновляет имя, цвет и категорию засчитывания кастомного статуса конверсии
        текущего воркспейса.
      tags:
      - Статусы конверсий
      x-permission: edit:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid статуса, обязателен, 400 если невалиден
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) новое имя; при отсутствии в теле будет записана
                    пустая строка"
                color:
                  type: string
                  description: "(string) новый цвет; при отсутствии будет записана
                    пустая строка"
                counts_as:
                  type: string
                  description: "(string) новая категория засчитывания; при отсутствии
                    будет записана пустая строка"
            example:
              name: Lead (qualified)
              color: "#16a34a"
              counts_as: lead
      responses:
        '200':
          description: успешно обновлено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный id / невалидный JSON / ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: статус не найден в этом воркспейсе (в т.ч. попытка изменить
            глобальный built-in статус)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: В отличие от Create, здесь нет проверки на пустое name — можно
        случайно обнулить name/color/counts_as, отправив тело без них (code этим запросом
        не изменяется); запрос жёстко исключает глобальные built-in статусы (workspace_id
        IS NULL) и статусы других воркспейсов через WHERE id=$1 AND workspace_id=$2.
    delete:
      operationId: delete_conversion_statuses_id
      summary: Удаляет кастомный статус конверсии текущего воркспейса.
      tags:
      - Статусы конверсий
      x-permission: delete:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid статуса, обязателен, 400 если невалиден
      responses:
        '200':
          description: успешно удалено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный id / ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: статус не найден в этом воркспейсе (включая попытку удалить
            глобальный built-in)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Пишется запись в audit log (delete/status).
  "/postback-url":
    get:
      operationId: get_postback_url
      summary: Отдаёт URL входящего постбэка текущего workspace — тот, что вставляется
        в настройки CPA-сети. Ключ в пути аутентифицирует каждую входящую конверсию.
      tags:
      - Входящий постбэк
      x-permission: view:settings
      responses:
        '200':
          description: успешно
          content:
            application/json:
              schema:
                type: object
                properties:
                  postback_key:
                    type: string
                    description: секрет workspace; выдаётся только этой ручкой
                  url:
                    type: string
                    description: базовый URL постбэка
                  example:
                    type: string
                    description: url с плейсхолдерами параметров
              example:
                postback_key: 9f2b1c7d4e6a4f0b8c1d2e3f4a5b6c7d
                url: https://tracker.example.com/9f2b1c7d4e6a4f0b8c1d2e3f4a5b6c7d/postback
                example: https://tracker.example.com/9f2b1c7d4e6a4f0b8c1d2e3f4a5b6c7d/postback?subid={subid}&status={status}&payout={payout}&currency={currency}&txid={txid}
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Запрос на /postback без валидного ключа (или с ключом чужого workspace)
        отклоняется с 401 "Incorrect postback key". В postback_log сегмент с ключом
        маскируется как /***/postback. Ротация ключа — только через SQL.
  "/postback-dlq":
    get:
      operationId: get_postback_dlq
      summary: Возвращает список постбэков из dead-letter очереди (постбэки трафик-сорсам,
        исчерпавшие попытки доставки), отсортированный по дате создания.
      tags:
      - DLQ постбеков
      x-permission: view:settings
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) макс. число строк, по умолчанию 100, максимум 1000;
          при невалидном/отрицательном значении тихо используется дефолт"
      - name: unresolved
        in: query
        required: false
        schema:
          type: string
        description: (string) если равно "true" — вернуть только записи с resolved_at
          IS NULL
      responses:
        '200':
          description: успешно, массив записей (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    campaign_id:
                      type: string
                      description: кампания, к которой относится постбэк
                    click_id:
                      type: string
                      description: ''
                    url_template:
                      type: string
                      description: шаблон URL постбэка
                    status:
                      type: string
                      description: статус доставки
                    payout:
                      type: number
                      description: ''
                    currency:
                      type: string
                      description: ''
                    transaction_id:
                      type: string
                      description: внутреннее поле tx_id
                    attempts:
                      type: number
                      description: число попыток доставки
                    last_error:
                      type: string
                      description: ''
                    next_try_at:
                      type: string
                      description: время следующей попытки
                    resolved_at:
                      type: string
                      description: время разрешения, null пока не решено
                    created_at:
                      type: string
                      description: ''
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                campaign_id: 1b6f1c1a-2222-4444-8888-aaaaaaaaaaaa
                click_id: clk_abc123
                url_template: https://tracker.example.com/pb?payout={payout}
                status: failed
                payout: 12.5
                currency: USD
                transaction_id: tx_9981
                attempts: 5
                last_error: connection timeout
                next_try_at: '2026-07-28T12:00:00Z'
                resolved_at: 
                created_at: '2026-07-28T09:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Таблица postback_dlq не имеет собственного workspace_id — изоляция
        по тенанту обеспечивается JOIN на campaigns и фильтром c.workspace_id=$1,
        что является защитой от IDOR.
  "/postback-dlq/{id}/replay":
    post:
      operationId: post_postback_dlq_id_replay
      summary: Повторно отправляет один постбэк из dead-letter очереди.
      tags:
      - DLQ постбеков
      x-permission: edit:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid записи DLQ, обязателен, 400 если невалиден
      responses:
        '200':
          description: успешно поставлено на повторную отправку
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный id / ошибка Sender.ReplayByID
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: запись не найдена или принадлежит кампании из другого воркспейса
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Перед реплеем выполняется явная проверка владения через EXISTS(...
        JOIN campaigns ... c.workspace_id=$2), чтобы исключить IDOR; пишется audit
        log (replay/postback_dlq).
  "/postback-dlq/replay-all":
    post:
      operationId: post_postback_dlq_replay_all
      summary: Массово повторно отправляет до 1000 постбэков из dead-letter очереди
        текущего воркспейса.
      tags:
      - DLQ постбеков
      x-permission: edit:settings
      responses:
        '200':
          description: успешно, возвращено число реплеенных записей
          content:
            application/json:
              schema:
                type: object
                properties:
                  replayed:
                    type: number
                    description: количество поставленных на повторную отправку записей
              example:
                replayed: 42
        '500':
          description: ошибка Sender.ReplayDLQForWorkspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Жёстко ограничено 1000 записями за один вызов (захардкожено в коде,
        не параметр запроса); пишется audit log (replay_all/postback_dlq) с count.
  "/conv-forward-dlq":
    get:
      operationId: get_conv_forward_dlq
      summary: Возвращает список server-side пересылок конверсий (CAPI/Events API),
        исчерпавших попытки доставки, из dead-letter очереди.
      tags:
      - DLQ форвардинга конверсий
      x-permission: view:settings
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) макс. число строк, по умолчанию 100, максимум 1000"
      - name: unresolved
        in: query
        required: false
        schema:
          type: string
        description: (string) если равно "true" — вернуть только записи с resolved_at
          IS NULL
      responses:
        '200':
          description: успешно, массив записей (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    conversion_id:
                      type: string
                      description: может быть null
                    click_id:
                      type: string
                      description: ''
                    attempts:
                      type: number
                      description: ''
                    last_error:
                      type: string
                      description: ''
                    resolved_at:
                      type: string
                      description: null пока не решено
                    created_at:
                      type: string
                      description: ''
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                conversion_id: 7c9e6679-7425-40de-944b-e07fc1f90ae7
                click_id: clk_abc123
                attempts: 3
                last_error: HTTP 500 from FB CAPI
                resolved_at: 
                created_at: '2026-07-28T09:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: В отличие от postback_dlq, таблица conv_forward_dlq имеет собственную
        колонку workspace_id — фильтрация прямая, без JOIN.
  "/conv-forward-dlq/{id}/replay":
    post:
      operationId: post_conv_forward_dlq_id_replay
      summary: Повторно отправляет одну запись из dead-letter очереди пересылки конверсий.
      tags:
      - DLQ форвардинга конверсий
      x-permission: edit:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid записи DLQ, обязателен, 400 если невалиден
      responses:
        '200':
          description: успешно поставлено на повторную отправку
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный id / ошибка Manager.ReplayByID
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: запись не найдена или принадлежит другому воркспейсу
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Явная проверка владения WHERE id=$1 AND workspace_id=$2 перед реплеем,
        защита от IDOR; пишется audit log (replay/conv_forward_dlq).
  "/conv-forward-dlq/replay-all":
    post:
      operationId: post_conv_forward_dlq_replay_all
      summary: Массово повторно отправляет до 1000 записей из dead-letter очереди
        пересылки конверсий текущего воркспейса.
      tags:
      - DLQ форвардинга конверсий
      x-permission: edit:settings
      responses:
        '200':
          description: успешно
          content:
            application/json:
              schema:
                type: object
                properties:
                  replayed:
                    type: number
                    description: количество поставленных на повторную отправку записей
              example:
                replayed: 17
        '500':
          description: ошибка Manager.ReplayDLQForWorkspace
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Жёстко ограничено 1000 записями за вызов; пишется audit log (replay_all/conv_forward_dlq)
        с count.
  "/webhooks":
    get:
      operationId: get_webhooks
      summary: Возвращает список исходящих вебхуков текущего воркспейса. Секрет подписи
        никогда не возвращается — вместо него флаг has_secret.
      tags:
      - Вебхуки
      x-permission: view:settings
      responses:
        '200':
          description: успешно, массив вебхуков (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    name:
                      type: string
                      description: ''
                    url:
                      type: string
                      description: ''
                    has_secret:
                      type: boolean
                      description: "true, если секрет задан; сам секрет никогда не
                        возвращается (input-only)"
                    events:
                      type: array
                      description: список событий-триггеров
                    enabled:
                      type: boolean
                      description: ''
                    last_delivery_at:
                      type: string
                      description: omitempty
                    last_status:
                      type: number
                      description: код ответа последней доставки
                    last_error:
                      type: string
                      description: ''
                    created_at:
                      type: string
                      description: ''
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                name: Slack alerts
                url: https://hooks.example.com/abc
                has_secret: true
                events:
                - conversion.created
                enabled: true
                last_delivery_at: '2026-07-28T08:00:00Z'
                last_status: 200
                last_error: ''
                created_at: '2026-06-01T00:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Как и в /integrations, секрет маскируется флагом has_secret и никогда
        не отдаётся наружу — для этого эндпоинта достаточно view:settings, и UI нужно
        лишь знать, задан ли секрет.
    post:
      operationId: post_webhooks
      summary: Создаёт новый исходящий вебхук для текущего воркспейса.
      tags:
      - Вебхуки
      x-permission: edit:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) обязательно, иначе 400"
                url:
                  type: string
                  description: "(string) обязательно, иначе 400"
                secret:
                  type: string
                  description: "(string) секрет подписи, шифруется перед сохранением
                    если задан"
                events:
                  type: array
                  description: (array) список событий; если пусто — подставляется
                    ["conversion.created"]
                enabled:
                  type: boolean
                  description: "(boolean) по умолчанию false (Go zero value), если
                    не передано"
              required:
              - name
              - url
            example:
              name: Slack alerts
              url: https://hooks.example.com/abc
              secret: whsec_live_9f8a
              events:
              - conversion.created
              - rule.fired
              enabled: true
      responses:
        '201':
          description: вебхук успешно создан
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: ''
                  name:
                    type: string
                    description: ''
                  url:
                    type: string
                    description: ''
                  has_secret:
                    type: boolean
                    description: "true, если секрет был передан в запросе; сам секрет
                      не возвращается"
                  events:
                    type: array
                    description: ''
                  enabled:
                    type: boolean
                    description: ''
                  last_delivery_at:
                    type: string
                    description: omitempty, отсутствует у новой записи
                  last_status:
                    type: number
                    description: 0 у новой записи
                  last_error:
                    type: string
                    description: ''
                  created_at:
                    type: string
                    description: ''
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                name: Slack alerts
                url: https://hooks.example.com/abc
                has_secret: true
                events:
                - conversion.created
                - rule.fired
                enabled: true
                last_status: 0
                last_error: ''
                created_at: '2026-07-28T10:00:00Z'
        '400':
          description: невалидный JSON / неизвестное поле / отсутствуют name или url
            / ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка шифрования секрета (Cipher.Encrypt)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Секрет шифруется через Cipher перед сохранением в БД; пишется audit
        log (create/webhook) с name и url.
  "/webhooks/{id}":
    put:
      operationId: put_webhooks_id
      summary: Обновляет параметры существующего вебхука (имя, URL, секрет, события,
        включённость).
      tags:
      - Вебхуки
      x-permission: edit:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid вебхука, обязателен, 400 если невалиден
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) частичное обновление: отсутствует — не меняется;
                    пустая строка — 400"
                url:
                  type: string
                  description: "(string) частичное обновление: отсутствует — не меняется;
                    пустая строка — 400"
                secret:
                  type: string
                  description: "(string) отсутствует или пусто — секрет не меняется;
                    если значение уже зашифровано (crypt.IsEncrypted), повторно не
                    шифруется"
                events:
                  type: array
                  description: "(array) если передан — полностью заменяет список событий;
                    отсутствует — не меняется"
                enabled:
                  type: boolean
                  description: "(boolean) отсутствует — не меняется"
            example:
              name: Slack alerts
              url: https://hooks.example.com/abc2
              secret: whsec_live_9f8a
              events:
              - conversion.created
              enabled: false
      responses:
        '200':
          description: успешно обновлено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный id / невалидный JSON / неизвестное поле / переданы
            пустые name или url / ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: вебхук не найден в этом воркспейсе
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка шифрования секрета (Cipher.Encrypt)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: "Частичное обновление через COALESCE: отсутствующее в теле поле
        не трогает колонку, поэтому тумблер enable/disable шлёт только {\"enabled\":
        bool}. Переданные пустые name/url отклоняются с 400; пустой или отсутствующий
        secret означает «не менять». Пишется audit log (update/webhook) с name, url,
        events и enabled — секрет в audit log не попадает."
    delete:
      operationId: delete_webhooks_id
      summary: Удаляет вебхук текущего воркспейса.
      tags:
      - Вебхуки
      x-permission: delete:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid вебхука, обязателен, 400 если невалиден
      responses:
        '200':
          description: успешно удалено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный id / ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: вебхук не найден в этом воркспейсе
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Пишется audit log (delete/webhook).
  "/webhooks/deliveries":
    get:
      operationId: get_webhooks_deliveries
      summary: Возвращает журнал попыток доставки вебхуков (успешных и неуспешных)
        для текущего воркспейса.
      tags:
      - Вебхуки
      x-permission: view:settings
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) макс. число строк, по умолчанию 100, максимум 1000"
      - name: webhook_id
        in: query
        required: false
        schema:
          type: string
        description: "(uuid) фильтр по конкретному вебхуку; если передан и не парсится
          как uuid — 400"
      responses:
        '200':
          description: успешно, массив доставок (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    webhook_id:
                      type: string
                      description: ''
                    event:
                      type: string
                      description: ''
                    url:
                      type: string
                      description: URL, на который была доставка
                    status_code:
                      type: number
                      description: HTTP код ответа получателя
                    request_body:
                      type: string
                      description: тело отправленного запроса
                    response_body:
                      type: string
                      description: тело ответа получателя
                    attempt:
                      type: number
                      description: номер попытки
                    error:
                      type: string
                      description: ''
                    created_at:
                      type: string
                      description: ''
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                webhook_id: 1b6f1c1a-2222-4444-8888-aaaaaaaaaaaa
                event: conversion.created
                url: https://hooks.example.com/abc
                status_code: 200
                request_body: '{"click_id":"clk_1"}'
                response_body: OK
                attempt: 1
                error: ''
                created_at: '2026-07-28T08:00:00Z'
        '400':
          description: невалидный webhook_id
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Хранит полные тела запроса и ответа для отладки доставки — потенциально
        чувствительные данные, если в payload есть PII.
  "/integrations":
    get:
      operationId: get_integrations
      summary: Возвращает список подключённых интеграций текущего воркспейса без раскрытия
        секретов конфигурации.
      tags:
      - Интеграции
      x-permission: view:settings
      responses:
        '200':
          description: успешно, массив интеграций (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: ''
                    provider:
                      type: string
                      description: например cloudflare, fb_capi, telegram
                    name:
                      type: string
                      description: имя инстанса интеграции (по умолчанию "default")
                    enabled:
                      type: boolean
                      description: ''
                    config:
                      type: object
                      description: omitempty — фактически всегда отсутствует в этом
                        эндпоинте (не заполняется в List)
                    has_secret:
                      type: boolean
                      description: true если сохранённый config не пуст
                    last_check_at:
                      type: string
                      description: omitempty
                    last_check_ok:
                      type: boolean
                      description: omitempty, указатель, может отсутствовать
                    last_check_error:
                      type: string
                      description: omitempty
                    created_at:
                      type: string
                      description: ''
                    updated_at:
                      type: string
                      description: ''
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                provider: cloudflare
                name: default
                enabled: true
                has_secret: true
                last_check_at: '2026-07-28T09:00:00Z'
                last_check_ok: true
                created_at: '2026-06-01T00:00:00Z'
                updated_at: '2026-07-28T09:00:00Z'
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Поле config никогда не попадает в ответ этого хендлера: колонка
        читается из БД только в локальную переменную, чтобы вычислить has_secret,
        и в DTO не присваивается — секрет полностью скрыт, наружу отдаётся только
        флаг has_secret.'
    post:
      operationId: post_integrations
      summary: Создаёт или обновляет конфигурацию интеграции провайдера (например,
        Cloudflare, Facebook CAPI, Telegram) для текущего воркспейса.
      tags:
      - Интеграции
      x-permission: edit:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                provider:
                  type: string
                  description: "(string) должен входить в список известных провайдеров
                    (cloudflare, namecheap, fb_ads, google_ads, tiktok_ads, yandex_direct,
                    dolphin, fb_capi, google_ec, tiktok_events, telegram), иначе 400"
                name:
                  type: string
                  description: (string) имя инстанса; если пусто — подставляется "default"
                enabled:
                  type: boolean
                  description: "(boolean)"
                config:
                  type: object
                  description: (object) произвольная конфигурация, зависящая от provider
                    (например {"token":"..."} для cloudflare); шифруется перед сохранением
              required:
              - provider
            example:
              provider: cloudflare
              name: default
              enabled: true
              config:
                token: cf_abc123secret
      responses:
        '200':
          description: успешно создано или обновлено (даже для создания статус 200,
            не 201)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: id созданной или обновлённой записи интеграции
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
        '400':
          description: невалидный JSON / неизвестный provider / ошибка INSERT..ON
            CONFLICT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка шифрования config (Cipher.Encrypt) / cipher недоступен
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'Это upsert по уникальному ключу (workspace_id, provider, name):
        если config в запросе пуст, существующий сохранённый config НЕ затирается
        (CASE WHEN EXCLUDED.config='''' THEN integrations.config ELSE EXCLUDED.config),
        что позволяет включать/выключать интеграцию без повторной отправки секрета;
        ответ не соответствует общей конвенции "создание возвращает полный объект"
        — возвращается только {id}; audit log пишется опционально (если h.Audit !=
        nil).'
  "/integrations/{id}":
    delete:
      operationId: delete_integrations_id
      summary: Удаляет интеграцию текущего воркспейса.
      tags:
      - Интеграции
      x-permission: delete:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid интеграции, обязателен, 400 если невалиден
      responses:
        '200':
          description: успешно удалено
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный id / ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: интеграция не найдена в этом воркспейсе ("integration not found")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: В отличие от большинства других мутирующих хендлеров, здесь НЕ
        пишется запись в audit log.
  "/integrations/cloudflare/test":
    post:
      operationId: post_integrations_cloudflare_test
      summary: Проверяет валидность сохранённого Cloudflare API-токена пробным запросом
        списка зон (GET /zones?per_page=1 — сознательно не /user/tokens/verify, который
        отвергает account-owned токены и не проверяет права Zone:Read) и сохраняет
        результат проверки.
      tags:
      - Интеграции
      x-permission: edit:settings
      responses:
        '200':
          description: токен валиден
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    description: true при успешной проверке токена
              example:
                ok: true
        '400':
          description: интеграция cloudflare не настроена или выключена ("integration
            disabled — saving a token re-enables it") / токен пуст / проверка Cloudflare
            не прошла (тело ответа = текст ошибки CF; если токен похож на Global API
            Key, Token ID или содержит пробелы, к тексту добавляется подсказка о формате
            — она же попадает в last_check_error)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Использует ранее сохранённую интеграцию provider='cloudflare',
        name='default' (тело запроса не читается); в любом случае (успех/неуспех)
        обновляет last_check_at/last_check_ok/last_check_error в БД; таймаут запроса
        к Cloudflare 10 секунд; audit log не пишется.
  "/integrations/cloudflare/zones":
    get:
      operationId: get_integrations_cloudflare_zones
      summary: Возвращает список зон Cloudflare, доступных сохранённому API-токену.
      tags:
      - Интеграции
      x-permission: view:settings
      responses:
        '200':
          description: успешно, массив зон
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: id зоны в Cloudflare
                    name:
                      type: string
                      description: доменное имя зоны
                    status:
                      type: string
                      description: статус зоны в Cloudflare
                    plan_name:
                      type: string
                      description: omitempty, название тарифного плана
                    name_servers:
                      type: array
                      description: omitempty, NS-серверы
              example:
              - id: 023e105f4ecef8ad9ca31a8372d0c353
                name: example.com
                status: active
                plan_name: Free Website
                name_servers:
                - ns1.cloudflare.com
                - ns2.cloudflare.com
        '400':
          description: интеграция cloudflare не настроена или выключена ("integration
            disabled — saving a token re-enables it") / токен пуст
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '502':
          description: ошибка обращения к Cloudflare API
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Список зон от Cloudflare ограничен первыми 200 (пагинация в cloudflare.Client.Zones
        не расширяется); таймаут запроса 15 секунд.
  "/domains/{id}/cloudflare":
    get:
      operationId: get_domains_id_cloudflare
      summary: Возвращает текущее состояние синхронизации домена с Cloudflare (zone_id,
        proxied) и готовность интеграции.
      tags:
      - Домены
      x-permission: view:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid домена, обязателен, 400 если невалиден
      responses:
        '200':
          description: успешно
          content:
            application/json:
              schema:
                type: object
                properties:
                  host:
                    type: string
                    description: хост домена
                  zone_id:
                    type: string
                    description: id зоны Cloudflare, пусто если не синхронизирован
                  proxied:
                    type: boolean
                    description: включён ли Cloudflare proxy (orange cloud)
                  integration_ready:
                    type: boolean
                    description: настроена ли и включена интеграция cloudflare с непустым
                      config
                  synced:
                    type: boolean
                    description: вычисляется как zone_id != ""
              example:
                host: track.example.com
                zone_id: 023e105f4ecef8ad9ca31a8372d0c353
                proxied: true
                integration_ready: true
                synced: true
        '400':
          description: невалидный id
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден в этом воркспейсе ("domain not found")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Домен скоупится через WHERE id=$1 AND workspace_id=$2 (защита от
        IDOR); проверка integration_ready дешёвая — не расшифровывает config, лишь
        смотрит, что строка непуста.
  "/domains/{id}/cloudflare/sync":
    post:
      operationId: post_domains_id_cloudflare_sync
      summary: Создаёт или обновляет A-запись в Cloudflare для домена, указывающую
        на IP трекера, и сохраняет zone_id/proxied в БД.
      tags:
      - Домены
      x-permission: edit:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid домена, обязателен, 400 если невалиден
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                zone_id:
                  type: string
                  description: "(string) id зоны Cloudflare, обязателен"
                ip:
                  type: string
                  description: "(string) IP-адрес трекера, обязателен"
                proxied:
                  type: boolean
                  description: "(boolean) по умолчанию true, если не передано (nullable
                    указатель, дефолт применяется в коде)"
              required:
              - zone_id
              - ip
            example:
              zone_id: 023e105f4ecef8ad9ca31a8372d0c353
              ip: 203.0.113.10
              proxied: true
      responses:
        '200':
          description: успешно синхронизировано
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: id DNS-записи в Cloudflare
                  type:
                    type: string
                    description: тип записи, "A"
                  name:
                    type: string
                    description: хост
                  content:
                    type: string
                    description: IP-адрес
                  proxied:
                    type: boolean
                    description: ''
                  ttl:
                    type: number
                    description: ''
              example:
                id: 372e67954025e0ba6aaa6d586b9e0b59
                type: A
                name: track.example.com
                content: 203.0.113.10
                proxied: true
                ttl: 1
        '400':
          description: невалидный id / невалидный JSON / отсутствуют zone_id или ip
            / интеграция cloudflare не настроена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден в этом воркспейсе
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '502':
          description: ошибка Cloudflare API при создании/обновлении записи
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Идемпотентно (ищет существующую A-запись перед созданием); после
        успеха обновляет domains.cloudflare_zone_id, cloudflare_proxied и переводит
        ssl_status в 'pending'; пишет audit log (cloudflare_sync/domain), если h.Audit
        задан; таймаут запроса 20 секунд.
  "/domains/{id}/cloudflare/unsync":
    post:
      operationId: post_domains_id_cloudflare_unsync
      summary: Удаляет A-запись домена из Cloudflare и очищает связанные поля синхронизации
        в БД.
      tags:
      - Домены
      x-permission: edit:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid домена, обязателен, 400 если невалиден
      responses:
        '200':
          description: успешно отвязано (или уже не было привязано — "not synced")
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok" или "not synced", если домен не был синхронизирован'
              example:
                status: ok
        '400':
          description: невалидный id / интеграция cloudflare не настроена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден в этом воркспейсе
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '502':
          description: ошибка Cloudflare API при удалении записи
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Если домен ещё не синхронизирован (cloudflare_zone_id пуст), к
        Cloudflare API не обращается и сразу возвращает {"status":"not synced"}; при
        успехе обнуляет cloudflare_zone_id и cloudflare_proxied; пишет audit log (cloudflare_unsync/domain),
        если h.Audit задан.
  "/domains/{id}/cloudflare/purge":
    post:
      operationId: post_domains_id_cloudflare_purge
      summary: Очищает кэш Cloudflare для зоны, к которой привязан домен.
      tags:
      - Домены
      x-permission: edit:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid домена, обязателен, 400 если невалиден
      responses:
        '200':
          description: кэш успешно очищен
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный id / домен не синхронизирован с Cloudflare / интеграция
            cloudflare не настроена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден в этом воркспейсе
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '502':
          description: ошибка Cloudflare API при очистке кэша
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Требует, чтобы домен уже был синхронизирован (cloudflare_zone_id
        непуст), иначе 400; пишет audit log (cloudflare_purge/domain), если h.Audit
        задан; таймаут 15 секунд.
  "/domains/{id}/cloudflare/proxy":
    post:
      operationId: post_domains_id_cloudflare_proxy
      summary: Включает или выключает Cloudflare-проксирование (orange cloud) для
        уже синхронизированной A-записи домена.
      tags:
      - Домены
      x-permission: edit:domains
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid домена, обязателен, 400 если невалиден
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                proxied:
                  type: boolean
                  description: "(boolean) целевое состояние проксирования; false —
                    валидное значение по умолчанию, если не передано"
                ip:
                  type: string
                  description: "(string) текущий IP-адрес, обязателен для пересоздания
                    записи"
              required:
              - ip
            example:
              proxied: false
              ip: 203.0.113.10
      responses:
        '200':
          description: состояние проксирования успешно изменено
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: id DNS-записи в Cloudflare
                  type:
                    type: string
                    description: '"A"'
                  name:
                    type: string
                    description: хост
                  content:
                    type: string
                    description: IP-адрес
                  proxied:
                    type: boolean
                    description: ''
                  ttl:
                    type: number
                    description: ''
              example:
                id: 372e67954025e0ba6aaa6d586b9e0b59
                type: A
                name: track.example.com
                content: 203.0.113.10
                proxied: false
                ttl: 1
        '400':
          description: невалидный id / невалидный JSON / отсутствует ip / домен не
            синхронизирован с Cloudflare / интеграция cloudflare не настроена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: домен не найден в этом воркспейсе
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '502':
          description: ошибка Cloudflare API
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Требует, чтобы домен уже был синхронизирован (cloudflare_zone_id
        непуст), иначе 400; после успеха обновляет только cloudflare_proxied в БД;
        пишет audit log (cloudflare_proxy/domain), если h.Audit задан.
  "/fx/base-currency":
    get:
      operationId: get_fx_base_currency
      summary: Возвращает базовую валюту воркспейса и текущий процент сэмплирования
        трафик-логов.
      tags:
      - Мультивалютность (FX)
      x-permission: view:settings
      responses:
        '200':
          description: всегда 200 — ошибка чтения из БД проглатывается
          content:
            application/json:
              schema:
                type: object
                properties:
                  base_currency:
                    type: string
                    description: ISO-код валюты, по умолчанию USD
                  traffic_log_sample:
                    type: number
                    description: процент трафика, логируемого в click_trace, 0..100
              example:
                base_currency: USD
                traffic_log_sample: 100
      description: Ошибка запроса к БД игнорируется (`_ = ...Scan`); при отсутствии
        строки воркспейса переменные остаются нулевыми на уровне Go, поэтому base_currency
        откатывается на "USD", а traffic_log_sample будет 0. SQL-COALESCE в запросе
        тоже подставляет 0 (не 100) — со времён миграции 0002 сэмплирование по умолчанию
        выключено.
    put:
      operationId: put_fx_base_currency
      summary: Устанавливает базовую валюту воркспейса, используемую для нормализации
        новых кликов и конверсий.
      tags:
      - Мультивалютность (FX)
      x-permission: admin:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                base_currency:
                  type: string
                  description: "(string) ISO-код валюты длиной 3-4 символа, обязателен"
              required:
              - base_currency
            example:
              base_currency: EUR
      responses:
        '200':
          description: успешно изменено
          content:
            application/json:
              schema:
                type: object
                properties:
                  base_currency:
                    type: string
                    description: нормализованный (uppercase, trimmed) код валюты
              example:
                base_currency: EUR
        '400':
          description: невалидный JSON / код валюты короче 3 или длиннее 4 символов
            / ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Не пересчитывает исторические значения cost/currency и revenue/currency
        — влияет только на нормализацию новых событий; сразу вызывает FX.Reload для
        применения без ожидания периодического обновления.
  "/fx/traffic-sample":
    put:
      operationId: put_fx_traffic_sample
      summary: Устанавливает процент сэмплирования трафик-логов (click_trace) для
        воркспейса.
      tags:
      - Мультивалютность (FX)
      x-permission: admin:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                traffic_log_sample:
                  type: number
                  description: "(number) целое число 0..100, обязателен диапазон"
              required:
              - traffic_log_sample
            example:
              traffic_log_sample: 50
      responses:
        '200':
          description: успешно изменено
          content:
            application/json:
              schema:
                type: object
                properties:
                  traffic_log_sample:
                    type: number
                    description: ''
              example:
                traffic_log_sample: 50
        '400':
          description: невалидный JSON / значение вне диапазона 0..100 / ошибка UPDATE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Используется для снижения нагрузки на высоконагруженных инсталляциях
        за счёт частичного логирования трафика; вызывает FX.Reload после изменения.
  "/fx/rates":
    get:
      operationId: get_fx_rates
      summary: Возвращает таблицу курсов обмена валют воркспейса, отсортированную
        по дате (новые сначала).
      tags:
      - Мультивалютность (FX)
      x-permission: view:reports
      responses:
        '200':
          description: успешно, массив курсов (может быть пустым)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: идентификатор записи курса
                    from_currency:
                      type: string
                      description: ''
                    to_currency:
                      type: string
                      description: ''
                    rate:
                      type: number
                      description: ''
                    rate_date:
                      type: string
                      description: формат YYYY-MM-DD
                    source:
                      type: string
                      description: например "manual" или имя провайдера
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                from_currency: EUR
                to_currency: USD
                rate: 1.08
                rate_date: '2026-07-27'
                source: manual
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Единственный из перечисленных fx-эндпоинтов с permission view:reports
        вместо *:settings — доступен ролям с правом только просмотра отчётов; без
        пагинации, отдаёт всю таблицу курсов воркспейса целиком.
    put:
      operationId: put_fx_rates
      summary: Создаёт или перезаписывает вручную заданный курс обмена валют на конкретную
        дату.
      tags:
      - Мультивалютность (FX)
      x-permission: edit:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                from_currency:
                  type: string
                  description: "(string) ISO-код, минимум 3 символа, обязателен"
                to_currency:
                  type: string
                  description: "(string) ISO-код, минимум 3 символа, обязателен, должен
                    отличаться от from_currency"
                rate:
                  type: number
                  description: "(number) должен быть > 0"
                rate_date:
                  type: string
                  description: "(string) формат YYYY-MM-DD; если пусто — берётся текущая
                    дата UTC"
                source:
                  type: string
                  description: (string) если пусто — подставляется "manual"
              required:
              - from_currency
              - to_currency
              - rate
            example:
              from_currency: EUR
              to_currency: USD
              rate: 1.08
              rate_date: '2026-07-27'
              source: manual
      responses:
        '200':
          description: курс успешно сохранён
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный JSON / from_currency или to_currency короче 3 символов
            / rate <= 0 / from_currency == to_currency / ошибка INSERT
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: ON CONFLICT (workspace_id, from_currency, to_currency, rate_date)
        перезаписывает существующую запись за тот же день той же валютной пары; вызывает
        FX.Reload сразу после записи.
  "/fx/rates/refresh":
    post:
      operationId: post_fx_rates_refresh
      summary: Принудительно подтягивает актуальные рыночные курсы валют у внешнего
        провайдера и перезагружает кэш курсов.
      tags:
      - Мультивалютность (FX)
      x-permission: edit:settings
      responses:
        '200':
          description: курсы успешно обновлены
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '502':
          description: ошибка обращения к внешнему провайдеру курсов
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Обновляет курсы для всех используемых в системе базовых валют воркспейсов
        (не только текущего), затем перезагружает in-memory кэш fx.Store.
  "/fx/rates/{id}":
    delete:
      operationId: delete_fx_rates_id
      summary: Удаляет одну запись курса обмена валют.
      tags:
      - Мультивалютность (FX)
      x-permission: delete:settings
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid записи курса, обязателен, 400 если невалиден
      responses:
        '200':
          description: запрос выполнен успешно (даже если ничего не было удалено)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: '"ok"'
              example:
                status: ok
        '400':
          description: невалидный id / ошибка DELETE
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: В отличие от большинства других DELETE-хендлеров в этом наборе,
        здесь НЕ проверяется RowsAffected — при несуществующем id или id из чужого
        воркспейса всё равно возвращается 200 "ok", а не 404; вызывает FX.Reload после
        удаления.
  "/rules":
    get:
      operationId: get_rules
      summary: Возвращает список всех автоправил (rules) текущего воркспейса, отсортированных
        по дате создания (новые первыми).
      tags:
      - Автоправила
      x-permission: view:rules
      responses:
        '200':
          description: OK — список правил (возможно пустой массив)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: string
                      description: идентификатор правила
                    workspace_id:
                      type: string
                      description: воркспейс-владелец
                    name:
                      type: string
                      description: название правила
                    enabled:
                      type: boolean
                      description: включено ли правило
                    dry_run:
                      type: boolean
                      description: режим тестового прогона без реальных действий
                    scope:
                      type: object
                      description: 'область действия (kind: campaign|flow|offer, campaign_ids/group/tags/all)'
                    trigger:
                      type: object
                      description: триггер запуска (type, interval_sec)
                    metric_window:
                      type: object
                      description: окно агрегации метрик (preset)
                    condition:
                      type: object
                      description: дерево условий (RuleNode-подобная структура)
                    guards:
                      type: object
                      description: статистические и rate-limit защиты
                    actions:
                      type: array
                      description: список действий при срабатывании
                    recover:
                      type: object
                      description: условие и действия восстановления (опционально)
                    last_fired_at:
                      type: string
                      description: время последнего срабатывания; если срабатываний
                        не было — поле отсутствует в JSON (omitempty), null не возвращается
                    created_by:
                      type: string
                      description: автор правила
                    created_at:
                      type: string
                      description: дата создания
                    updated_at:
                      type: string
                      description: дата обновления
              example:
              - id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                workspace_id: 11111111-1111-1111-1111-111111111111
                name: ROI drop guard
                enabled: true
                dry_run: false
                scope:
                  kind: campaign
                  campaign_ids:
                  - 22222222-2222-2222-2222-222222222222
                trigger:
                  type: schedule
                  interval_sec: 300
                metric_window:
                  preset: last_3h
                condition:
                  kind: cond
                  metric: roi
                  op: lt
                  value: -30
                guards:
                  min_clicks: 100
                  cooldown_sec: 1800
                actions:
                - type: pause
                created_by: 33333333-3333-3333-3333-333333333333
                created_at: '2026-07-20T10:00:00Z'
                updated_at: '2026-07-20T10:00:00Z'
        '500':
          description: ошибка чтения из БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Список всегда скоупится по workspace_id из JWT через WHERE workspace_id=$1
        в SQL-запросе.
    post:
      operationId: post_rules
      summary: Создаёт новое автоправило в воркспейсе текущего пользователя.
      tags:
      - Автоправила
      x-permission: edit:rules
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) название правила (Validate возвращает 422,
                    если пусто)"
                enabled:
                  type: boolean
                  description: "(boolean) включено ли правило; по умолчанию false,
                    если не передано"
                dry_run:
                  type: boolean
                  description: "(boolean) по умолчанию true (сервер сидирует Rule{DryRun:true}
                    до декодирования тела) — новое правило не выполняет реальных действий,
                    пока явно не выключен dry_run"
                scope:
                  type: object
                  description: "(object) kind (campaign|flow|offer) обязателен; должен
                    быть выбран хотя бы один из campaign_ids/group/tags/all"
                trigger:
                  type: object
                  description: "(object) type по умолчанию 'schedule'; interval_sec
                    обязателен и должен быть >= 60"
                metric_window:
                  type: object
                  description: "(object) preset по умолчанию 'today'; допустимые значения:
                    today|yesterday|last_1h|last_3h|last_6h|last_24h|last_3d|last_7d"
                condition:
                  type: object
                  description: "(object) дерево условий (kind group|cond); должно
                    содержать минимум 1 и максимум 32 листа, глубина ≤ 6"
                guards:
                  type: object
                  description: "(object) min_clicks, min_conversions, confidence ('wilson_95'
                    — только для lt/lte/gt/gte на cr/approval_rate), cooldown_sec,
                    max_fires_per_day"
                actions:
                  type: array
                  description: (array) минимум одно действие; type должен быть pause|resume|notify|webhook;
                    для offer-скоупа pause/resume запрещены; notify без channels получает
                    ["inapp"] по умолчанию
                recover:
                  type: object
                  description: "(object) если задан — condition обязателен; consecutive
                    по умолчанию 2"
              required:
              - name
              - scope
              - trigger
              - condition
              - actions
            example:
              name: ROI drop guard
              enabled: true
              dry_run: false
              scope:
                kind: campaign
                campaign_ids:
                - 22222222-2222-2222-2222-222222222222
              trigger:
                type: schedule
                interval_sec: 300
              metric_window:
                preset: last_3h
              condition:
                kind: cond
                metric: roi
                op: lt
                value: -30
              guards:
                min_clicks: 100
                cooldown_sec: 1800
              actions:
              - type: pause
              - type: notify
                channels:
                - telegram
                message: ROI упал ниже -30% для {{name}}
      responses:
        '201':
          description: Created — правило создано
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: сгенерированный идентификатор
                  workspace_id:
                    type: string
                    description: воркспейс из JWT
                  name:
                    type: string
                    description: название
                  enabled:
                    type: boolean
                    description: включено
                  dry_run:
                    type: boolean
                    description: тестовый режим
                  scope:
                    type: object
                    description: область действия
                  trigger:
                    type: object
                    description: триггер
                  metric_window:
                    type: object
                    description: окно метрик
                  condition:
                    type: object
                    description: дерево условий
                  guards:
                    type: object
                    description: защиты
                  actions:
                    type: array
                    description: действия
                  recover:
                    type: object
                    description: восстановление
                  created_by:
                    type: string
                    description: id создателя из JWT
                  created_at:
                    type: string
                    description: проставлено сервером
                  updated_at:
                    type: string
                    description: проставлено сервером
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                workspace_id: 11111111-1111-1111-1111-111111111111
                name: ROI drop guard
                enabled: true
                dry_run: false
                scope:
                  kind: campaign
                  campaign_ids:
                  - 22222222-2222-2222-2222-222222222222
                trigger:
                  type: schedule
                  interval_sec: 300
                metric_window:
                  preset: last_3h
                condition:
                  kind: cond
                  metric: roi
                  op: lt
                  value: -30
                guards:
                  min_clicks: 100
                  cooldown_sec: 1800
                actions:
                - type: pause
                - type: notify
                  channels:
                  - telegram
                  message: ROI упал ниже -30% для {{name}}
                created_by: 33333333-3333-3333-3333-333333333333
                created_at: '2026-07-28T09:00:00Z'
                updated_at: '2026-07-28T09:00:00Z'
        '400':
          description: невалидный JSON или неизвестное поле (DisallowUnknownFields)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '422':
          description: ошибка бизнес-валидации Rule.Validate()
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка записи в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: После создания пишется запись в audit-лог (action=create, entity=rule);
        dry_run по умолчанию true, чтобы новое правило не начало действовать сразу
        после создания через сырой API-запрос.
  "/rules/{id}":
    get:
      operationId: get_rules_id
      summary: Возвращает одно автоправило по id.
      tags:
      - Автоправила
      x-permission: view:rules
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid правила; обязателен, при невалидном формате — 400 'bad id'
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: идентификатор
                  workspace_id:
                    type: string
                    description: воркспейс
                  name:
                    type: string
                    description: название
                  enabled:
                    type: boolean
                    description: включено
                  dry_run:
                    type: boolean
                    description: тестовый режим
                  scope:
                    type: object
                    description: область действия
                  trigger:
                    type: object
                    description: триггер
                  metric_window:
                    type: object
                    description: окно метрик
                  condition:
                    type: object
                    description: дерево условий
                  guards:
                    type: object
                    description: защиты
                  actions:
                    type: array
                    description: действия
                  recover:
                    type: object
                    description: восстановление
                  last_fired_at:
                    type: string
                    description: последнее срабатывание
                  created_by:
                    type: string
                    description: автор
                  created_at:
                    type: string
                    description: создано
                  updated_at:
                    type: string
                    description: обновлено
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                workspace_id: 11111111-1111-1111-1111-111111111111
                name: ROI drop guard
                enabled: true
                dry_run: false
                scope:
                  kind: campaign
                  campaign_ids:
                  - 22222222-2222-2222-2222-222222222222
                trigger:
                  type: schedule
                  interval_sec: 300
                metric_window:
                  preset: last_3h
                condition:
                  kind: cond
                  metric: roi
                  op: lt
                  value: -30
                guards:
                  min_clicks: 100
                  cooldown_sec: 1800
                actions:
                - type: pause
                last_fired_at: '2026-07-27T18:12:00Z'
                created_by: 33333333-3333-3333-3333-333333333333
                created_at: '2026-07-20T10:00:00Z'
                updated_at: '2026-07-20T10:00:00Z'
        '400':
          description: невалидный uuid в {id}
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: правило не найдено или принадлежит другому воркспейсу
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Скоуп по воркспейсу реализован через SQL WHERE id=$1 AND workspace_id=$2
        — доступ к чужому правилу невозможен (IDOR закрыт на уровне запроса).
    put:
      operationId: put_rules_id
      summary: Полностью обновляет автоправило по id.
      tags:
      - Автоправила
      x-permission: edit:rules
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid правила; обязателен, при невалидном формате — 400 'bad id'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) название; пусто → 422"
                enabled:
                  type: boolean
                  description: "(boolean) включено ли правило"
                dry_run:
                  type: boolean
                  description: "(boolean) тестовый режим (здесь НЕ сидируется в true
                    — если не передан, останется false)"
                scope:
                  type: object
                  description: "(object) kind + хотя бы один селектор целей"
                trigger:
                  type: object
                  description: "(object) interval_sec >= 60"
                metric_window:
                  type: object
                  description: "(object) preset, по умолчанию 'today'"
                condition:
                  type: object
                  description: "(object) дерево условий, минимум 1 лист"
                guards:
                  type: object
                  description: "(object) защиты"
                actions:
                  type: array
                  description: "(array) минимум одно действие"
                recover:
                  type: object
                  description: "(object) восстановление"
              required:
              - name
              - scope
              - trigger
              - condition
              - actions
            example:
              name: ROI drop guard v2
              enabled: true
              dry_run: false
              scope:
                kind: campaign
                campaign_ids:
                - 22222222-2222-2222-2222-222222222222
              trigger:
                type: schedule
                interval_sec: 600
              metric_window:
                preset: last_6h
              condition:
                kind: cond
                metric: roi
                op: lt
                value: -25
              guards:
                min_clicks: 150
                cooldown_sec: 3600
              actions:
              - type: pause
      responses:
        '200':
          description: OK — обновлено
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: id из пути
                  workspace_id:
                    type: string
                    description: из JWT
                  name:
                    type: string
                    description: название
                  enabled:
                    type: boolean
                    description: включено
                  dry_run:
                    type: boolean
                    description: тестовый режим
                  scope:
                    type: object
                    description: область
                  trigger:
                    type: object
                    description: триггер
                  metric_window:
                    type: object
                    description: окно
                  condition:
                    type: object
                    description: условия
                  guards:
                    type: object
                    description: защиты
                  actions:
                    type: array
                    description: действия
                  recover:
                    type: object
                    description: восстановление
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                workspace_id: 11111111-1111-1111-1111-111111111111
                name: ROI drop guard v2
                enabled: true
                dry_run: false
                scope:
                  kind: campaign
                  campaign_ids:
                  - 22222222-2222-2222-2222-222222222222
                trigger:
                  type: schedule
                  interval_sec: 600
                metric_window:
                  preset: last_6h
                condition:
                  kind: cond
                  metric: roi
                  op: lt
                  value: -25
                guards:
                  min_clicks: 150
                  cooldown_sec: 3600
                actions:
                - type: pause
                created_at: '0001-01-01T00:00:00Z'
                updated_at: '0001-01-01T00:00:00Z'
        '400':
          description: невалидный uuid или невалидный/неизвестный JSON
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: правило не найдено в воркспейсе (RowsAffected==0 в UPDATE ...
            WHERE id=$1 AND workspace_id=$2)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '422':
          description: ошибка Rule.Validate()
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 'ВАЖНО: Store.Update не делает RETURNING, поэтому в ответе поля
        created_at/updated_at/created_by/last_fired_at — это то, что было в теле запроса
        (обычно нулевые значения), а НЕ актуальные значения из БД; клиенту не следует
        полагаться на них в ответе PUT. Пишется audit-лог (action=update).'
    delete:
      operationId: delete_rules_id
      summary: Удаляет автоправило по id.
      tags:
      - Автоправила
      x-permission: delete:rules
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid правила; обязателен, при невалидном формате — 400 'bad id'
      responses:
        '200':
          description: OK — удалено
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    description: true при успешном удалении
              example:
                ok: true
        '400':
          description: невалидный uuid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: правило не найдено (RowsAffected==0 в DELETE ... WHERE id=$1
            AND workspace_id=$2)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Ответ — {"ok":true}, а не общий конверт {"status":"ok"}; пишется
        audit-лог (action=delete, entity=rule).
  "/rules/{id}/toggle":
    post:
      operationId: post_rules_id_toggle
      summary: Переключает флаги enabled и/или dry_run у правила без пересохранения
        всего объекта.
      tags:
      - Автоправила
      x-permission: edit:rules
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid правила; обязателен, при невалидном формате — 400 'bad id'
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled:
                  type: boolean
                  description: "(boolean) новое значение enabled; если не передано
                    (null/отсутствует) — не изменяется"
                dry_run:
                  type: boolean
                  description: (boolean) новое значение dry_run; если не передано
                    — не изменяется. Переход true→false (постановка "на боевой") сбрасывает
                    cooldown-таймеры правила
            example:
              enabled: true
      responses:
        '200':
          description: OK — переключено
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    description: идентификатор
                  workspace_id:
                    type: string
                    description: воркспейс
                  enabled:
                    type: boolean
                    description: текущее значение после переключения
                  dry_run:
                    type: boolean
                    description: текущее значение после переключения
                  name:
                    type: string
                    description: название (не изменялось)
                  scope:
                    type: object
                    description: область действия
                  trigger:
                    type: object
                    description: триггер
                  metric_window:
                    type: object
                    description: окно метрик
                  condition:
                    type: object
                    description: условия
                  guards:
                    type: object
                    description: защиты
                  actions:
                    type: array
                    description: действия
                  recover:
                    type: object
                    description: восстановление (если задано)
                  last_fired_at:
                    type: string
                    description: последнее срабатывание (если было)
                  created_by:
                    type: string
                    description: автор (если задан)
                  created_at:
                    type: string
                    description: дата создания
                  updated_at:
                    type: string
                    description: старое значение до этого UPDATE (см. примечание)
              example:
                id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                workspace_id: 11111111-1111-1111-1111-111111111111
                name: ROI drop guard
                enabled: true
                dry_run: false
                scope:
                  kind: campaign
                  campaign_ids:
                  - 22222222-2222-2222-2222-222222222222
                trigger:
                  type: schedule
                  interval_sec: 300
                metric_window:
                  preset: last_3h
                condition:
                  kind: cond
                  metric: roi
                  op: lt
                  value: -30
                guards:
                  min_clicks: 100
                  cooldown_sec: 1800
                actions:
                - type: pause
                created_at: '2026-07-20T10:00:00Z'
                updated_at: '2026-07-20T10:00:00Z'
        '400':
          description: невалидный uuid или невалидный JSON тела
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: правило не найдено (из Store.Get)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка записи в БД (Store.Update)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Rule.Validate() здесь НЕ вызывается — проверяются только сами флаги;
        тело сначала читается через Store.Get, поэтому updated_at в ответе — старое
        значение (до этого UPDATE), а не свежее now(); пишется audit-лог (action=toggle)
        с полями enabled/dry_run.
  "/rules/{id}/test":
    post:
      operationId: post_rules_id_test
      summary: Выполняет сухой прогон (dry-evaluate) сохранённого правила прямо сейчас
        и возвращает результат по каждой цели, не выполняя реальных действий.
      tags:
      - Автоправила
      x-permission: view:rules
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid сохранённого правила; используется, только если тело запроса
          пустое или не содержит валидного черновика правила (поле name пусто)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                "(тело обычно не отправляется)":
                  type: object
                  description: "(object) если всё же передать полное тело правила
                    с непустым name, обработчик протестирует ЭТОТ черновик вместо
                    сохранённого правила по {id} — это особенность общего с /rules/test
                    обработчика"
      responses:
        '200':
          description: OK — массив предпросмотров (может быть пустым, если цели не
            резолвятся)
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    target:
                      type: object
                      description: 'цель проверки: kind, id, name, state, campaign_id'
                    metrics:
                      type: object
                      description: агрегированные метрики за окно (clicks, cr, roi,
                        epc, ...); если данных за окно нет — объект с нулевыми значениями,
                        а не null
                    fired:
                      type: boolean
                      description: сработало бы условие правила
                    trace:
                      type: array
                      description: пошаговый трейс вычисления условия
                    guards:
                      type: array
                      description: пошаговый трейс проверки guard-ограничений
              example:
              - target:
                  kind: campaign
                  id: 22222222-2222-2222-2222-222222222222
                  name: Facebook EU
                  state: active
                  campaign_id: 22222222-2222-2222-2222-222222222222
                metrics:
                  clicks: 420
                  unique_clicks: 390
                  conversions: 12
                  leads: 10
                  sales: 2
                  rejected: 1
                  revenue: 240
                  cost: 300
                  profit: -60
                  cr: 2.86
                  epc: 0.57
                  roi: -20
                  roas: 0.8
                  cpa: 25
                  approval_rate: 91.7
                fired: true
                trace:
                - node: cond
                  detail: roi(-20.0) < -30
                  pass: false
                  metric: roi
                  value: -20
                  against: -30
                guards:
                - node: guard
                  detail: min_clicks 420 >= 100
                  pass: true
        '400':
          description: невалидный uuid в {id} (когда тело не содержит черновика)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: правило с таким id не найдено
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '422':
          description: если в теле передан черновик правила и он не прошёл Rule.Validate()
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка движка при резолве целей/загрузке метрик
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Ничего не пишется в rule_runs и не выполняются реальные действия
        — это чистый предпросмотр; при null-результате движка (нет целей) API возвращает
        пустой массив [] вместо null.
  "/rules/test":
    post:
      operationId: post_rules_test
      summary: Тот же обработчик, что и POST /rules/{id}/test, но тестирует несохранённый
        черновик правила, переданный целиком в теле запроса (используется редактором
        до первого сохранения).
      tags:
      - Автоправила
      x-permission: view:rules
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                  description: "(string) название черновика; пустое имя приводит к
                    попытке трактовать запрос как id-based тест, а поскольку {id}
                    в этом маршруте нет — к 400 'bad id'"
                scope:
                  type: object
                  description: "(object) kind + селектор целей"
                trigger:
                  type: object
                  description: "(object) interval_sec обязателен и должен быть >=
                    60 — Rule.Validate() проверяет его всегда, хотя сам тест триггер
                    не использует; черновик без trigger получает 422"
                metric_window:
                  type: object
                  description: "(object) preset, по умолчанию 'today'"
                condition:
                  type: object
                  description: "(object) дерево условий, минимум 1 лист"
                guards:
                  type: object
                  description: "(object) защиты"
                actions:
                  type: array
                  description: "(array) минимум одно действие (валидируется, хотя
                    тест их не выполняет)"
                recover:
                  type: object
                  description: "(object) восстановление"
              required:
              - name
              - scope
              - trigger
              - condition
              - actions
            example:
              name: Draft ROI check
              scope:
                kind: campaign
                campaign_ids:
                - 22222222-2222-2222-2222-222222222222
              trigger:
                type: schedule
                interval_sec: 300
              metric_window:
                preset: last_3h
              condition:
                kind: cond
                metric: roi
                op: lt
                value: -30
              guards:
                min_clicks: 100
              actions:
              - type: pause
      responses:
        '200':
          description: OK — массив предпросмотров
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    target:
                      type: object
                      description: цель проверки
                    metrics:
                      type: object
                      description: метрики за окно; если данных нет — объект с нулевыми
                        значениями, а не null
                    fired:
                      type: boolean
                      description: сработало бы условие
                    trace:
                      type: array
                      description: трейс условия
                    guards:
                      type: array
                      description: трейс guard'ов
              example:
              - target:
                  kind: campaign
                  id: 22222222-2222-2222-2222-222222222222
                  name: Facebook EU
                  state: active
                  campaign_id: 22222222-2222-2222-2222-222222222222
                metrics:
                  clicks: 420
                  unique_clicks: 390
                  conversions: 12
                  leads: 10
                  sales: 2
                  rejected: 1
                  revenue: 240
                  cost: 300
                  profit: -60
                  cr: 2.86
                  epc: 0.57
                  roi: -20
                  roas: 0.8
                  cpa: 25
                  approval_rate: 91.7
                fired: false
                trace:
                - node: cond
                  detail: roi(-20.0) < -30
                  pass: false
                  metric: roi
                  value: -20
                  against: -30
                guards:
                - node: guard
                  detail: min_clicks 420 >= 100
                  pass: true
        '400':
          description: невалидный JSON, неизвестное поле, или пустое имя (приводит
            к попытке распарсить отсутствующий {id} → 'bad id')
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '422':
          description: ошибка Rule.Validate() черновика
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка движка
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Черновик не сохраняется в БД и не получает id/workspace_id в ответе
        целей помимо тех, что резолвятся из scope; удобно для проверки правила ещё
        до нажатия 'Сохранить'.
  "/rules/{id}/runs":
    get:
      operationId: get_rules_id_runs
      summary: Возвращает историю прогонов (evaluation runs) конкретного правила с
        пагинацией.
      tags:
      - Автоправила
      x-permission: view:rules
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: uuid правила; обязателен, при невалидном формате — 400 'bad id'
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) макс. число записей; при <=0 или >200 сервер тихо подставляет
          50 (значение по умолчанию)"
      - name: offset
        in: query
        required: false
        schema:
          type: number
        description: "(number) смещение; при отсутствии/ошибке парсинга — 0"
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    description: 'массив RuleRun: id, rule_id, workspace_id, evaluated_at,
                      target_kind, target_id, target_name, fired, dry_run, is_recover,
                      metrics, trace, actions, error'
                  total:
                    type: number
                    description: общее число прогонов для этого правила
              example:
                items:
                - id: 44444444-4444-4444-4444-444444444444
                  rule_id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                  workspace_id: 11111111-1111-1111-1111-111111111111
                  evaluated_at: '2026-07-28T09:00:00Z'
                  target_kind: campaign
                  target_id: 22222222-2222-2222-2222-222222222222
                  target_name: Facebook EU
                  fired: true
                  dry_run: false
                  is_recover: false
                  metrics:
                    roi: -35.2
                  trace:
                  - node: cond
                    pass: true
                  actions:
                  - type: pause
                total: 1
        '400':
          description: невалидный uuid
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Запрос фильтруется по rule_id AND workspace_id, так что чужие прогоны
        недоступны, даже если id правила угадан; лимит жёстко ограничен 200 на уровне
        Store.ListRuns независимо от переданного limit.
  "/integrations/telegram/test":
    post:
      operationId: post_integrations_telegram_test
      summary: Отправляет тестовое сообщение в настроенный Telegram-канал воркспейса,
        чтобы проверить конфигурацию интеграции.
      tags:
      - Интеграции
      x-permission: edit:settings
      responses:
        '200':
          description: OK — тестовое сообщение отправлено
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                    description: true при успешной отправке
              example:
                ok: true
        '502':
          description: не удалось достучаться до Telegram / ошибка конфигурации канала
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Тело запроса не читается; используется телеграм-конфигурация, привязанная
        к workspace_id вызывающего. В audit-лог ничего не пишется.
  "/integrations/fb-capi/deliveries":
    get:
      operationId: get_integrations_fb_capi_deliveries
      summary: Возвращает журнал последних доставок Facebook Conversions API (CAPI)
        для воркспейса вместе со сводной статистикой за 24 часа.
      tags:
      - Интеграции
      x-permission: view:settings
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) по умолчанию 100, максимум 1000"
      - name: offset
        in: query
        required: false
        schema:
          type: number
        description: "(number) по умолчанию 0"
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    description: 'массив доставок: id, integration_id, provider, event_name,
                      event_id, click_id, status_code, success, response_body, error,
                      duration_ms, created_at'
                  total:
                    type: number
                    description: общее число записей provider='fb_capi' в воркспейсе
                  success_rate:
                    type: number
                    description: процент успешных доставок за 24ч (0, если событий
                      не было)
                  successes_24h:
                    type: number
                    description: число успешных доставок за 24ч
                  failures_24h:
                    type: number
                    description: число неуспешных доставок за 24ч
              example:
                rows:
                - id: 55555555-5555-5555-5555-555555555555
                  integration_id: 66666666-6666-6666-6666-666666666666
                  provider: fb_capi
                  event_name: Lead
                  event_id: 77777777-7777-7777-7777-777777777777
                  click_id: abc123
                  status_code: 200
                  success: true
                  response_body: '{"events_received":1}'
                  error: ''
                  duration_ms: 180
                  created_at: '2026-07-28T08:55:00Z'
                total: 1
                success_rate: 100
                successes_24h: 1
                failures_24h: 0
        '500':
          description: ошибка запроса к БД (count или select)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: 24-часовая статистика собирается отдельным best-effort запросом,
        ошибка которого молча игнорируется (`_ = ...Scan(...)`), поэтому success_rate
        может остаться 0 даже при наличии старых записей вне окна.
  "/integrations/fb-capi/test":
    post:
      operationId: post_integrations_fb_capi_test
      summary: Отправляет синтетическое тестовое событие Lead через интеграцию Facebook
        CAPI и возвращает ответ Facebook, чтобы проверить pixel_id/access_token.
      tags:
      - Интеграции
      x-permission: edit:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                integration_id:
                  type: string
                  description: "(uuid) id интеграции fb_capi в этом воркспейсе; явной
                    проверки на отсутствие нет, но пустое/чужое значение приводит
                    к 404 при поиске в БД"
                email:
                  type: string
                  description: "(string) email для тестового события (хэшируется на
                    стороне диспетчера перед отправкой в FB)"
              required:
              - integration_id
            example:
              integration_id: 66666666-6666-6666-6666-666666666666
              email: test@example.com
      responses:
        '200':
          description: OK — событие отправлено (независимо от того, успешна ли была
            доставка на стороне FB)
          content:
            application/json:
              schema:
                type: object
                properties:
                  event_id:
                    type: string
                    description: сгенерированный сервером uuid тестового события (не
                      совпадает ни с чем из запроса)
                  status_code:
                    type: number
                    description: HTTP-статус, полученный от Facebook (0, если запись
                      ещё не появилась в capi_deliveries)
                  response:
                    type: object
                    description: распарсенное JSON-тело ответа Facebook (или сырая
                      строка, если не JSON)
                  error:
                    type: string
                    description: текст ошибки диспетчера, если была
                  hint:
                    type: string
                    description: подсказка, где искать событие в Facebook Events Manager
              example:
                event_id: 77777777-7777-7777-7777-777777777777
                status_code: 200
                response:
                  events_received: 1
                  fbtrace_id: Ab1Cd2Ef3
                error: ''
                hint: Open Facebook Events Manager → your Pixel → Test Events tab.
                  With test_event_code configured, this event will appear there within
                  ~30 seconds.
        '400':
          description: невалидный JSON тела ИЛИ найденная интеграция не является fb_capi
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: интеграция с таким id не найдена в воркспейсе
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Обработчик синхронно блокируется на 2 секунды (time.Sleep) в ожидании
        появления строки в capi_deliveries перед ответом — это заметная задержка UI;
        event_id генерируется заново на сервере (uuid.New()), не привязан к click_id.
  "/integrations/tiktok-events/deliveries":
    get:
      operationId: get_integrations_tiktok_events_deliveries
      summary: Возвращает журнал последних доставок TikTok Events API для воркспейса
        вместе со сводной статистикой за 24 часа.
      tags:
      - Интеграции
      x-permission: view:settings
      parameters:
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) по умолчанию 100, максимум 1000"
      - name: offset
        in: query
        required: false
        schema:
          type: number
        description: "(number) по умолчанию 0"
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    description: 'массив доставок: id, integration_id, provider, event_name,
                      event_id, click_id, status_code, success, response_body, error,
                      duration_ms, created_at'
                  total:
                    type: number
                    description: общее число записей provider='tiktok_events' в воркспейсе
                  success_rate:
                    type: number
                    description: процент успешных доставок за 24ч
                  successes_24h:
                    type: number
                    description: успешные доставки за 24ч
                  failures_24h:
                    type: number
                    description: неуспешные доставки за 24ч
              example:
                rows:
                - id: 88888888-8888-8888-8888-888888888888
                  integration_id: 99999999-9999-9999-9999-999999999999
                  provider: tiktok_events
                  event_name: SubmitForm
                  event_id: aaaaaaa1-0000-0000-0000-000000000001
                  click_id: abc123
                  status_code: 200
                  success: true
                  response_body: '{"code":0}'
                  error: ''
                  duration_ms: 150
                  created_at: '2026-07-28T08:50:00Z'
                total: 1
                success_rate: 100
                successes_24h: 1
                failures_24h: 0
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Структура и логика полностью зеркальны /integrations/fb-capi/deliveries
        — используют общую таблицу capi_deliveries с фильтром provider='tiktok_events';
        24ч-статистика тоже best-effort и игнорирует ошибки.
  "/integrations/tiktok-events/test":
    post:
      operationId: post_integrations_tiktok_events_test
      summary: Отправляет синтетическое тестовое событие через интеграцию TikTok Events
        и возвращает ответ TikTok, чтобы проверить настройки pixel/access_token.
      tags:
      - Интеграции
      x-permission: edit:settings
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                integration_id:
                  type: string
                  description: "(uuid) id интеграции tiktok_events в этом воркспейсе;
                    отсутствие/чужой id приводит к 404 при поиске"
                email:
                  type: string
                  description: "(string) email для тестового события"
              required:
              - integration_id
            example:
              integration_id: 99999999-9999-9999-9999-999999999999
              email: test@example.com
      responses:
        '200':
          description: OK — событие отправлено
          content:
            application/json:
              schema:
                type: object
                properties:
                  event_id:
                    type: string
                    description: сгенерированный сервером uuid тестового события
                  status_code:
                    type: number
                    description: HTTP-статус ответа TikTok (0, если запись ещё не
                      появилась)
                  response:
                    type: object
                    description: распарсенное JSON-тело ответа TikTok (или сырая строка)
                  error:
                    type: string
                    description: текст ошибки, если была
                  hint:
                    type: string
                    description: подсказка, где искать событие в TikTok Events Manager
              example:
                event_id: aaaaaaa1-0000-0000-0000-000000000001
                status_code: 200
                response:
                  code: 0
                  message: OK
                error: ''
                hint: Open TikTok Events Manager → your pixel → Test Events. With
                  a test_event_code configured, this event appears there within ~30
                  seconds.
        '400':
          description: невалидный JSON тела ИЛИ интеграция не является tiktok_events
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '404':
          description: интеграция с таким id не найдена в воркспейсе
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Как и fb-capi/test, синхронно ждёт 2 секунды перед ответом (time.Sleep),
        чтобы успеть прочитать результат из capi_deliveries; событие фиксированного
        типа 'lead'/SubmitForm с payout=1.0, currency=USD, не зависящее от параметров
        запроса кроме email.
  "/system/status":
    get:
      operationId: get_system_status
      summary: 'Возвращает состояние здоровья сервера: версию, аптайм, память, число
        горутин и доступность Postgres/ClickHouse/Redis.'
      tags:
      - Система
      x-permission: view:settings
      responses:
        '200':
          description: OK — всегда 200, даже если отдельные компоненты нездоровы (см.
            components[x].ok=false и .error)
          content:
            application/json:
              schema:
                type: object
                properties:
                  version:
                    type: string
                    description: версия сборки сервера
                  uptime_sec:
                    type: number
                    description: время работы процесса в секундах
                  goroutines:
                    type: number
                    description: текущее число горутин Go
                  memory:
                    type: object
                    description: heap_alloc, sys, num_gc из runtime.MemStats
                  components:
                    type: object
                    description: 'по ключам postgres/clickhouse/redis: {ok, latency_ms,
                      error?}'
                  intel:
                    type: object
                    description: статус IP-intelligence/бот-баз (enabled, datacenter_asns,
                      datacenter_ranges, vpn_ranges, proxy_ranges, bot_signatures,
                      tor_exit_nodes, last_sync, last_error); присутствует, только
                      если Track-хендлер сконфигурирован
              example:
                version: 1.4.2
                uptime_sec: 86412
                goroutines: 57
                memory:
                  heap_alloc: 41943040
                  sys: 83886080
                  num_gc: 124
                components:
                  postgres:
                    ok: true
                    latency_ms: 2
                  clickhouse:
                    ok: true
                    latency_ms: 5
                  redis:
                    ok: true
                    latency_ms: 1
                intel:
                  enabled: true
                  datacenter_asns: 1200
                  datacenter_ranges: 45000
                  vpn_ranges: 8000
                  proxy_ranges: 3000
                  bot_signatures: 900
                  tor_exit_nodes: 1500
                  last_sync: '2026-07-28T06:00:00Z'
      description: Каждая проверка компонента ограничена таймаутом 1с внутри общего
        контекста в 3с; clickhouse/redis считаются ok=true с latency~0, если клиент
        не сконфигурирован (h.CH/h.Redis == nil).
  "/system/logs":
    get:
      operationId: get_system_logs
      summary: Возвращает постраничный список записей операционного системного лога
        (уровень/источник/сообщение) с фильтрами.
      tags:
      - Система
      x-permission: view:settings
      parameters:
      - name: level
        in: query
        required: false
        schema:
          type: string
        description: "(string) точное совпадение по колонке level"
      - name: source
        in: query
        required: false
        schema:
          type: string
        description: "(string) точное совпадение по колонке source"
      - name: from
        in: query
        required: false
        schema:
          type: string
        description: "(string) нижняя граница created_at (>=); передаётся как строка
          прямо в SQL-сравнение, парсинг делает Postgres"
      - name: to
        in: query
        required: false
        schema:
          type: string
        description: "(string) верхняя граница created_at (<); аналогично from"
      - name: q
        in: query
        required: false
        schema:
          type: string
        description: "(string) подстрока для ILIKE-поиска по message или detail::text"
      - name: limit
        in: query
        required: false
        schema:
          type: number
        description: "(number) по умолчанию 200, максимум 1000"
      - name: offset
        in: query
        required: false
        schema:
          type: number
        description: "(number) по умолчанию 0"
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    description: 'записи лога: id, level, source, message, detail
                      (произвольный JSON), created_at'
                  total:
                    type: number
                    description: общее число записей, удовлетворяющих фильтрам
                  limit:
                    type: number
                    description: применённый лимит
                  offset:
                    type: number
                    description: применённое смещение
              example:
                rows:
                - id: bbbbbbbb-0000-0000-0000-000000000001
                  level: error
                  source: postback
                  message: delivery failed after 3 retries
                  detail:
                    url: https://example.com/pb
                    status: 502
                  created_at: '2026-07-28T07:40:00Z'
                total: 1
                limit: 200
                offset: 0
        '500':
          description: ошибка БД (включая ошибку парсинга from/to некорректного формата
            на стороне Postgres)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Таблица system_log не привязана к workspace_id — это общесистемный
        (не per-tenant) операционный лог; доступ ограничивается только правом view:settings
        у вызывающего.
  "/realtime/token":
    post:
      operationId: post_realtime_token
      summary: Выпускает краткоживущий Centrifugo connection-токен для подключения
        текущего пользователя к real-time шине.
      tags:
      - Realtime
      x-permission: view:reports
      responses:
        '200':
          description: OK — токен выдан
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: JWT-токен подключения Centrifugo, действителен 1
                      час
                  ws:
                    type: string
                    description: публичный websocket URL (переопределяется env CENTRIFUGO_PUBLIC_WS)
                  channels:
                    type: array
                    description: 'список каналов воркспейса пользователя: clicks/conversions/postbacks/simulator/dashboard/notifications
                      в формате ws:<topic>#<workspace_id>'
                  workspace_id:
                    type: string
                    description: воркспейс пользователя
                  expires_in:
                    type: number
                    description: срок действия токена в секундах (3600)
              example:
                token: eyJhbGciOi...
                ws: ws://localhost:8001/connection/websocket
                channels:
                - ws:clicks#11111111-1111-1111-1111-111111111111
                - ws:conversions#11111111-1111-1111-1111-111111111111
                - ws:postbacks#11111111-1111-1111-1111-111111111111
                - ws:simulator#11111111-1111-1111-1111-111111111111
                - ws:dashboard#11111111-1111-1111-1111-111111111111
                - ws:notifications#11111111-1111-1111-1111-111111111111
                workspace_id: 11111111-1111-1111-1111-111111111111
                expires_in: 3600
        '401':
          description: нет аутентифицированного пользователя в контексте (защитный
            случай, обычно не достижим за auth-middleware)
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: нет права view:reports в активном воркспейсе — в том числе если
            участника уже удалили из воркспейса, а его session-JWT ещё жив
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка выпуска токена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '503':
          description: realtime (Centrifugo-клиент) не сконфигурирован/выключен
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Список каналов жёстко формируется сервером по workspace_id из JWT
        — сам список каналов не даёт прав на подписку, для этого нужен отдельный вызов
        /realtime/subscription-token на каждый канал.
  "/realtime/subscription-token":
    post:
      operationId: post_realtime_subscription_token
      summary: Выпускает Centrifugo subscription-токен для подписки на один конкретный
        канал, только если этот канал принадлежит активному воркспейсу вызывающего.
      tags:
      - Realtime
      x-permission: view:reports
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                channel:
                  type: string
                  description: "(string) имя канала в формате ws:<topic>#<workspace_id>,
                    где topic ∈ {clicks, conversions, postbacks, simulator, dashboard,
                    notifications}"
              required:
              - channel
            example:
              channel: ws:clicks#11111111-1111-1111-1111-111111111111
      responses:
        '200':
          description: OK — токен выдан
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: JWT subscription-токен Centrifugo, действителен 5
                      минут
                  expires_in:
                    type: number
                    description: срок действия в секундах (300)
              example:
                token: eyJhbGciOi...
                expires_in: 300
        '400':
          description: невалидный JSON тела ИЛИ channel не соответствует формату ws:<topic>#<uuid>
            с известным topic
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '401':
          description: нет аутентифицированного пользователя в контексте
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: нет права view:reports в активном воркспейсе (в том числе после
            удаления участника из воркспейса) ИЛИ канал синтаксически валиден, но воркспейс
            в его имени не совпадает с воркспейсом вызывающего
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка выпуска токена
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '503':
          description: realtime выключен/не сконфигурирован
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Это барьер, не позволяющий одному тенанту подписаться на канал другого
        (например ws:clicks#<чужой-workspace>). Воркспейс берётся из подписанной сессионной
        JWT — подделать его нельзя, но он живёт 7 дней и может устареть, поэтому членство
        в воркспейсе дополнительно перепроверяется по workspace_members на каждый выпуск
        токена (view:reports).
  "/branding":
    get:
      operationId: get_branding
      summary: Публичный дескриптор white-label брендирования инстанса — имя панели,
        текст логина, флаги наличия логотипа/favicon и версия v для cache-busting.
      tags:
      - Брендирование
      x-permission: public (no auth, rate-limited 1rps/burst10 на IP)
      responses:
        '200':
          description: ok; если строка брендирования ещё не создана — нулевые значения
            ("", false, 0)
          content:
            application/json:
              schema:
                type: object
                properties:
                  panel_name:
                    type: string
                    description: имя панели; пустая строка = дефолтный брендинг
                  login_note:
                    type: string
                    description: текст на странице логина
                  logo:
                    type: boolean
                    description: загружен ли кастомный логотип
                  favicon:
                    type: boolean
                    description: загружен ли кастомный favicon
                  v:
                    type: integer
                    description: версия для cache-busting URL логотипа/favicon — микросекунды
                      Unix-времени updated_at
              example:
                panel_name: My Tracker
                login_note: Only for team members
                logo: true
                favicon: false
                v: 1755518400000000
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Брендирование глобально для всего инстанса (одна строка instance_branding),
        а не per-workspace. Read-эндпоинты публичны — страница логина показывает кастомное
        имя/лого до аутентификации.
    put:
      operationId: put_branding
      summary: Частично обновляет текстовые поля брендирования (panel_name, login_note);
        непереданные поля не меняются, ответ — свежий дескриптор как у GET /branding.
      tags:
      - Брендирование
      x-permission: instance owner (requireInstanceAdmin)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                panel_name:
                  type: string
                  description: "(string) до 60 символов после trim, без < и >; пустая
                    строка сбрасывает к дефолту"
                login_note:
                  type: string
                  description: "(string) до 160 символов после trim, без < и >; пустая
                    строка очищает"
            example:
              panel_name: My Tracker
              login_note: Only for team members
      responses:
        '200':
          description: обновлено; ответ = дескриптор GET /branding
          content:
            application/json:
              schema:
                type: object
                properties:
                  panel_name:
                    type: string
                    description: ''
                  login_note:
                    type: string
                    description: ''
                  logo:
                    type: boolean
                    description: ''
                  favicon:
                    type: boolean
                    description: ''
                  v:
                    type: integer
                    description: ''
              example:
                panel_name: My Tracker
                login_note: Only for team members
                logo: false
                favicon: false
                v: 1755518400000000
        '400':
          description: невалидный JSON или неизвестное поле, длина сверх лимита либо
            символы < > в значении
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: не instance owner ("instance owner required")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Значения попадают в i18n-строки, часть которых рендерится как HTML
        — угловые скобки отклоняются, а не экранируются. Гейт — users.is_instance_owner,
        роль owner/admin в workspace доступа НЕ даёт. Пишется audit log (update/branding).
  "/branding/logo":
    get:
      operationId: get_branding_logo
      summary: Отдаёт байты загруженного логотипа (Content-Type = mime при загрузке);
        404, если логотип не загружен.
      tags:
      - Брендирование
      x-permission: public (no auth, rate-limited 1rps/burst10 на IP)
      responses:
        '200':
          description: байты изображения; заголовки ETag "br-logo-<v>", Cache-Control
            no-cache, X-Content-Type-Options nosniff, CSP default-src 'none'
          content:
            image/*:
              schema:
                type: string
                format: binary
        '304':
          description: If-None-Match совпал с ETag, тело не отдаётся
        '404':
          description: логотип не загружен (plain-text ответ, не JSON)
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: CSP без скриптов на отдаче — загруженный SVG не может исполниться
        в origin панели.
    post:
      operationId: post_branding_logo
      summary: Загружает логотип — сырые байты изображения в теле запроса (Content-Type
        игнорируется, формат sniff'ится); максимум 1 MiB.
      tags:
      - Брендирование
      x-permission: instance owner (requireInstanceAdmin)
      requestBody:
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: сырые байты изображения, максимум 1048576 байт; форматы
                png, jpeg, gif, webp, ico, svg
      responses:
        '200':
          description: загружен
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
                  mime:
                    type: string
                    description: распознанный mime, например "image/png"
              example:
                status: ok
                mime: image/png
        '400':
          description: 'ошибка чтения тела либо неподдерживаемый формат: "unsupported
            image format (allowed: png, jpeg, webp, gif, ico, svg)"'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: не instance owner
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '413':
          description: тело больше 1 MiB ("logo must be at most 1048576 bytes")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: SVG принимается либерально (валидный UTF-8 с <svg в первых 1024
        байтах) — путь отдачи пришпиливает Content-Type + nosniff + CSP без скриптов.
        Пишется audit log (update/branding) с mime и размером.
    delete:
      operationId: delete_branding_logo
      summary: Удаляет кастомный логотип (сброс к дефолту) — GET /branding/logo снова
        отвечает 404.
      tags:
      - Брендирование
      x-permission: instance owner (requireInstanceAdmin)
      responses:
        '200':
          description: удалён (идемпотентно — 200 и если логотипа не было)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '403':
          description: не instance owner
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Пишется audit log (delete/branding).
  "/branding/favicon":
    get:
      operationId: get_branding_favicon
      summary: Отдаёт загруженный favicon; 404 не бывает — без кастомного файла генерируется
        дефолтный SVG с первой буквой panel_name.
      tags:
      - Брендирование
      x-permission: public (no auth, rate-limited 1rps/burst10 на IP)
      responses:
        '200':
          description: байты изображения (сгенерированный дефолт — image/svg+xml);
            заголовки ETag "br-favicon-<v>", Cache-Control no-cache, nosniff, CSP
          content:
            image/*:
              schema:
                type: string
                format: binary
        '304':
          description: If-None-Match совпал с ETag
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: updated_at входит в ETag — после переименования панели дефолтный
        favicon перезапрашивается с новой буквой.
    post:
      operationId: post_branding_favicon
      summary: Загружает favicon — сырые байты изображения в теле запроса (Content-Type
        игнорируется, формат sniff'ится); максимум 256 KiB.
      tags:
      - Брендирование
      x-permission: instance owner (requireInstanceAdmin)
      requestBody:
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
              description: сырые байты изображения, максимум 262144 байта; форматы
                png, jpeg, gif, webp, ico, svg
      responses:
        '200':
          description: загружен
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
                  mime:
                    type: string
                    description: распознанный mime, например "image/x-icon"
              example:
                status: ok
                mime: image/x-icon
        '400':
          description: 'ошибка чтения тела либо неподдерживаемый формат: "unsupported
            image format (allowed: png, jpeg, webp, gif, ico, svg)"'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: не instance owner
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '413':
          description: тело больше 256 KiB ("favicon must be at most 262144 bytes")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Пишется audit log (update/branding) с mime и размером.
    delete:
      operationId: delete_branding_favicon
      summary: Удаляет кастомный favicon — GET /branding/favicon возвращается к сгенерированному
        дефолту.
      tags:
      - Брендирование
      x-permission: instance owner (requireInstanceAdmin)
      responses:
        '200':
          description: удалён (идемпотентно)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: всегда "ok"
              example:
                status: ok
        '403':
          description: не instance owner
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ошибка запроса к БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Пишется audit log (delete/branding).
  "/license":
    get:
      operationId: get_license
      summary: Возвращает производный (без секретов) статус лицензии инстанса — сам
        ключ и install_token не отдаются никогда.
      tags:
      - Лицензия
      x-permission: view:settings
      responses:
        '200':
          description: OK — всегда 200; на неактивированном инстансе поля пустые,
            а status = "unlicensed"
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: 'вердикт: active, unlicensed, expired, cancelled,
                      revoked, not_found, already_installed'
                  licensee:
                    type: string
                    description: имя владельца лицензии со стороны сервера лицензий;
                      пустая строка, пока активации не было
                  features:
                    type: array
                    description: 'список энтайтлментов вида {name, limit}; пустой
                      массив = лимитов нет. Известный ключ — users (лимит мест в воркспейсе)'
                  license_expires:
                    type: integer
                    nullable: true
                    description: unix-время окончания лицензии; null = бессрочная
                  hash_expires:
                    type: integer
                    description: unix-время, до которого закешированный вердикт считается
                      свежим (grace-окно); блокировка наступает только после него
                  last_check:
                    type: integer
                    description: unix-время последней попытки обращения к серверу лицензий
                      (значение берётся из instance_license.updated_at, который сдвигается
                      и при неудачной проверке); 0, если активации ещё не было
                  has_key:
                    type: boolean
                    description: настроен ли ключ (через MINIK_LICENSE_KEY или введён
                      в панели) — не то же самое, что status = active
                  last_error:
                    type: string
                    description: короткий код последней ошибки связи/проверки (например
                      license_server_unreachable, rate_limited, untrusted_response);
                      секретов не содержит
                  enforced:
                    type: boolean
                    description: включено ли принуждение в этой сборке — true, если
                      вшит публичный ключ проверки (release-сборка), независимо от
                      того, настроен ли лицензионный ключ; в dev/local-сборке без
                      pubkey false
              example:
                status: active
                licensee: ACME LLC
                features:
                - name: users
                  limit: 10
                license_expires: 1790000000
                hash_expires: 1756080000
                last_check: 1755993600
                has_key: true
                last_error: ''
                enforced: true
      description: Единственная строка instance_license на весь инстанс, а не на воркспейс.
        При enforced=true admin_api отвечает 402 {"error":"license_required"}, если
        (а) ключ вообще не настроен (has_key=false) — сразу, без учёта grace-окна,
        либо (б) вердикт подтверждённо плохой (expired/cancelled/revoked/not_found/already_installed)
        и grace-окно hash_expires уже истекло. Блокировка не касается /license, /me
        и /auth, чтобы из заблокированной панели можно было ввести ключ.
  "/license/activate":
    post:
      operationId: post_license_activate
      summary: Активирует лицензионный ключ на сервере лицензий, сохраняет установку
        и возвращает свежий статус.
      tags:
      - Лицензия
      x-permission: instance owner (requireInstanceAdmin)
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                key:
                  type: string
                  description: "(string) лицензионный ключ; обрезаются пробелы, пустая
                    строка -> 400 \"key required\""
              required:
              - key
            example:
              key: MNK-XXXX-XXXX-XXXX
      responses:
        '200':
          description: запрос к серверу лицензий выполнен; тело = дескриптор GET /license.
            Доменный отказ (not_found, already_installed, expired, cancelled) — тоже
            200, причина в status и last_error
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: ''
                  licensee:
                    type: string
                    description: ''
                  features:
                    type: array
                    description: ''
                  license_expires:
                    type: integer
                    nullable: true
                    description: ''
                  hash_expires:
                    type: integer
                    description: ''
                  last_check:
                    type: integer
                    description: ''
                  has_key:
                    type: boolean
                    description: ''
                  last_error:
                    type: string
                    description: ''
                  enforced:
                    type: boolean
                    description: ''
              example:
                status: active
                licensee: ACME LLC
                features:
                - name: users
                  limit: 10
                license_expires: 1790000000
                hash_expires: 1756080000
                last_check: 1755993600
                has_key: true
                last_error: ''
                enforced: true
        '400':
          description: невалидный JSON, пустой key ("key required") либо сервер лицензий
            отверг запрос ("bad_request")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: не instance owner ("instance owner required")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '429':
          description: сервер лицензий ограничил частоту ("rate_limited")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '500':
          description: ответ получен, но не сохранился в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '502':
          description: 'сервер лицензий недоступен или ответил некорректно: license_server_unreachable,
            license_server_error, untrusted_response (подпись ответа не проверилась),
            unauthorized'
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '503':
          description: клиент лицензий не сконфигурирован в этой сборке ("license
            client not configured")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Ключ и выданный install_token хранятся только на сервере и не возвращаются
        ни этим, ни любым другим эндпоинтом. Успешная активация сбрасывает счётчик
        проверок; при доменном отказе ключ сохраняется, чтобы повтор работал. Пишется
        audit log (activate/license) — со статусом, без ключа.
  "/license/recheck":
    post:
      operationId: post_license_recheck
      summary: Выполняет внеочередную проверку лицензии на сервере лицензий и возвращает
        обновлённый статус.
      tags:
      - Лицензия
      x-permission: instance owner (requireInstanceAdmin)
      responses:
        '200':
          description: проверка выполнена; тело = дескриптор GET /license
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    description: ''
                  licensee:
                    type: string
                    description: ''
                  features:
                    type: array
                    description: ''
                  license_expires:
                    type: integer
                    nullable: true
                    description: ''
                  hash_expires:
                    type: integer
                    description: ''
                  last_check:
                    type: integer
                    description: ''
                  has_key:
                    type: boolean
                    description: ''
                  last_error:
                    type: string
                    description: ''
                  enforced:
                    type: boolean
                    description: ''
              example:
                status: active
                licensee: ACME LLC
                features:
                - name: users
                  limit: 10
                license_expires: 1790000000
                hash_expires: 1756080000
                last_check: 1755993600
                has_key: true
                last_error: ''
                enforced: true
        '400':
          description: инстанс ещё не активирован ("not activated") либо сервер лицензий
            отверг запрос ("bad_request")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '403':
          description: не instance owner ("instance owner required")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '429':
          description: сервер лицензий ограничил частоту ("rate_limited")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '502':
          description: сервер лицензий недоступен или ответил некорректно (license_server_unreachable,
            license_server_error, untrusted_response, unauthorized); сюда же попадает
            ошибка записи результата в БД
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
        '503':
          description: клиент лицензий не сконфигурирован в этой сборке ("license
            client not configured")
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/ErrorResponse"
      description: Тело запроса не читается. Проверка выполняется и сама, в фоне, по
        таймеру с джиттером ±10%; ручной вызов нужен, когда нужно применить изменение
        лицензии немедленно. Ошибка связи не сбрасывает закешированный вердикт — записывается
        только last_error, а панель продолжает работать по последнему подтверждённому
        ответу.
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
  schemas:
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
    StatusOK:
      type: object
      properties:
        status:
          type: string
          example: ok
