openapi: 3.0.3
info:
  title: BizHub Public API
  version: "1.3.1"
  description: |
    Read-oriented API + CRM reply loop для интеграторов и агентств.
    Self-serve кабинет остаётся основным UI; этот контракт — для CRM/скриптов.

    **Auth:** `Authorization: Bearer <jwt>` (login) **или** `Bearer bh_live_…` (API key из кабинета → Аккаунт).
    Управление ключами (`/api/v1/api-keys`) — только JWT-сессия. `GET /api/v1/me` → `auth_via`.

    **Catalog:** `GET /api/v1/integration/catalog` (public) — события webhooks + recipes + карта endpoints.
    **Samples:** `/samples/crm-webhook-receiver.py` · `/samples/helpdesk-webhook-receiver.py` (Usedesk/Bitrix).

    **Write (v1.1+):** `POST /api/v1/reviews/{id}/reply` · `…/approve` · `…/appeal`.

    **Citations (v1.3):** `FEATURE_CITATION_LAYER` — catalog / slots / niche-pack / link / recheck.
    Большинство слотов — manual-assist (URL + pack), не silent write в каталоги.

    **Presence audit (v1.3.1):** `GET …/presence-audit/latest` — последний снимок Presence Audit org.
    **Reputation (v1.3.1):** `GET …/analytics/reputation` — аспекты / keyword / ★ Δ (`FEATURE_ANALYTICS_HUB`).

    **Domclick chats (v1.3):** `FEATURE_MESSAGING_DOMCLICK` — Bearer из Домклик Pro (не OAuth);
    inbox `channel=domclick`. Внешний OpenAPI: https://public-api.domclick.ru/chats/swagger

    **Client Ops (v1.3):** `FEATURE_CLIENT_OPS` — intake / client-share / публичный `/client/{token}`.
    **Geo-grid (v1.3):** `FEATURE_GEO_GRID` — `GET/POST …/analytics/geo-grid` (2GIS Places heatmap; soft quota).
    **Ops cadence (v1.3):** `FEATURE_CLIENT_OPS` — `GET/POST …/ops-tasks*` (monthly board; portfolio `ops_due`).

    **Agency batch (v1.3):** `POST /api/v1/analytics/portfolio/reports` → client `/rpts/{token}` (cap 10).
    **Network Week share (v1.3):** `POST /api/v1/analytics/network-week/share` → публичный `GET /nw/{token}` (TTL, снимок HTML).

    **Webhooks:** HMAC `sha256(secret, "{unix}.{body}")`; headers `X-BizHub-Timestamp`, `X-BizHub-Signature`, `X-BizHub-Event`, `X-BizHub-Delivery`.

    **Честность:** live vs manual-assist — https://bizhub.com.ru/platforms
  contact:
    name: BizHub
    url: https://bizhub.com.ru/developers
  license:
    name: Proprietary

servers:
  - url: https://bizhub.com.ru
    description: Production
  - url: http://127.0.0.1:9131
    description: Local

tags:
  - name: auth
  - name: api-keys
  - name: integration
  - name: webhooks
  - name: organizations
  - name: reviews
  - name: review-invites
  - name: metrics
  - name: presence
  - name: citations
  - name: messaging
  - name: local_seo
  - name: competitors
  - name: portfolio
  - name: client_ops

paths:
  /api/v1/integration/catalog:
    get:
      tags: [integration]
      summary: Публичный каталог интеграции (events + endpoints)
      operationId: integrationCatalog
      responses:
        "200":
          description: OK — webhook_events, crm_loop, endpoints map

  /api/v1/me:
    get:
      tags: [auth]
      summary: Текущий пользователь (JWT или API key)
      operationId: getMe
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: OK — поле auth_via = jwt|api_key
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  email: { type: string }
                  role: { type: string }
                  auth_via: { type: string, enum: [jwt, api_key] }
                  organizations_count: { type: integer }
                  docs: { type: string }
                  openapi: { type: string }

  /api/v1/api-keys:
    get:
      tags: [api-keys]
      summary: Список API-ключей (только JWT)
      operationId: listAPIKeys
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items: { type: array, items: { type: object } }
                  max: { type: integer }
        "403":
          description: jwt_required (если передан API key)
    post:
      tags: [api-keys]
      summary: Создать API-ключ (только JWT; token один раз)
      operationId: createAPIKey
      security: [{ bearerAuth: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
      responses:
        "201":
          description: Created — поле token только в этом ответе
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  token: { type: string }
                  prefix: { type: string }
        "409":
          description: api_key_limit
  /api/v1/api-keys/{id}:
    delete:
      tags: [api-keys]
      summary: Отозвать ключ (только JWT)
      operationId: revokeAPIKey
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
        "404":
          description: not_found

  /api/v1/organizations/{id}/webhooks:
    get:
      tags: [webhooks]
      summary: Список outbound webhooks
      operationId: listWebhooks
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK
    post:
      tags: [webhooks]
      summary: Создать webhook (secret один раз)
      operationId: createWebhook
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url: { type: string, format: uri }
                events: { type: string, example: review.created }
      responses:
        "201":
          description: Created — поле secret только в этом ответе
        "409":
          description: webhook_limit

  /api/v1/organizations/{id}/webhooks/{hookId}/deliveries:
    get:
      tags: [webhooks]
      summary: Лог последних доставок endpoint
      operationId: listWebhookDeliveries
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
        - name: hookId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK — до 20 последних попыток (attempt, status, error)
        "404":
          description: not_found

  /api/v1/auth/login:
    post:
      tags: [auth]
      summary: Получить JWT
      operationId: authLogin
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, format: email }
                password: { type: string, format: password }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  user:
                    type: object
                    properties:
                      id: { type: string }
                      email: { type: string }
                      role: { type: string }
        "401":
          $ref: "#/components/responses/Error"

  /api/v1/organizations:
    get:
      tags: [organizations]
      summary: Список организаций текущего пользователя
      operationId: listOrganizations
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Organization" }
        "401":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/completeness:
    get:
      tags: [presence]
      summary: Score заполненности карточек (Google/Яндекс/2GIS/Zoon/Avito)
      operationId: orgCompleteness
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/dgis/card-pack:
    get:
      tags: [presence]
      summary: 2GIS Card Ops — pack NAP / описание / прайс / фото / gaps
      description: |
        Требует `FEATURE_DGIS_CARD_OPS=1`. Clipboard-блоки для ручной вставки в кабинет 2GIS.
        Silent write нет без partner WriteClient.
      operationId: orgDGISCardPack
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — blocks.nap|description|price|media|attrs + gaps + deep-links
        "404":
          description: feature_dgis_card_ops_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/dgis/price-export:
    get:
      tags: [presence]
      summary: 2GIS Card Ops — табличный export прайса (service/product)
      operationId: orgDGISPriceExport
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — export_text, items, count, cabinet_url
        "404":
          description: feature_dgis_card_ops_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/dgis/card-fill/done:
    post:
      tags: [presence]
      summary: Audit mark-done после ручного fill в кабинете 2GIS
      operationId: orgDGISCardFillDone
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                block:
                  type: string
                  description: nap|description|price|media|attrs|reviews|all
                note:
                  type: string
      responses:
        "200":
          description: OK — audit dgis.card_fill.*
        "404":
          description: feature_dgis_card_ops_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/card-quality:
    get:
      tags: [presence]
      summary: Inbox подсказок качества карточки (H5)
      operationId: orgCardQuality
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — suggested fixes + Q&A playbook
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/presence-audit/latest:
    get:
      tags: [presence]
      summary: Последний Presence Audit снимок org (score / NAP / gaps)
      operationId: orgPresenceAuditLatest
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — audit payload или empty если ещё не запускали
        "404":
          description: feature_presence_audit_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/analytics/reputation:
    get:
      tags: [portfolio]
      summary: Репутация — аспекты, keyword, ★, SLA, geo-пин
      description: |
        Требует `FEATURE_ANALYTICS_HUB`. Aspects из Opportunity Lab (если вкл).
        Без scrape конкурентов. Поле `geo` — агрегаты inbox на lat/lon бизнеса
        (не география авторов отзывов); `available=false` без координат match.
      operationId: orgAnalyticsReputation
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
        - name: days
          in: query
          schema: { type: integer, default: 30, maximum: 90 }
        - name: q
          in: query
          schema: { type: string }
          description: Keyword search по тексту отзывов
      responses:
        "200":
          description: OK — aspects, keyword_hits, platforms, staff, competitors_rating, geo, brief
        "404":
          description: feature_analytics_hub_disabled
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/citations:
    get:
      tags: [citations]
      summary: Сводка Присутствия (canon NAP + listings + health)
      description: |
        Требует `FEATURE_CITATION_LAYER=1`. Core/national/services/niche слоты;
        режимы match_only / live_read / manual_only — см. `/platforms`.
      operationId: orgCitationsGet
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — canon, listings, limits, health
        "404":
          description: feature_citation_layer_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/citations/catalog:
    get:
      tags: [citations]
      summary: Каталог целей цитирования (enabled platforms + tiers)
      description: |
        Seed каталога (core / national / services / niche / reviews / legitimacy / ads).
        Не создаёт слоты org — только справочник для UI/CRM.
      operationId: orgCitationsCatalog
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — items[] с key, tier, default_mode, niche_tags
        "404":
          description: feature_citation_layer_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/citations/slots:
    post:
      tags: [citations]
      summary: Добавить optional слот из каталога (лимит тарифа)
      operationId: orgCitationsAddSlot
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [target_key]
              properties:
                target_key:
                  type: string
                  description: catalog key (например cian, profi, otzovik)
      responses:
        "200":
          description: OK — listing создан / уже есть
        "409":
          description: citation_slot_limit | citation_unknown_target
        "404":
          description: feature_citation_layer_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/citations/slots/{target}:
    delete:
      tags: [citations]
      summary: Убрать optional слот (core не удаляются)
      operationId: orgCitationsRemoveSlot
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
        - name: target
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
        "404":
          description: feature_citation_layer_off | not_found
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/citations/niche-pack:
    get:
      tags: [citations]
      summary: Niche pack для org (realty / medicine / …) + preview activate
      operationId: orgCitationsNichePack
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — pack key, recommended targets, activate preview
        "404":
          description: feature_citation_layer_off | citation_no_niche_pack
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/citations/niche-pack/activate:
    post:
      tags: [citations]
      summary: Активировать niche pack (добавить рекомендованные слоты в лимит)
      operationId: orgCitationsNichePackActivate
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                target_keys:
                  type: array
                  items: { type: string }
                  description: subset of pack targets; empty = all recommended within plan limit
      responses:
        "200":
          description: OK — added / skipped / limit
        "400":
          description: citation_no_niche_pack | citation_pack_target | citation_activate_empty
        "404":
          description: feature_citation_layer_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/citations/{target}/link:
    post:
      tags: [citations]
      summary: Привязать URL / external_id к слоту (manual match)
      operationId: orgCitationsLink
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
        - name: target
          in: path
          required: true
          schema: { type: string }
          description: catalog key (domclick, cian, profi, …)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string }
                external_id: { type: string }
      responses:
        "200":
          description: OK — normalized URL + listing
        "400":
          description: invalid_url
        "404":
          description: feature_citation_layer_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/citations/recheck:
    post:
      tags: [citations]
      summary: Перепроверить NAP / статусы слотов (soft daily cap)
      operationId: orgCitationsRecheck
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK
        "429":
          description: citation_recheck_cap
        "404":
          description: feature_citation_layer_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/messaging/domclick:
    put:
      tags: [messaging]
      summary: Подключить Домклик чаты (Bearer Pro + company_id)
      description: |
        Требует `FEATURE_MESSAGING_LITE` + `FEATURE_MESSAGING_DOMCLICK`.
        Токен выдаёт поддержка Домклик Pro («Выпуск ключа авторизации для API-чатов») —
        self-serve OAuth нет. Хранится в `domclick_token_enc` (не в `.env`).
      operationId: orgMessagingDomclickPut
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  description: Bearer ACCESS TOKEN из Pro
                company_id:
                  type: string
                  description: ID агентства (agencies.domclick.ru/agency/{id})
      responses:
        "200":
          description: OK — connected; webhook subscribe best-effort
        "404":
          description: feature_messaging_domclick_off
        "403":
          $ref: "#/components/responses/Error"
    delete:
      tags: [messaging]
      summary: Отключить Домклик чаты (сброс токена)
      operationId: orgMessagingDomclickDelete
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK
        "404":
          description: feature_messaging_domclick_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/messaging/domclick/sync:
    post:
      tags: [messaging]
      summary: Sync чатов Домклик → conversations channel=domclick
      operationId: orgMessagingDomclickSync
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — imported / updated counts
        "401":
          description: domclick_token_invalid
        "404":
          description: feature_messaging_domclick_off | not_connected
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/messaging/domclick/agents:
    get:
      tags: [messaging]
      summary: Снимок агентов Домклик + map agent_id→user_id
      operationId: orgMessagingDomclickAgentsGet
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — agents + mappings
        "404":
          description: feature_messaging_domclick_off
        "403":
          $ref: "#/components/responses/Error"
    put:
      tags: [messaging]
      summary: Soft map Domclick agent_id → org member (не hard ACL)
      operationId: orgMessagingDomclickAgentsPut
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mappings]
              properties:
                mappings:
                  type: object
                  additionalProperties: { type: string }
                  description: "{ \"<agent_id>\": \"<user_id>\" }"
      responses:
        "200":
          description: OK — backfill assignee на существующих чатах
        "404":
          description: feature_messaging_domclick_off
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/messaging/domclick/webhook/{orgId}:
    post:
      tags: [messaging]
      summary: Inbound webhook Домклик чатов (public, rate-limit)
      description: |
        Подписывается best-effort при PUT/Sync. 120 req / org / мин.
        Не требует JWT; orgId в path.
      operationId: messagingDomclickWebhook
      parameters:
        - name: orgId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema: { type: object }
      responses:
        "200":
          description: OK
        "429":
          description: rate_limited

  /api/v1/organizations/{id}/knowledge-pack:
    get:
      tags: [organizations]
      summary: Knowledge pack org (NAP, catalog, proof, visual, sources)
      description: |
        Требует `FEATURE_ORG_BRAND_PACK=1`. Единый контекст для статей, ads, экспортов и AI-черновиков.
      operationId: orgKnowledgePack
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK
        "404":
          description: feature_org_brand_pack_disabled
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/brand-kit:
    get:
      tags: [organizations]
      summary: Visual brand kit (logo + reference photos)
      operationId: orgBrandKitGet
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK
        "404":
          description: feature disabled
    put:
      tags: [organizations]
      summary: Обновить brand kit
      operationId: orgBrandKitPut
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK
        "404":
          description: feature disabled

  /api/v1/organizations/{id}/brand-kit/seed:
    post:
      tags: [organizations]
      summary: Seed brand kit из media roles (+ LLM style notes при FEATURE_AI_LLM)
      operationId: orgBrandKitSeed
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — BrandKit (palette_source seed|seed_llm|manual)
        "404":
          description: feature disabled
        "429":
          description: brand_pack_refresh_rate_limited

  /api/v1/organizations/{id}/platform-copy:
    get:
      tags: [organizations]
      summary: Clipboard-текст для ручной публикации на площадке
      operationId: orgPlatformCopy
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
        - name: platform
          in: query
          schema: { type: string, enum: [2gis, zoon, avito, google, yandex] }
        - name: title
          in: query
          schema: { type: string }
        - name: price
          in: query
          schema: { type: string }
      responses:
        "200":
          description: OK — export_text + checklist
        "404":
          description: feature disabled

  /api/v1/organizations/{id}/positions:
    get:
      tags: [local_seo]
      summary: Локальные позиции по запросам (Local SEO)
      operationId: orgPositions
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK (status может быть no_api_key / ok)
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/reviews:
    get:
      tags: [reviews]
      summary: Inbox отзывов
      operationId: listReviews
      security: [{ bearerAuth: [] }]
      parameters:
        - name: organization_id
          in: query
          schema: { type: string }
        - name: platform
          in: query
          schema: { type: string, enum: [google, yandex, 2gis, avito, zoon] }
        - name: sentiment
          in: query
          schema: { type: string, enum: [positive, neutral, negative] }
        - name: replied
          in: query
          schema: { type: string, enum: ["true", "false"] }
        - name: sla_overdue
          in: query
          schema: { type: string, enum: ["1", "true"] }
        - name: approve_queue
          in: query
          schema: { type: string, enum: ["1", "true"] }
        - name: appeal_requested
          in: query
          schema: { type: string, enum: ["1", "true"] }
        - name: ops_status
          in: query
          description: Agency ops pipeline (G6) — derived поверх reply_status
          schema:
            type: string
            enum: [new, drafting, waiting_approve, published, appealed, ignored]
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Review" }
                  sla_overdue_count: { type: integer }
                  approve_queue_count: { type: integer }
        "401":
          $ref: "#/components/responses/Error"

  /api/v1/reviews/{id}/reply:
    post:
      tags: [reviews]
      summary: Ответить на отзыв (очередь + publish на площадку)
      operationId: replyReview
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, minLength: 1 }
      responses:
        "200":
          description: OK — status published|failed|queued; шлёт webhook review.replied
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  published: { type: boolean }
                  review: { $ref: "#/components/schemas/Review" }
                  publish_error: { type: string }
        "401":
          $ref: "#/components/responses/Error"
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/reviews/{id}/approve:
    post:
      tags: [reviews]
      summary: Опубликовать ответ из очереди pending_approve
      operationId: approveReview
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                text: { type: string, description: "Опционально заменить текст ответа перед publish" }
      responses:
        "200":
          description: OK — шлёт webhook review.replied при успехе
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  published: { type: boolean }
                  review: { $ref: "#/components/schemas/Review" }
        "400":
          $ref: "#/components/responses/Error"
        "401":
          $ref: "#/components/responses/Error"
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/reviews/{id}/appeal:
    post:
      tags: [reviews]
      summary: Метка обжалования (manual-assist, без удаления с площадки)
      operationId: appealReview
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                requested: { type: boolean }
                reason: { type: string }
      responses:
        "200":
          description: OK
        "401":
          $ref: "#/components/responses/Error"

  /api/v1/reviews/{id}/ignore:
    post:
      tags: [reviews]
      summary: Ops ignore — скрыть/вернуть в активный pipeline (G6; не меняет reply_status)
      operationId: ignoreReview
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ignored: { type: boolean }
      responses:
        "200":
          description: OK — review + ops_status
        "401":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/review-invites:
    get:
      tags: [review-invites]
      summary: Список email-кампаний просьбы об отзыве
      operationId: listReviewInvites
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK
    post:
      tags: [review-invites]
      summary: Создать кампанию (consent + emails; опц. send_now)
      operationId: createReviewInvite
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [emails, consent]
              properties:
                emails: { type: string, description: "Список email через запятую/строку" }
                consent: { type: boolean }
                subject: { type: string }
                body_text: { type: string, description: "Должен содержать {{link}}" }
                send_now: { type: boolean }
      responses:
        "201":
          description: Created
        "409":
          description: campaign_limit / daily_limit

  /api/v1/metrics:
    get:
      tags: [metrics]
      summary: Метрики организации (рейтинг, % ответов, тренды)
      operationId: getMetrics
      security: [{ bearerAuth: [] }]
      parameters:
        - name: organization_id
          in: query
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OrgMetrics" }
        "401":
          $ref: "#/components/responses/Error"
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/organizations/{id}/competitors/light:
    get:
      tags: [competitors]
      summary: Соседи рядом (2GIS / Google / Yandex) + Δ7д
      operationId: competitorsLight
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
        - name: platform
          in: query
          schema: { type: string, enum: [2gis, google, yandex] }
        - name: radius_m
          in: query
          schema: { type: integer, default: 1500 }
        - name: limit
          in: query
          schema: { type: integer, default: 20 }
      responses:
        "200":
          description: OK (status может быть no_api_key / no_match / ok)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  hint: { type: string }
                  summary: { type: object, additionalProperties: true }
                  items: { type: array, items: { type: object } }
        "401":
          $ref: "#/components/responses/Error"
        "403":
          $ref: "#/components/responses/Error"

  /api/v1/analytics/portfolio:
    get:
      tags: [portfolio]
      summary: Портфель организаций (агентство / сеть)
      operationId: analyticsPortfolio
      security: [{ bearerAuth: [] }]
      parameters:
        - name: niche
          in: query
          schema: { type: string }
        - name: city
          in: query
          schema: { type: string }
        - name: sort
          in: query
          schema: { type: string, enum: [overall, unanswered, completeness, name] }
        - name: critical
          in: query
          schema: { type: string, enum: ["1", "true"] }
      responses:
        "200":
          description: OK (нужен FEATURE_ANALYTICS_HUB)
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/PortfolioOrgRow" }
                  generated_at: { type: string, format: date-time }
                  facets:
                    type: object
                    properties:
                      niches: { type: array, items: { type: string } }
                      cities: { type: array, items: { type: string } }
        "404":
          description: feature_analytics_hub_disabled
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /api/v1/analytics/network-week.html:
    get:
      tags: [portfolio]
      summary: HTML-сводка «сеть за неделю» (печатная)
      operationId: networkWeekHTML
      security: [{ bearerAuth: [] }]
      responses:
        "200":
          description: HTML document
          content:
            text/html:
              schema: { type: string }
        "404":
          description: feature_analytics_hub_disabled

  /api/v1/analytics/network-week/share:
    post:
      tags: [portfolio]
      summary: Публичная ссылка на снимок «сеть за неделю»
      description: |
        Создаёт TTL-токен и сохраняет HTML-снимок текущего фильтра портфеля.
        Нужны `FEATURE_ANALYTICS_HUB` + `FEATURE_AGENCY_REPORTS`. Публичный URL без `:8443`.
      operationId: networkWeekShare
      security: [{ bearerAuth: [] }]
      parameters:
        - name: niche
          in: query
          schema: { type: string }
        - name: city
          in: query
          schema: { type: string }
        - name: critical
          in: query
          schema: { type: string, enum: ["1", "true"] }
        - name: pack_attention
          in: query
          schema: { type: string, enum: ["1", "true"] }
        - name: ads_attention
          in: query
          schema: { type: string, enum: ["1", "true"] }
        - name: slots_attention
          in: query
          schema: { type: string, enum: ["1", "true"] }
        - name: boost_needed
          in: query
          schema: { type: string, enum: ["1", "true"] }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                ttl_days: { type: integer, default: 14, maximum: 90 }
      responses:
        "200":
          description: OK — share_url /nw/{token}
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  id: { type: string }
                  org_count: { type: integer }
                  share_token: { type: string }
                  share_expires_at: { type: string, format: date-time }
                  share_url: { type: string }
                  filter: { type: string }
        "404":
          description: feature_agency_reports_disabled | feature_analytics_hub_disabled

  /nw/{token}:
    get:
      tags: [portfolio]
      summary: Публичный HTML-снимок «сеть за неделю»
      operationId: publicNetworkWeekShare
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: HTML snapshot
          content:
            text/html:
              schema: { type: string }
        "410":
          description: network_week_share_expired
        "404":
          description: not found / feature off

  /api/v1/analytics/portfolio/reports:
    post:
      tags: [portfolio]
      summary: Batch Agency Reports для текущего фильтра портфеля (cap 10)
      description: |
        Создаёт client-facing отчёты (`/rpts/{token}`) по org из того же фильтра, что
        `GET /api/v1/analytics/portfolio`. Нужны `FEATURE_ANALYTICS_HUB` + `FEATURE_AGENCY_REPORTS`.
      operationId: analyticsPortfolioReportsBatch
      security: [{ bearerAuth: [] }]
      parameters:
        - name: niche
          in: query
          schema: { type: string }
        - name: city
          in: query
          schema: { type: string }
        - name: sort
          in: query
          schema: { type: string }
        - name: critical
          in: query
          schema: { type: string, enum: ["1", "true"] }
        - name: boost_needed
          in: query
          schema: { type: string, enum: ["1", "true"] }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                period_days: { type: integer, default: 7 }
                tone: { type: string, description: agency | client }
                cap: { type: integer, maximum: 10, default: 10 }
      responses:
        "200":
          description: OK — created/skipped items + share URLs
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  created: { type: integer }
                  skipped: { type: integer }
                  cap: { type: integer }
                  period_days: { type: integer }
                  tone: { type: string }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        organization_id: { type: string }
                        name: { type: string }
                        report_id: { type: string }
                        share_url: { type: string }
                        share_expires_at: { type: string, format: date-time }
                        skipped: { type: boolean }
                        skip_reason: { type: string }
                        error: { type: string }
        "404":
          description: feature_agency_reports_disabled | feature_analytics_hub_disabled

  /api/v1/organizations/{id}/intake:
    get:
      tags: [client_ops]
      summary: Client Ops intake (Master NAP)
      operationId: orgClientIntakeGet
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — completed + payload_json
        "404":
          description: feature_client_ops_disabled
    put:
      tags: [client_ops]
      summary: Upsert intake (+ optional apply NAP to org)
      operationId: orgClientIntakePut
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                phone: { type: string }
                address: { type: string }
                city: { type: string }
                website: { type: string }
                niche: { type: string }
                goals: { type: string }
                notes: { type: string }
                apply_to_org: { type: boolean }
                mark_complete: { type: boolean }
                extras_json: { type: string }
      responses:
        "200":
          description: OK
        "404":
          description: feature_client_ops_disabled

  /api/v1/organizations/{id}/client-share:
    post:
      tags: [client_ops]
      summary: Создать публичную ссылку /client/{token}
      operationId: orgClientShareCreate
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                ttl_days: { type: integer, default: 14, maximum: 90 }
                scopes: { type: string, description: "map,scorecard,advice" }
                label: { type: string }
      responses:
        "200":
          description: OK — share_url, share_token, share_expires_at
        "404":
          description: feature_client_ops_disabled

  /api/v1/organizations/{id}/client-shares:
    get:
      tags: [client_ops]
      summary: Список активных Client View ссылок (до 10)
      operationId: orgClientSharesList
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — items[]
        "404":
          description: feature_client_ops_disabled

  /api/v1/organizations/{id}/client-shares/{shareId}:
    delete:
      tags: [client_ops]
      summary: Отозвать Client View ссылку (токен сразу невалиден)
      operationId: orgClientShareRevoke
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
        - name: shareId
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK — ok, id
        "404":
          description: not_found | feature_client_ops_disabled

  /api/v1/public/client/{token}:
    get:
      tags: [client_ops]
      summary: Публичный Client View (scorecard + advice)
      operationId: publicClientView
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK
        "404":
          description: not_found | feature_disabled
        "410":
          description: expired
        "429":
          description: rate_limited

  /api/v1/public/client/{token}/map:
    get:
      tags: [client_ops]
      summary: Публичная карта филиалов (без map_key)
      operationId: publicClientMap
      parameters:
        - name: token
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK — markers + attribution
        "404":
          description: not_found | feature_disabled

  /api/v1/organizations/{id}/analytics/geo-grid:
    get:
      tags: [client_ops]
      summary: Последний geo-grid run + квота (FEATURE_GEO_GRID)
      operationId: orgGeoGridGet
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — run, quota, defaults
        "404":
          description: feature_geo_grid_disabled
    post:
      tags: [client_ops]
      summary: Enqueue geo-grid 2GIS (N×N Places relevance)
      operationId: orgGeoGridEnqueue
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                size: { type: integer, enum: [3, 5, 7], default: 5 }
                step_m: { type: integer, minimum: 100, maximum: 1000, default: 250 }
                queries: { type: array, items: { type: string }, maxItems: 3 }
                sync: { type: boolean, default: false }
      responses:
        "200":
          description: OK — status + run
        "404":
          description: feature_geo_grid_disabled
        "429":
          description: quota_exceeded

  /api/v1/ops-tasks:
    get:
      tags: [client_ops]
      summary: Доска ops_tasks по всем org пользователя
      operationId: opsTasksBoard
      security: [{ bearerAuth: [] }]
      parameters:
        - name: due
          in: query
          schema: { type: string, enum: ["1"] }
        - name: status
          in: query
          schema: { type: string }
        - name: kind
          in: query
          schema: { type: string }
      responses:
        "200":
          description: OK — items[]
        "404":
          description: feature_client_ops_disabled

  /api/v1/organizations/{id}/ops-tasks:
    get:
      tags: [client_ops]
      summary: Ops tasks одной org
      operationId: orgOpsTasksList
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      responses:
        "200":
          description: OK — items[]
        "404":
          description: feature_client_ops_disabled

  /api/v1/organizations/{id}/ops-tasks/generate:
    post:
      tags: [client_ops]
      summary: Сгенерировать monthly / playbook tasks
      operationId: orgOpsTasksGenerate
      security: [{ bearerAuth: [] }]
      parameters:
        - $ref: "#/components/parameters/OrgID"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                preset: { type: string, enum: [monthly, local_seo_month, realty_presence_month] }
                also_auto: { type: boolean }
      responses:
        "200":
          description: OK — created[]
        "404":
          description: feature_client_ops_disabled

  /api/v1/ops-tasks/{id}/claim:
    post:
      tags: [client_ops]
      summary: Взять задачу (todo→doing)
      operationId: opsTaskClaim
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK — OpsTask

  /api/v1/ops-tasks/{id}/complete:
    post:
      tags: [client_ops]
      summary: Закрыть задачу; kind=report → Agency Report
      operationId: opsTaskComplete
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK — OpsTask (+ report_id)

  /api/v1/ops-tasks/{id}/snooze:
    post:
      tags: [client_ops]
      summary: Отложить задачу (1–30д)
      operationId: opsTaskSnooze
      security: [{ bearerAuth: [] }]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                days: { type: integer, minimum: 1, maximum: 30, default: 7 }
      responses:
        "200":
          description: OK — OpsTask


components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    OrgID:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }

  responses:
    Error:
      description: Error
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }

  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
    Organization:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        slug: { type: string }
        niche: { type: string }
        city: { type: string }
        city_id: { type: string }
        phone: { type: string }
        website: { type: string }
        address: { type: string }
        plan: { type: string }
        plan_status: { type: string }
    Review:
      type: object
      properties:
        id: { type: string }
        organization_id: { type: string }
        platform: { type: string }
        rating: { type: integer }
        author: { type: string }
        text: { type: string }
        sentiment: { type: string }
        replied: { type: boolean }
        reply_status: { type: string }
        ops_status:
          type: string
          description: Derived agency pipeline (G6)
          enum: [new, drafting, waiting_approve, published, appealed, ignored]
        ops_ignored: { type: boolean }
        created_at: { type: string, format: date-time }
    OrgMetrics:
      type: object
      properties:
        review_count: { type: integer }
        avg_rating: { type: number }
        replied_pct: { type: number }
        unanswered: { type: integer }
        sla_overdue: { type: integer }
        platforms: { type: array, items: { type: object } }
        trends: { type: array, items: { type: object } }
    PortfolioOrgRow:
      type: object
      properties:
        organization_id: { type: string }
        name: { type: string }
        city: { type: string }
        niche: { type: string }
        overall: { type: number }
        label: { type: string }
        unanswered: { type: integer }
        completeness: { type: number }
        top_advice_title: { type: string }
