openapi: 3.0.3
info:
  title: c3-reports — публичный API
  version: 0.4.0
  description: |
    REST API сервиса отчётов и BI-аналитики **c3-reports**.

    Один и тот же API используют и экраны приложения, и внешние системы: отчёты, строки листа,
    точки графиков, дашборды, печать файлов, ответы по документации со ссылками на разделы
    и встраивание в чужие страницы. Ответы всегда зависят от арендатора: чужие объекты
    отвечают `404`, а не `403`, чтобы по ответу нельзя было узнать, что такой номер существует.

    ## Вход

    | Способ | Заголовок | Где применяется |
    | --- | --- | --- |
    | API-токен | `Authorization: Bearer c3_…` | Почти все адреса ниже |
    | Сессия браузера | cookie сессии | Экраны приложения; внешним системам недоступна |
    | Встраивание | `X-C3-Embed: c3e_…` или `c3t_…` | Только адреса, где это указано явно |

    Токен создаётся в разделе «API-токены» и действует **от имени владельца**: его роль,
    его ограничения строк и его арендатор. Области токена только сужают права владельца.

    ## Области доступа

    | Область | Что разрешает |
    | --- | --- |
    | `reports:read` | Список и карточка отчёта, строки листа, данные графика |
    | `dashboards:read` | Список и карточка дашборда, данные виджета |
    | `exports:create` | Заказ печати отчёта и генерации файла |
    | `exports:read` | Состояние печати и скачивание готового файла |
    | `ai:chat` | Ответ по справке со ссылками на разделы |

    ## Ограничение частоты

    Запросы с токеном ограничены **120 запросами в минуту** (окно — 60 секунд, счётчик общий
    на токен). Каждый ответ несёт заголовки `X-RateLimit-Limit`, `X-RateLimit-Remaining`
    и `X-RateLimit-Reset` (секунды до сброса окна). При превышении — `429` и `Retry-After`.

    ## Ошибки

    Адреса отвечают телом Nitro: `{ statusCode, statusMessage, message, data }`.
    Неизвестный, просроченный и отозванный токен — один ответ `401`: по нему нельзя перебрать
    существующие токены. Ответ `401` несёт заголовок `WWW-Authenticate: Bearer`.

    ## Встраивание

    Ссылка `c3e_…` живёт до отзыва, тикет `c3t_…` — час и хранится в Redis. Оба удостоверения
    открывают ровно один объект: отчёт или один виджет дашборда. iframe-страницы —
    `/embed/report/{reportId}` и `/embed/widget/{dashboardId}/{widgetId}`, клиентский скрипт —
    `/embed.js`.
  license:
    name: Проприетарное ПО
servers:
  - url: /
    description: Текущий адрес установки
security:
  - bearerAuth: []
tags:
  - name: Отчёты
    description: Отчёты, строки листа и точки графиков
  - name: Дашборды
    description: Дашборды и данные виджетов
  - name: Печать
    description: Очередь печати и готовые файлы
  - name: AI
    description: Ответ по документации со ссылками на разделы
  - name: Встраивание
    description: Ссылки, тикеты и iframe-страницы
  - name: Служебные
    description: Проверка живости сервиса
x-tagGroups:
  - name: Данные и отчёты
    tags:
      - Отчёты
      - Дашборды
  - name: Файлы
    tags:
      - Печать
  - name: Интеграции
    tags:
      - AI
      - Встраивание
  - name: Служебные
    tags:
      - Служебные
paths:
  /api/reports:
    get:
      tags: [Отчёты]
      operationId: listReports
      summary: Список отчётов
      description: |
        Отчёты арендатора, от недавно изменённых к старым. В строке — число датасетов
        и число найденных в шаблоне проблем: черновик печатать нельзя, и список говорит
        об этом до открытия карточки.
      parameters:
        - $ref: '#/components/parameters/LimitQuery'
        - $ref: '#/components/parameters/OffsetQuery'
      responses:
        '200':
          description: Порция списка и общее число отчётов
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/reports/{reportId}:
    get:
      tags: [Отчёты]
      operationId: getReport
      summary: Карточка отчёта
      description: |
        Шаблон отчёта, привязанные датасеты и проблемы, найденные в документе. Если шаблон
        записан несовместимой версией программы, ответ — `422`, а не наполовину разобранный
        документ. Принимает и удостоверение встраивания — только на этот же отчёт.
      security:
        - bearerAuth: []
        - embedCredential: []
      parameters:
        - $ref: '#/components/parameters/ReportIdPath'
      responses:
        '200':
          description: Отчёт целиком
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportDetail' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/reports/{reportId}/rows:
    post:
      tags: [Отчёты]
      operationId: readReportRows
      summary: Строки листа отчёта
      description: |
        Строки отчёта в том виде, в каком их напечатает сам отчёт: с отбором источника,
        порядком строк и связями со справочниками. Шаблон берётся из сохранённого отчёта,
        а не из тела запроса. Тело дополняет шапку листа: порядок, отбор и свёртку.
        При встраивании источник, колонки и предел строк берутся из сохранённого виджета,
        а `params`, `sort`, `filters`, `globalFilters` и `grouping` из тела отбрасываются.
      security:
        - bearerAuth: []
        - embedCredential: []
      parameters:
        - $ref: '#/components/parameters/ReportIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ReportRowsRequest' }
      responses:
        '200':
          description: Строки и описание колонок
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ReportRowsResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/reports/{reportId}/chart-data:
    post:
      tags: [Отчёты]
      operationId: readReportChartData
      summary: Точки графика отчёта
      description: |
        Точки по категориям для элемента-графика. Настройки живут в шаблоне: тело несёт только
        идентификатор элемента и значения параметров запуска. Поле `template` принимается лишь
        с экрана конструктора — во встраивании и просмотре оно отбрасывается.
      security:
        - bearerAuth: []
        - embedCredential: []
      parameters:
        - $ref: '#/components/parameters/ReportIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ChartDataRequest' }
      responses:
        '200':
          description: Колонки и точки графика
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ChartDataResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/reports/{reportId}/package:
    get:
      tags: [Отчёты]
      operationId: downloadReportPackage
      summary: Пакет отчёта .c3report
      description: |
        Файл переноса отчёта в другую систему: документ шаблона, описания датасетов
        и подключений, картинки. Данные источников и пароли в пакет не попадают, но сам
        документ выходит из системы, поэтому скачать его может тот же, кому разрешена
        печать: «наблюдателю» закрыто. Пакет собирается в памяти и сразу отдаётся файлом;
        выгрузка пишется в журнал действий.
      parameters:
        - $ref: '#/components/parameters/ReportIdPath'
      responses:
        '200':
          description: Архив с пакетом отчёта
          content:
            application/zip:
              schema:
                type: string
                format: binary
          headers:
            Content-Disposition:
              description: Имя файла пакета
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
        '502': { $ref: '#/components/responses/BadGateway' }
  /api/dashboards:
    get:
      tags: [Дашборды]
      operationId: listDashboards
      summary: Список дашбордов
      description: Дашборды арендатора, от недавно изменённых к старым, с числом виджетов.
      parameters:
        - $ref: '#/components/parameters/LimitQuery'
        - $ref: '#/components/parameters/OffsetQuery'
      responses:
        '200':
          description: Порция списка и общее число дашбордов
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardListResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/dashboards/{dashboardId}:
    get:
      tags: [Дашборды]
      operationId: getDashboard
      summary: Карточка дашборда
      description: |
        Дашборд с разметкой, уложенной в сетку, и глобальными фильтрами. Нечитаемое условие
        отбрасывается при чтении: один испорченный фильтр не должен оставлять человека
        без дашборда.
      parameters:
        - $ref: '#/components/parameters/DashboardIdPath'
      responses:
        '200':
          description: Дашборд целиком
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DashboardDetail' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/dashboards/{dashboardId}/widget-data:
    post:
      tags: [Дашборды]
      operationId: readWidgetData
      summary: Данные виджета
      description: |
        Точки графика, числа показателя или матрица среза. Без настроек в теле они берутся
        из сохранённого дашборда; с настройками — проверяются по схеме своего вида, и `kind`
        тогда обязателен. При встраивании показывается ровно сохранённый виджет: настройки,
        пересечения и глобальные фильтры из тела отбрасываются, а расхождение вида — `400`.
      security:
        - bearerAuth: []
        - embedCredential: []
      parameters:
        - $ref: '#/components/parameters/DashboardIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/WidgetDataRequest' }
      responses:
        '200':
          description: Данные виджета; форма зависит от вида
          content:
            application/json:
              schema: { $ref: '#/components/schemas/WidgetDataResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/reports/{reportId}/exports:
    post:
      tags: [Печать]
      operationId: createReportExport
      summary: Заказать печать отчёта
      description: |
        Ставит печать в очередь и сразу возвращает номер строки: файла в ответе нет и быть
        не может — печать занимает секунды и десятки секунд. Готовность проверяется
        по `GET /api/reports/{reportId}/exports`, файл — по адресу из `downloadUrl`.
        Роль «наблюдатель» печатать не может: печать — вынос отчёта из системы.
      parameters:
        - $ref: '#/components/parameters/ReportIdPath'
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ExportRequest' }
      responses:
        '200':
          description: Номер строки печати
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportOrderResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
    get:
      tags: [Печать]
      operationId: listReportExports
      summary: Печати отчёта
      description: |
        Последние 20 печатей отчёта, новые сверху. Ссылка на файл есть только у готовой
        печати: у строки в очереди файла ещё нет, и «скачать» вело бы в пустоту.
      parameters:
        - $ref: '#/components/parameters/ReportIdPath'
      responses:
        '200':
          description: Список печатей
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/ReportExport' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/reports/{reportId}/exports/{exportId}/file:
    get:
      tags: [Печать]
      operationId: downloadReportExport
      summary: Скачать напечатанный файл
      description: |
        Файл отдаётся байтами с именем в `Content-Disposition`; тип — по виду выгрузки.
        Чужая печать и печать без файла отвечают `404`.
      parameters:
        - $ref: '#/components/parameters/ReportIdPath'
        - $ref: '#/components/parameters/ExportIdPath'
      responses:
        '200':
          description: Файл выгрузки
          headers:
            Content-Disposition:
              description: Имя файла для сохранения
              schema: { type: string }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/generations:
    post:
      tags: [Печать]
      operationId: createGeneration
      summary: Заказать генерацию по номеру отчёта
      description: |
        Короткий путь для внешних систем: номер отчёта и вид файла приходят в теле, обратно
        уходит номер генерации. Внутри — та же очередь и та же строка, что у печати из карточки.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/GenerationRequest' }
      responses:
        '200':
          description: Номер генерации
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ExportOrderResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/generations/{generationId}:
    get:
      tags: [Печать]
      operationId: getGeneration
      summary: Состояние генерации
      description: |
        В очереди, печатается, готово или отказ. Ссылка на файл появляется только у готовой
        генерации. Чужая генерация отвечает `404`.
      parameters:
        - $ref: '#/components/parameters/GenerationIdPath'
      responses:
        '200':
          description: Строка генерации
          content:
            application/json:
              schema: { $ref: '#/components/schemas/GenerationSummary' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/generations/{generationId}/file:
    get:
      tags: [Печать]
      operationId: downloadGeneration
      summary: Скачать файл генерации
      description: Байты готового файла с именем в `Content-Disposition`. Пока генерация не готова — `404`.
      parameters:
        - $ref: '#/components/parameters/GenerationIdPath'
      responses:
        '200':
          description: Файл выгрузки
          headers:
            Content-Disposition:
              description: Имя файла для сохранения
              schema: { type: string }
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/ai/rag:
    post:
      tags: [AI]
      operationId: ragAnswer
      summary: Ответ по документации с источниками
      description: |
        Вопрос по справке: сервер сам находит разделы, передаёт их модели и проверяет цитаты.
        Источники в ответе — записи индекса, а не текст, придуманный моделью. По умолчанию
        ответ идёт потоком SSE (`sources` → `delta` → `done`), поле `stream: false` просит
        обычный JSON. Если подходящих разделов нет, модель не вызывается.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/RagRequest' }
      responses:
        '200':
          description: Ответ целиком или поток SSE
          content:
            application/json:
              schema: { $ref: '#/components/schemas/RagAnswer' }
            text/event-stream:
              schema: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/embed/tickets:
    post:
      tags: [Встраивание]
      operationId: createEmbedTicket
      summary: Выпустить короткий тикет встраивания
      description: |
        Тикет живёт час и хранится в Redis: он для страницы, которая открывается сразу
        и ненадолго. Права в тикет не копируются — в нём только владелец и объект, а роль
        и ограничения строк читаются из профиля при каждом запросе. Область выбирается
        целью: отчёт требует `reports:read`, виджет — `dashboards:read`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/EmbedTicketRequest' }
      responses:
        '200':
          description: Тикет, готовый адрес iframe и срок действия
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmbedTicket' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/embed/resolve:
    get:
      tags: [Встраивание]
      operationId: resolveEmbedCredential
      summary: Что открыто удостоверением
      description: |
        Публичный адрес: iframe-страница ещё не знает, чей профиль стоит за секретом,
        и спрашивать сессию не у чего. Нужен, чтобы вовремя показать «ссылка отозвана»
        или «тикет истёк», подписать вкладку названием объекта и сверить цель. Невалидный,
        просроченный и отозванный секрет — один ответ `401`.
      security: []
      parameters:
        - $ref: '#/components/parameters/EmbedCredentialQuery'
      responses:
        '200':
          description: Объект, срок действия и настройки региона владельца
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmbedResolvedTarget' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/embed/widget:
    get:
      tags: [Встраивание]
      operationId: getEmbeddedWidget
      summary: Один виджет для iframe
      description: |
        Отдаёт сам виджет — вид, настройки и подпись, — но только один: разметка всего
        дашборда открыла бы ссылкой и соседние виджеты. Вход только по `X-C3-Embed`;
        расхождение адреса с целью удостоверения — `403`.
      security:
        - embedCredential: []
      parameters:
        - name: dashboardId
          in: query
          required: true
          description: Дашборд, в котором лежит виджет
          schema: { type: string, format: uuid }
        - name: widgetId
          in: query
          required: true
          description: Виджет, открытый удостоверением
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Дашборд (кратко) и виджет
          content:
            application/json:
              schema: { $ref: '#/components/schemas/EmbedWidgetResponse' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/UnprocessableEntity' }
        '429': { $ref: '#/components/responses/TooManyRequests' }
  /api/health:
    get:
      tags: [Служебные]
      operationId: health
      summary: Живость инфраструктуры
      description: |
        Проверяет Postgres, Hasura, ClickHouse и Redis. `degraded` — работает не всё,
        и в `checks` видно, что именно. Адрес публичный: его опрашивает мониторинг.
      security: []
      responses:
        '200':
          description: Состояние сервиса и каждой зависимости
          content:
            application/json:
              schema: { $ref: '#/components/schemas/HealthResponse' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: c3_…
      description: |
        API-токен из раздела «API-токены»: `Authorization: Bearer c3_…`. Действует от имени
        владельца — его роль, его ограничения строк и его арендатор. Области токена только
        сужают права владельца.
    embedCredential:
      type: apiKey
      in: header
      name: X-C3-Embed
      description: |
        Удостоверение встраивания: ссылка `c3e_…` или короткий тикет `c3t_…`. Открывает ровно
        один объект. Передавать одновременно с `Authorization` нельзя — ответ `403`.
  parameters:
    LimitQuery:
      name: limit
      in: query
      required: false
      description: Сколько строк вернуть (по умолчанию 100)
      schema: { type: integer, minimum: 1, maximum: 200, default: 100 }
    OffsetQuery:
      name: offset
      in: query
      required: false
      description: Сколько строк пропустить
      schema: { type: integer, minimum: 0, default: 0 }
    ReportIdPath:
      name: reportId
      in: path
      required: true
      description: Номер отчёта
      schema: { type: string, format: uuid }
    DashboardIdPath:
      name: dashboardId
      in: path
      required: true
      description: Номер дашборда
      schema: { type: string, format: uuid }
    ExportIdPath:
      name: exportId
      in: path
      required: true
      description: Номер строки печати
      schema: { type: string, format: uuid }
    GenerationIdPath:
      name: generationId
      in: path
      required: true
      description: Номер генерации
      schema: { type: string, format: uuid }
    EmbedCredentialQuery:
      name: credential
      in: query
      required: true
      description: Секрет удостоверения встраивания целиком
      schema: { type: string, maxLength: 500 }
  responses:
    BadRequest:
      description: 'Запрос не прошёл проверку: неверный адрес, тело или параметры'
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Нет входа или токен неизвестен, просрочен либо отозван
      headers:
        WWW-Authenticate:
          description: Способ входа — Bearer-токен
          schema: { type: string, example: Bearer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Forbidden:
      description: Вход есть, но область токена, право роли или цель встраивания не подходят
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Объект не найден или принадлежит другому арендатору
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    UnprocessableEntity:
      description: Объект есть, но документ не читается этой версией программы
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    TooManyRequests:
      description: Превышен предел запросов
      headers:
        Retry-After:
          description: Через сколько секунд повторять
          schema: { type: integer }
        X-RateLimit-Limit:
          description: Сколько запросов разрешено в окне
          schema: { type: integer }
        X-RateLimit-Remaining:
          description: Сколько осталось в текущем окне
          schema: { type: integer }
        X-RateLimit-Reset:
          description: Через сколько секунд окно сбросится
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    BadGateway:
      description: Внешняя зависимость (хранилище файлов) недоступна или ответила не JSON
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
  schemas:
    Error:
      type: object
      description: Ответ об ошибке в формате Nitro
      required: [statusCode]
      properties:
        statusCode: { type: integer, example: 401 }
        statusMessage: { type: string, example: Invalid or expired API token }
        message: { type: string }
        data:
          nullable: true
          description: Подробности проверки, если обработчик их передал
    CellValue:
      description: Значение ячейки; `null` — пустая ячейка
      oneOf:
        - type: string
        - type: number
        - type: boolean
    ReportListResponse:
      type: object
      required: [reports, total, limit, offset]
      properties:
        reports:
          type: array
          items: { $ref: '#/components/schemas/ReportSummary' }
        total: { type: integer, description: Сколько отчётов всего у арендатора }
        limit: { type: integer }
        offset: { type: integer }
    ReportSummary:
      type: object
      required: [id, name, version, datasetCount, setupStatus, createdAt, updatedAt, issues]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        version: { type: integer, description: Номер сохранённой версии шаблона }
        datasetCount: { type: integer, description: Сколько датасетов читает отчёт }
        setupStatus:
          type: string
          enum: [draft, ready]
          description: Черновик печатать нельзя
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        issues: { type: integer, description: Сколько проблем найдено в шаблоне }
    ReportDetail:
      type: object
      required:
        [
          id,
          name,
          version,
          template,
          datasets,
          setupStatus,
          draftDatasets,
          issues,
          createdAt,
          updatedAt,
          createdBy,
        ]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        version: { type: integer }
        template:
          type: object
          additionalProperties: true
          description: 'Документ шаблона отчёта: элементы, источники, параметры'
        datasets:
          type: array
          items: { $ref: '#/components/schemas/BoundDataset' }
        setupStatus:
          type: string
          enum: [draft, ready]
        draftDatasets:
          type: array
          description: 'Недонастроенные датасеты: по ним видно, что мешает печати'
          items: { $ref: '#/components/schemas/DraftDataset' }
        issues:
          type: array
          description: Проблемы, найденные в шаблоне
          items: { $ref: '#/components/schemas/TemplateIssue' }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        createdBy: { type: string, format: uuid }
    BoundDataset:
      type: object
      required: [id, datasetId, role, alias]
      properties:
        id: { type: string, format: uuid, description: Номер привязки }
        datasetId: { type: string, format: uuid }
        role: { type: string, description: Роль источника в шаблоне }
        alias:
          { type: string, description: Имя источника в шаблоне, по нему ходят пути `alias.column` }
    DraftDataset:
      type: object
      required: [id, name, mode, setupReason]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        mode: { type: string, description: 'Режим датасета: копия в ClickHouse, live или срез' }
        setupReason:
          type: string
          nullable: true
          description: Почему датасет ещё не готов
    TemplateIssue:
      type: object
      additionalProperties: true
      description: Одна проблема шаблона с путём до места и понятным текстом
    ReportRowsRequest:
      type: object
      required: [source, columns]
      properties:
        source:
          type: string
          maxLength: 64
          description: Алиас источника-строки, строки которого нужны
        columns:
          type: array
          minItems: 1
          maxItems: 120
          description: Пути `alias.column` ровно так, как они лежат в шаблоне
          items: { type: string }
        limit:
          type: integer
          minimum: 1
          maximum: 1000
          description: Сколько строк показать
        offset:
          type: integer
          minimum: 0
          maximum: 100000
          description: С какой строки начать порцию
        params:
          type: object
          additionalProperties:
            type: array
            maxItems: 200
            items: { type: string, maxLength: 500 }
          description: Значения параметров запуска — имя параметра → выбранные значения
        sort:
          type: array
          maxItems: 120
          items: { $ref: '#/components/schemas/SortItem' }
        filters:
          type: array
          maxItems: 120
          items: { $ref: '#/components/schemas/FilterItem' }
        globalFilters:
          type: array
          description: Глобальные фильтры дашборда — нужны виджету-отчёту
          items: { $ref: '#/components/schemas/DashboardFilter' }
        grouping:
          $ref: '#/components/schemas/Grouping'
    SortItem:
      type: object
      required: [column, direction]
      properties:
        column: { type: string, description: Путь `alias.column` }
        direction: { type: string, enum: [asc, desc] }
    FilterItem:
      type: object
      required: [column, op, value]
      properties:
        column: { type: string, description: Путь `alias.column` }
        op:
          type: string
          enum: ['=', '!=', '<', '<=', '>', '>=', like]
          description: Сравнение; `like` — поиск подстроки
        value: { type: string, maxLength: 500 }
        datePart:
          type: string
          enum: [full, year, quarter, month, week, day, hour]
          description: Часть даты, по которой собрано значение
    DashboardFilter:
      type: object
      required: [column, op, value]
      properties:
        column: { type: string, maxLength: 200, description: Путь `alias.column` }
        op:
          type: string
          enum: ['=', '!=', '<', '<=', '>', '>=', like, between]
          description: Сравнение; `between` — период, границы через `..`
        value: { type: string, maxLength: 500 }
    Grouping:
      type: object
      required: [groupBy, measures]
      properties:
        groupBy:
          type: array
          minItems: 1
          maxItems: 120
          items:
            type: object
            required: [column]
            properties:
              column: { type: string }
              datePart:
                type: string
                enum: [full, year, quarter, month, week, day, hour]
        measures:
          type: array
          minItems: 1
          maxItems: 120
          items:
            type: object
            required: [column, agg]
            properties:
              column: { type: string }
              agg: { type: string, enum: [sum, count, avg, min, max] }
    ReportRowsResponse:
      type: object
      required: [source, total, columns, rows, unresolved, grouped, measures, totals]
      properties:
        source: { type: string }
        total: { type: integer, description: Сколько строк всего, а не только в этой порции }
        columns:
          type: array
          description: Под каким ключом лежит значение и из какой колонки оно пришло
          items: { $ref: '#/components/schemas/ResponseColumn' }
        rows:
          type: array
          items:
            type: object
            additionalProperties: { $ref: '#/components/schemas/CellValue' }
        unresolved:
          type: array
          description: Колонки шаблона, которые не удалось найти в источнике
          items: { type: string }
        grouped: { type: boolean, description: Свернуты ли строки группировкой }
        measures:
          type: array
          description: Колонки посчитанных мер
          items: { $ref: '#/components/schemas/ResponseColumn' }
        totals:
          type: object
          additionalProperties: { $ref: '#/components/schemas/CellValue' }
    ResponseColumn:
      type: object
      required: [key, source]
      properties:
        key: { type: string, description: Короткое имя значения в строке ответа }
        source: { type: string, description: Путь колонки в документе }
    ChartDataRequest:
      type: object
      required: [elementId]
      properties:
        elementId:
          { type: string, format: uuid, description: Идентификатор элемента-графика в шаблоне }
        params:
          type: object
          additionalProperties:
            type: array
            maxItems: 200
            items: { type: string, maxLength: 500 }
        template:
          type: object
          additionalProperties: true
          description: Черновик шаблона с экрана конструктора; в просмотре и встраивании отбрасывается
    ChartDataResponse:
      type: object
      required: [columns, rows, total]
      properties:
        columns:
          type: array
          items: { $ref: '#/components/schemas/ChartColumn' }
        rows:
          type: array
          items:
            type: object
            additionalProperties: { $ref: '#/components/schemas/CellValue' }
        total:
          { type: integer, description: Сколько точек всего, если показанные упёрлись в предел }
    ChartColumn:
      type: object
      required: [key, source, kind]
      properties:
        key: { type: string }
        source: { type: string }
        kind:
          {
            type: string,
            description: Тип колонки в источнике — по нему ось подписывается как дата,
          }
        series:
          type: object
          description: Для цифры — какая это серия и как посчитана
          properties:
            index: { type: integer }
            name: { type: string }
            agg: { type: string, enum: [sum, count, avg, min, max] }
        role:
          type: string
          description: Особая роль колонки — часть «ящика с усами» или интервала гистограммы
        datePart:
          type: string
          enum: [full, year, quarter, month, week, day, hour]
    DashboardListResponse:
      type: object
      required: [dashboards, total, limit, offset]
      properties:
        dashboards:
          type: array
          items: { $ref: '#/components/schemas/DashboardSummary' }
        total: { type: integer }
        limit: { type: integer }
        offset: { type: integer }
    DashboardSummary:
      type: object
      required: [id, name, theme, version, widgets, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        theme: { type: string, description: Оформление дашборда }
        version: { type: integer }
        widgets: { type: integer, description: Число виджетов }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    DashboardDetail:
      type: object
      required: [id, name, theme, version, layout, filters, createdAt, updatedAt]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        theme: { type: string }
        version: { type: integer }
        layout:
          type: object
          additionalProperties: true
          description: Разметка дашборда; на чтении уже уложена в сетку
        filters:
          type: array
          items: { $ref: '#/components/schemas/DashboardFilter' }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    WidgetDataRequest:
      type: object
      required: [widgetId]
      properties:
        widgetId: { type: string, format: uuid }
        kind:
          type: string
          enum: [chart, metric, pivot]
          description: Вид виджета; обязателен вместе с настройками
        settings:
          type: object
          additionalProperties: true
          description: Настройки по черновику конструктора
        limit:
          type: integer
          minimum: 1
          maximum: 200
          description: Сколько точек прочитать
        filters:
          type: array
          description: Пересечения с соседними виджетами
          items: { $ref: '#/components/schemas/CrossFilter' }
        drill:
          type: array
          description: Углубление внутри графика
          items: { $ref: '#/components/schemas/CrossFilter' }
        globalFilters:
          type: array
          description: Глобальные фильтры дашборда
          items: { $ref: '#/components/schemas/DashboardFilter' }
    CrossFilter:
      type: object
      required: [column]
      properties:
        column: { type: string, maxLength: 200 }
        values:
          type: array
          maxItems: 200
          items: { type: string, maxLength: 200 }
        origin: { type: string, description: Виджет, из которого выбрали значения }
        datePart:
          type: string
          enum: [full, year, quarter, month, week, day, hour]
    WidgetDataResponse:
      description: |
        Данные виджета. У графика — `columns`, `rows` и `total`; у показателя — `values`
        и `total`; у среза — `columns`, `rows` и `truncated`.
      oneOf:
        - $ref: '#/components/schemas/ChartDataResponse'
        - $ref: '#/components/schemas/MetricDataResponse'
        - $ref: '#/components/schemas/PivotDataResponse'
    MetricDataResponse:
      type: object
      required: [values, total]
      properties:
        values:
          type: array
          items:
            type: object
            additionalProperties: true
        total: { type: integer, description: Сколько строк источника попало в расчёт }
    PivotDataResponse:
      type: object
      required: [columns, rows, truncated]
      properties:
        columns:
          type: array
          items:
            type: object
            additionalProperties: true
        rows:
          type: array
          items:
            type: array
            items: { $ref: '#/components/schemas/CellValue' }
        truncated: { type: boolean, description: За показанными строками есть ещё данные }
    ExportKind:
      type: string
      enum: [pdf, xlsx, csv, docx, html, png, json]
      description: Вид файла
    ExportStatus:
      type: string
      enum: [queued, running, done, failed, canceled]
    ExportRequest:
      type: object
      properties:
        kind: { $ref: '#/components/schemas/ExportKind' }
        params:
          type: object
          additionalProperties:
            type: array
            maxItems: 200
            items: { type: string, maxLength: 500 }
          description: Значения параметров запуска
    GenerationRequest:
      type: object
      required: [reportId]
      properties:
        reportId: { type: string, format: uuid }
        kind: { $ref: '#/components/schemas/ExportKind' }
        params:
          type: object
          additionalProperties:
            type: array
            maxItems: 200
            items: { type: string, maxLength: 500 }
    ExportOrderResponse:
      type: object
      required: [id, status]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [queued] }
    ReportExport:
      type: object
      required:
        [
          id,
          reportId,
          reportVersion,
          kind,
          status,
          error,
          fileName,
          sizeBytes,
          createdAt,
          finishedAt,
          downloadUrl,
        ]
      properties:
        id: { type: string, format: uuid }
        reportId: { type: string, format: uuid }
        reportVersion: { type: integer }
        kind: { $ref: '#/components/schemas/ExportKind' }
        status: { $ref: '#/components/schemas/ExportStatus' }
        error: { type: string, description: Причина отказа; пусто, если отказа не было }
        fileName: { type: string }
        sizeBytes: { type: integer }
        createdAt: { type: string, format: date-time }
        finishedAt: { type: string, description: Пусто, пока печать не закончилась }
        downloadUrl:
          type: string
          nullable: true
          description: Адрес файла; `null`, пока печать не готова
    GenerationSummary:
      allOf:
        - $ref: '#/components/schemas/ReportExport'
        - type: object
          required: [reportName]
          properties:
            reportName: { type: string }
    TokenUsage:
      type: object
      description: Израсходованные токены запроса и ответа
      required: [prompt_tokens, completion_tokens, total_tokens]
      properties:
        prompt_tokens: { type: integer }
        completion_tokens: { type: integer }
        total_tokens: { type: integer }
    RagRequest:
      type: object
      required: [question]
      properties:
        question: { type: string, minLength: 3, maxLength: 500 }
        stream: { type: boolean, description: Поток SSE; по умолчанию да }
    RagAnswer:
      type: object
      required: [answer, sources, grounded, usage, model, requestId]
      properties:
        answer: { type: string, description: 'Текст ответа с цитатами `[n]`' }
        sources:
          type: array
          items: { $ref: '#/components/schemas/RagSource' }
        grounded: { type: boolean, description: Есть ли в ответе хотя бы одна валидная цитата }
        usage: { $ref: '#/components/schemas/TokenUsage' }
        model: { type: string }
        requestId: { type: string }
        reason:
          type: string
          description: Причина отсутствия ответа; пусто — ответ есть
    RagSource:
      type: object
      required: [n, id, path, articleTitle, sectionTitle, titles, snippet, score, cited]
      properties:
        n: { type: integer, description: Номер цитаты в ответе }
        id: { type: string, description: Адрес раздела с якорем }
        path: { type: string, description: Адрес статьи без якоря }
        articleTitle: { type: string }
        sectionTitle: { type: string }
        titles:
          type: array
          items: { type: string }
        snippet: { type: string }
        score: { type: number }
        cited: { type: boolean, description: Ссылалась ли модель на этот источник }
    EmbedTicketRequest:
      oneOf:
        - type: object
          required: [kind, reportId]
          properties:
            kind: { type: string, enum: [report] }
            reportId: { type: string, format: uuid }
        - type: object
          required: [kind, dashboardId, widgetId]
          properties:
            kind: { type: string, enum: [widget] }
            dashboardId: { type: string, format: uuid }
            widgetId: { type: string, format: uuid }
    EmbedTicket:
      type: object
      required: [ticket, url, expiresAt]
      properties:
        ticket: { type: string, description: Секрет `c3t_…` }
        url: { type: string, description: Готовый адрес iframe }
        expiresAt: { type: string, format: date-time }
    EmbedResolvedTarget:
      type: object
      required: [kind, title, expiresAt, via, region]
      properties:
        kind: { type: string, enum: [report, widget] }
        reportId: { type: string, format: uuid }
        dashboardId: { type: string, format: uuid }
        widgetId: { type: string, format: uuid }
        title: { type: string, description: Название объекта для заголовка вкладки }
        expiresAt: { type: string, format: date-time, nullable: true }
        via: { type: string, enum: [link, ticket] }
        region:
          type: object
          description: Локаль и часовой пояс владельца удостоверения
          properties:
            locale: { type: string }
            timezone: { type: string }
    EmbedWidgetResponse:
      type: object
      required: [dashboard, widget]
      properties:
        dashboard:
          type: object
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            theme: { type: string }
        widget:
          type: object
          additionalProperties: true
          description: Вид, настройки и подпись сохранённого виджета
    HealthResponse:
      type: object
      required: [status, checks, time]
      properties:
        status: { type: string, enum: [ok, degraded] }
        checks:
          type: object
          required: [postgres, hasura, clickhouse, redis]
          properties:
            postgres: { type: string, enum: [ok, fail] }
            hasura: { type: string, enum: [ok, fail] }
            clickhouse: { type: string, enum: [ok, fail] }
            redis: { type: string, enum: [ok, fail] }
        time: { type: string, format: date-time }
