openapi: 3.1.0
info:
  title: oddzwon.pl API
  version: 1.0.0
  description: |
    REST API systemu oddzwonień oddzwon.pl. Wygenerowane ze źródła prawdy: apps/api/src/server.ts.

    Poziomy dostępu:
    * **public_key** (pk_...) w ciele żądania: tylko tworzenie zgłoszeń callback (widget, formularze).
    * **Bearer** w nagłówku Authorization: klucz zarządczy (management_key) LUB token sesji użytkownika panelu (sess_...). Role: owner (pełny), agent (pełny bez użytkowników i billingu), viewer (tylko GET).
    * **Mobilne** (/v1/app/*, /v1/calls/*, /v1/presence, /v1/devices): auth w ciele żądania przez public_key + consultant_id, a dla danych z PII dodatkowo fcm_token urządzenia.

    Wszystkie czasy w bazie w UTC, rendering w Europe/Warsaw. Format: JSON, HTTPS.
servers:
  - url: https://api.oddzwon.pl
tags:
  - name: Publiczne
    description: Endpointy bez nagłówka Authorization (widget, monitoring).
  - name: Auth
    description: Logowanie i sesje użytkowników panelu.
  - name: Management
    description: API panelu. Auth Bearer (management_key lub token sesji sess_...).
  - name: Mobilne
    description: Endpointy aplikacji konsultanta (Android). Auth przez public_key + consultant_id + fcm_token w ciele żądania (POST, żeby token nie trafiał do logów proxy).
  - name: MCP
    description: Serwer MCP (JSON-RPC 2.0) dla agentów AI.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: management_key tenanta (rola owner) LUB token sesji użytkownika panelu (sess_..., rola z konta).

  parameters:
    IdParam:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: Identyfikator zasobu (UUID).

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
          description: Kod błędu (stały, maszynowy).
      required: [error]

    CallbackStatus:
      type: string
      description: Status zgłoszenia callback (kanoniczna taksonomia, enum callback_status w Postgres).
      enum: [pending, ringing, accepted, called, missed, saved, abandoned, expired]

    Disposition:
      type: string
      description: Dyspozycja rozmowy raportowana przez konsultanta (enum disposition w Postgres).
      enum: [connected, no_answer, wrong_number, scheduled, not_interested, callback_later]

    Presence:
      type: string
      description: Obecność konsultanta (enum presence w Postgres).
      enum: [available, busy, away, offline]

    Intake:
      type: object
      description: Zgłoszenie oddzwonienia (IntakeSchema). Pole company to honeypot antyspamowy, wypełnione powoduje ciche odrzucenie z odpowiedzią 202.
      properties:
        public_key:
          type: string
          minLength: 6
          maxLength: 128
          description: Publiczny klucz tenanta (pk_...).
        phone:
          type: string
          minLength: 6
          maxLength: 24
          description: Numer telefonu klienta (normalizowany do E.164, domyślnie PL).
        name:
          type: string
          maxLength: 80
          description: Imię klienta (opcjonalne).
        consent:
          type: boolean
          description: Zgoda na kontakt. Wymagane true, inaczej 422 consent_required.
        consent_text:
          type: string
          maxLength: 2000
          description: Pełny tekst zgody widoczny dla klienta (dowód RODO).
        consent_version:
          type: string
          maxLength: 40
          description: Wersja tekstu zgody.
        page_url:
          type: string
          maxLength: 2048
          description: URL strony, z której wysłano zgłoszenie.
        referrer:
          type: string
          maxLength: 2048
          description: Referrer przeglądarki.
        utm:
          type: object
          additionalProperties:
            type: string
            maxLength: 200
          description: Parametry UTM (utm_source, utm_medium itd.).
        gclid:
          type: string
          maxLength: 200
          description: Google Click ID (atrybucja Ads).
        mode:
          type: string
          enum: [immediate, scheduled]
          default: immediate
          description: immediate = dzwoń teraz, scheduled = umów termin (wymaga preferred_slot).
        preferred_slot:
          type: string
          maxLength: 64
          description: 'Termin dla mode=scheduled w formacie "YYYY-MM-DD HH:mm" lub "YYYY-MM-DD HH:mm:ss", czas Europe/Warsaw. Musi być w przyszłości (tolerancja 60 s).'
          examples: ["2026-07-03 14:30"]
        company:
          type: string
          maxLength: 200
          description: Honeypot. Zostaw puste. Wypełnione = 202 bez przetwarzania.
        test:
          type: boolean
          description: Ping testowy (podgląd widgetu). Zwraca 202 ze statusem test, bez zapisu leada.
        idempotency_key:
          type: string
          maxLength: 128
          description: Klucz idempotencji klienta. Bez niego dedup po (tenant, telefon, okno czasowe).
      required: [public_key, phone, consent]

    IntakeAccepted:
      type: object
      properties:
        request_id:
          type: string
          format: uuid
        status:
          type: string
          description: Status początkowy (np. ringing, pending, missed, test).
        dedup:
          type: boolean
          description: true = duplikat w oknie dedup, zwrócono istniejące zgłoszenie.
        consultant_id:
          type: [string, "null"]
          description: Konsultant, do którego ruszył dispatch (jeśli routing wystartował).
      required: [request_id, status, dedup]

    MobileAuth:
      type: object
      description: Auth aplikacji konsultanta w ciele żądania.
      properties:
        public_key:
          type: string
          minLength: 6
        consultant_id:
          type: string
          format: uuid
        fcm_token:
          type: string
          minLength: 16
          maxLength: 4096
          description: Token FCM aktywnego urządzenia (zna go tylko ten telefon, pełni rolę tokenu urządzenia).
      required: [public_key, consultant_id, fcm_token]

    CallAct:
      type: object
      description: Akcja na zgłoszeniu (auth przez public_key + consultant_id).
      properties:
        public_key:
          type: string
          minLength: 6
        consultant_id:
          type: string
          format: uuid
        reason:
          type: string
          maxLength: 60
          description: Powód (używane przy decline, domyślnie rejected).
      required: [public_key, consultant_id]

    QueueItem:
      type: object
      description: Nieodebrane zgłoszenie w kolejce do oddzwonienia.
      properties:
        id:
          type: string
          format: uuid
        phone_e164:
          type: string
        name:
          type: [string, "null"]
        created_at:
          type: string
          format: date-time
        attempt_count:
          type: integer

    HistoryItem:
      type: object
      properties:
        id:
          type: string
          format: uuid
        phone_e164:
          type: string
        status:
          $ref: "#/components/schemas/CallbackStatus"
        mode:
          type: string
          enum: [immediate, scheduled]
        preferred_slot:
          type: [string, "null"]
        scheduled_at:
          type: [string, "null"]
          format: date-time
        attempt_count:
          type: integer
        created_at:
          type: string
          format: date-time
        ended_at:
          type: [string, "null"]
          format: date-time
        consultant:
          type: [string, "null"]
          description: Nazwa przypisanego konsultanta.

    Consultant:
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        phone_e164:
          type: string
        active:
          type: boolean
        presence:
          $ref: "#/components/schemas/Presence"
        position:
          type: integer
          description: Pozycja w kolejności round-robin.
        last_seen_at:
          type: [string, "null"]
          format: date-time

    OkResult:
      type: object
      properties:
        ok:
          type: boolean
        reason:
          type: string
          description: Kod przyczyny przy ok=false.
      required: [ok]

  responses:
    Unauthorized:
      description: Brak lub nieprawidłowy Bearer (management_key albo sesja).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example: { error: unauthorized }
    Forbidden:
      description: Rola nie pozwala na operację (viewer nie mutuje, users/billing/erase tylko owner).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example: { error: forbidden }
    InvalidPayload:
      description: Nieprawidłowe dane wejściowe.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example: { error: invalid_payload }

paths:
  /health:
    get:
      tags: [Publiczne]
      operationId: getHealth
      summary: Stan usługi
      description: Healthcheck. Zwraca status, dostępność bazy i uptime procesu.
      responses:
        "200":
          description: Usługa działa.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  db: { type: boolean }
                  uptime: { type: number }
              example: { status: ok, db: true, uptime: 12345.6 }

  /v1/callback:
    post:
      tags: [Publiczne]
      operationId: createCallback
      summary: Zgłoś oddzwonienie
      description: |
        Publiczny intake zgłoszeń callback (widget, formularze, integracje). Auth przez public_key w ciele.
        Dedup po kluczu naturalnym (tenant + telefon E.164 + okno czasowe) lub idempotency_key.
        Dla mode=scheduled wymagany poprawny preferred_slot w przyszłości (czas Europe/Warsaw).
        Gdy tenant ma listę dozwolonych originów, żądania z innych stron dostają 403 origin_forbidden.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Intake"
            examples:
              natychmiast:
                summary: Dzwoń teraz
                value:
                  public_key: pk_demo123
                  phone: "+48500100200"
                  name: Jan
                  consent: true
                  consent_text: Zgadzam się na kontakt telefoniczny.
                  consent_version: v2
                  page_url: https://example.pl/oferta
                  utm: { utm_source: google, utm_medium: cpc }
                  gclid: EAIaIQ
                  mode: immediate
              umow_termin:
                summary: Umów termin
                value:
                  public_key: pk_demo123
                  phone: "500100200"
                  consent: true
                  mode: scheduled
                  preferred_slot: "2026-07-03 14:30"
      responses:
        "202":
          description: Zgłoszenie przyjęte (routing wystartował lub duplikat zwrócony).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/IntakeAccepted"
              examples:
                nowe:
                  value: { request_id: 6f1f0a3e-8f5c-4d2a-9c1b-0e6a2b7c9d10, status: ringing, dedup: false, consultant_id: 2b9e6a10-4c1d-4f7e-8a2b-3c4d5e6f7a80 }
                duplikat:
                  value: { request_id: 6f1f0a3e-8f5c-4d2a-9c1b-0e6a2b7c9d10, status: ringing, dedup: true, consultant_id: null }
        "403":
          description: Nieznany public_key albo origin spoza listy dozwolonych.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalid_tenant:
                  value: { error: invalid_tenant }
                origin_forbidden:
                  value: { error: origin_forbidden }
        "422":
          description: Nieprawidłowe dane (walidacja zod, zgoda, telefon, slot).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalid_payload:
                  value: { error: invalid_payload, issues: { fieldErrors: { phone: ["String must contain at least 6 character(s)"] } } }
                consent_required:
                  value: { error: consent_required }
                invalid_phone:
                  value: { error: invalid_phone }
                invalid_slot:
                  value: { error: invalid_slot }
                slot_in_past:
                  value: { error: slot_in_past }
        "429":
          description: Rate limit (per tenant, numer, IP). Nagłówek Retry-After w sekundach.
          headers:
            Retry-After:
              schema: { type: string }
              description: Ile sekund odczekać.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example: { error: rate_limited, retry_after: 60 }

  /v1/auth/login:
    post:
      tags: [Auth]
      operationId: authLogin
      summary: Logowanie do panelu
      description: Logowanie e-mailem i hasłem. Zwraca token sesji (sess_...) używany jako Bearer. Limit 15 prób na 5 minut per IP.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email, maxLength: 160 }
                password: { type: string, minLength: 1, maxLength: 200 }
              required: [email, password]
      responses:
        "200":
          description: Zalogowano.
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string, description: "Token sesji (sess_...)." }
                  user:
                    type: object
                    properties:
                      email: { type: string }
                      role: { type: string, enum: [owner, agent, viewer] }
              example: { token: sess_abc123, user: { email: biuro@example.pl, role: owner } }
        "401":
          description: Błędny e-mail lub hasło.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_credentials }
        "422":
          $ref: "#/components/responses/InvalidPayload"
        "429":
          description: Za dużo prób logowania z tego IP.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: rate_limited }

  /v1/auth/logout:
    post:
      tags: [Auth]
      operationId: authLogout
      summary: Wylogowanie
      description: Unieważnia token sesji z nagłówka Authorization. Zawsze 204.
      security:
        - bearerAuth: []
      responses:
        "204":
          description: Sesja unieważniona.

  /v1/auth/me:
    get:
      tags: [Auth]
      operationId: authMe
      summary: Kim jestem
      description: Rola bieżącego tokenu i podstawowe dane tenanta. Działa z management_key (rola owner) i sesją.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Dane bieżącej tożsamości.
          content:
            application/json:
              schema:
                type: object
                properties:
                  role: { type: string, enum: [owner, agent, viewer] }
                  tenant:
                    type: object
                    properties:
                      id: { type: string, format: uuid }
                      plan: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/auth/password:
    post:
      tags: [Auth]
      operationId: authChangePassword
      summary: Zmiana własnego hasła
      description: Wymaga sesji użytkownika (sess_...), management_key nie ma przypisanego usera. Unieważnia pozostałe sesje.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                current: { type: string, minLength: 1, maxLength: 200 }
                next: { type: string, minLength: 8, maxLength: 200 }
              required: [current, next]
      responses:
        "200":
          description: Hasło zmienione.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
              example: { ok: true }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: Błędne bieżące hasło.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: bad_current }
        "404":
          description: Użytkownik nie istnieje.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Nieprawidłowe dane lub za słabe hasło.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: weak_password }

  /v1/auth/forgot:
    post:
      tags: [Auth]
      operationId: authForgot
      summary: Przypomnienie hasła
      description: Wysyła e-mail z linkiem resetu. Zawsze 200 (bez ujawniania, czy konto istnieje). Limit 3 wysyłki na 15 minut per e-mail i IP.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email, maxLength: 160 }
              required: [email]
      responses:
        "200":
          description: Przyjęto (e-mail wysłany, jeśli konto istnieje).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
              example: { ok: true }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/auth/reset:
    post:
      tags: [Auth]
      operationId: authReset
      summary: Reset hasła tokenem
      description: Ustawia nowe hasło na podstawie tokenu z e-maila.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string, minLength: 10, maxLength: 200 }
                password: { type: string, minLength: 8, maxLength: 200 }
              required: [token, password]
      responses:
        "200":
          description: Hasło ustawione.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
              example: { ok: true }
        "422":
          description: Token nieprawidłowy, wygasł albo hasło za słabe.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_token }

  /v1/manage/consultants:
    get:
      tags: [Management]
      operationId: listConsultants
      summary: Lista konsultantów
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Konsultanci tenanta (kolejność round-robin).
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Consultant" }
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Management]
      operationId: createConsultant
      summary: Dodaj konsultanta
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, minLength: 1, maxLength: 80 }
                phone: { type: string, minLength: 6, maxLength: 24 }
                position: { type: integer, description: Pozycja w round-robin (opcjonalna). }
              required: [name, phone]
      responses:
        "201":
          description: Utworzono.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: Limit stanowisk planu wyczerpany.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: seat_limit_reached }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/manage/consultants/{id}:
    patch:
      tags: [Management]
      operationId: updateConsultant
      summary: Edytuj konsultanta
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string, maxLength: 80 }
                phone: { type: string, maxLength: 24 }
                active: { type: boolean }
                position: { type: integer }
      responses:
        "200":
          description: Zaktualizowano.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Konsultant nie istnieje w tym tenancie.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"
    delete:
      tags: [Management]
      operationId: deactivateConsultant
      summary: Dezaktywuj konsultanta
      description: Soft delete (active=false), historia zostaje.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdParam"
      responses:
        "200":
          description: Dezaktywowano.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Nie znaleziono.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/manage/hours:
    get:
      tags: [Management]
      operationId: listHours
      summary: Godziny pracy
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Wpisy godzin pracy (globalne i per konsultant).
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        consultant_id: { type: [string, "null"], format: uuid, description: null = godziny całej firmy. }
                        weekday: { type: integer, minimum: 0, maximum: 6, description: 0 = niedziela. }
                        start_minute: { type: integer, minimum: 0, maximum: 1440 }
                        end_minute: { type: integer, minimum: 0, maximum: 1440 }
        "401":
          $ref: "#/components/responses/Unauthorized"
    put:
      tags: [Management]
      operationId: replaceHours
      summary: Zastąp godziny pracy
      description: Zastępuje cały zestaw wpisów (max 400). Minuty liczone od północy Europe/Warsaw.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                entries:
                  type: array
                  maxItems: 400
                  items:
                    type: object
                    properties:
                      consultant_id: { type: [string, "null"], format: uuid }
                      weekday: { type: integer, minimum: 0, maximum: 6 }
                      start_minute: { type: integer, minimum: 0, maximum: 1440 }
                      end_minute: { type: integer, minimum: 0, maximum: 1440 }
                    required: [weekday, start_minute, end_minute]
              required: [entries]
      responses:
        "200":
          description: Zapisano.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/queue:
    get:
      tags: [Management]
      operationId: getQueue
      summary: Kolejka nieodebranych
      description: Nieodebrane zgłoszenia do oddzwonienia (PII, dlatego Bearer, nie public_key).
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Kolejka (najstarsze pierwsze, max 200).
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/QueueItem" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/queue/{id}/claim:
    post:
      tags: [Management]
      operationId: claimQueueItem
      summary: Przypisz nieodebrane konsultantowi
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                consultant_id: { type: string, format: uuid }
              required: [consultant_id]
      responses:
        "200":
          description: Przypisano (odpowiedź zawiera numer do oddzwonienia).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
              example: { ok: true, phone: "+48500100200" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: Zgłoszenie już przejęte lub w innym stanie.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          description: Nieprawidłowe dane albo konsultant spoza tenanta.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_consultant }

  /v1/manage/queue:
    get:
      tags: [Management]
      operationId: getManageQueue
      summary: Kolejka nieodebranych (alias panelu)
      description: To samo co GET /v1/queue.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Kolejka nieodebranych.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/QueueItem" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/manage/queue/{id}/requeue:
    post:
      tags: [Management]
      operationId: requeueQueueItem
      summary: Ponów dzwonienie
      description: Wrzuca nieodebrane zgłoszenie ponownie do routingu (dzwonek do konsultantów).
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdParam"
      responses:
        "200":
          description: Ponowiono.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Zgłoszenie nie istnieje lub nie jest w kolejce.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/manage/queue/{id}/resolve:
    post:
      tags: [Management]
      operationId: resolveQueueItem
      summary: Zamknij zgłoszenie z kolejki
      description: Oznacza nieodebrane jako załatwione (bez dzwonienia).
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdParam"
      responses:
        "200":
          description: Zamknięto.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Zgłoszenie nie istnieje lub nie jest w kolejce.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/manage/history:
    get:
      tags: [Management]
      operationId: getHistory
      summary: Historia zgłoszeń
      description: Historia z filtrami i stronicowaniem. Daty filtrów interpretowane w Europe/Warsaw.
      security:
        - bearerAuth: []
      parameters:
        - name: from
          in: query
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
          description: Data od (YYYY-MM-DD, włącznie).
        - name: to
          in: query
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
          description: Data do (YYYY-MM-DD, włącznie).
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/CallbackStatus" }
          description: Filtr po statusie.
        - name: consultant_id
          in: query
          schema: { type: string, format: uuid }
          description: Filtr po przypisanym konsultancie.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - name: offset
          in: query
          schema: { type: integer, minimum: 0, default: 0 }
      responses:
        "200":
          description: Strona historii + total do stronicowania.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/HistoryItem" }
                  total: { type: integer, description: Łączna liczba wierszy dla filtrów. }
                  limit: { type: integer }
                  offset: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: Nieprawidłowe parametry zapytania.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_query }

  /v1/manage/stats:
    get:
      tags: [Management]
      operationId: getStats
      summary: Statystyki dzisiejsze
      description: Agregaty na dziś (Europe/Warsaw). Odebrane liczone kanonicznie z usage_events (to samo źródło co billing).
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Statystyki.
          content:
            application/json:
              schema:
                type: object
                properties:
                  dzis_zamowione: { type: integer }
                  odebrane: { type: integer }
                  nieodebrane: { type: integer }
                  w_kolejce: { type: integer }
                  online_teraz: { type: integer }
                  answer_rate: { type: [integer, "null"], description: Procent odebranych (0..100) albo null. }
                  sr_sek_do_odebrania: { type: [integer, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/manage/stats/daily:
    get:
      tags: [Management]
      operationId: getStatsDaily
      summary: Serie dzienne
      description: Dzienne liczby leadów i odebranych pod wykresy (dni wg Europe/Warsaw, ciągłe).
      security:
        - bearerAuth: []
      parameters:
        - name: days
          in: query
          schema: { type: integer, minimum: 1, maximum: 90, default: 30 }
          description: Ile dni wstecz.
      responses:
        "200":
          description: Serie dzienne.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        day: { type: string, description: YYYY-MM-DD. }
                        leads: { type: integer }
                        answered: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: Nieprawidłowy parametr days.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_query }

  /v1/manage/usage:
    get:
      tags: [Management]
      operationId: getUsage
      summary: Zużycie w bieżącym miesiącu
      description: Liczniki usage_events od początku miesiąca (Europe/Warsaw). Root billingu.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Zużycie.
          content:
            application/json:
              schema:
                type: object
                properties:
                  created: { type: integer }
                  answered: { type: integer }
                  connected: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/manage/widget:
    get:
      tags: [Management]
      operationId: getWidget
      summary: Snippet widgetu
      description: Publiczny klucz i gotowy snippet script do wklejenia na stronę.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Konfiguracja widgetu.
          content:
            application/json:
              schema:
                type: object
                properties:
                  public_key: { type: string }
                  snippet: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/manage/origins:
    get:
      tags: [Management]
      operationId: getOrigins
      summary: Origin lock widgetu
      description: Lista domen, z których przyjmowane są zgłoszenia. Pusta lista = brak blokady.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Dozwolone originy.
          content:
            application/json:
              schema:
                type: object
                properties:
                  origins:
                    type: array
                    items: { type: string }
              example: { origins: ["https://example.pl"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
    put:
      tags: [Management]
      operationId: setOrigins
      summary: Ustaw origin lock
      description: Zastępuje listę (max 20). Każdy wpis musi być gołym originem http(s) bez ścieżki, query i fragmentu.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                origins:
                  type: array
                  maxItems: 20
                  items: { type: string, maxLength: 200 }
              required: [origins]
            example: { origins: ["https://example.pl", "https://www.example.pl"] }
      responses:
        "200":
          description: Zapisano (originy znormalizowane, bez duplikatów).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  origins:
                    type: array
                    items: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: Nieprawidłowy origin.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              examples:
                invalid_origin:
                  value: { error: invalid_origin, value: "nie-url" }
                origin_must_be_bare:
                  value: { error: origin_must_be_bare, value: "https://example.pl/sciezka" }

  /v1/manage/webhooks:
    get:
      tags: [Management]
      operationId: listWebhooks
      summary: Lista webhooków
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Zarejestrowane odbiorniki zdarzeń.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        url: { type: string }
                        events:
                          type: [array, "null"]
                          items: { type: string }
                          description: Subskrybowane typy zdarzeń, null = wszystkie.
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [Management]
      operationId: createWebhook
      summary: Dodaj webhook
      description: |
        Rejestruje odbiornik zdarzeń. Dostawy podpisywane HMAC sekretem zwracanym tylko raz przy utworzeniu.
        Ograniczenia anty-SSRF: wyłącznie https na publiczne hosty. Blokowane: http, localhost i *.localhost,
        loopback (127.0.0.0/8, ::1), sieci prywatne (10/8, 172.16/12, 192.168/16), link-local (169.254/16, fe80::),
        ULA (fc00::/7) oraz metadata.google.internal.
        Typy zdarzeń (kanoniczna taksonomia): callback.requested, callback.scheduled, call.ringing, call.answered,
        call.connected, call.missed, call.completed, lead.created, disposition.set.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                  description: Filtr typów zdarzeń (opcjonalny, domyślnie wszystkie).
              required: [url]
            example: { url: "https://hooks.example.pl/oddzwon", events: ["call.missed", "lead.created"] }
      responses:
        "201":
          description: Utworzono. Sekret HMAC zwracany tylko w tej odpowiedzi.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  secret: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: Nieprawidłowe dane lub URL odrzucony przez SSRF-guard.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_url }

  /v1/manage/webhooks/{id}:
    delete:
      tags: [Management]
      operationId: deleteWebhook
      summary: Usuń webhook
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdParam"
      responses:
        "200":
          description: Usunięto.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Webhook nie istnieje.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/manage/crm:
    get:
      tags: [Management]
      operationId: getCrm
      summary: Konfiguracja CRM
      description: Bieżąca konfiguracja integracji CRM (Twenty). Bez api_key w odpowiedzi.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Konfiguracja lub configured=false.
          content:
            application/json:
              schema:
                type: object
                properties:
                  configured: { type: boolean }
                  provider: { type: string }
                  base_url: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
    put:
      tags: [Management]
      operationId: setCrm
      summary: Ustaw CRM
      description: Zapisuje konfigurację CRM. base_url przechodzi ten sam SSRF-guard co webhooki (tylko https na publiczne hosty).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                provider: { type: string, enum: [twenty], default: twenty }
                base_url: { type: string, format: uri, maxLength: 300 }
                api_key: { type: string, minLength: 8, maxLength: 300 }
              required: [base_url, api_key]
      responses:
        "200":
          description: Zapisano.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: Nieprawidłowe dane lub URL odrzucony przez SSRF-guard.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: unsafe_url }
    delete:
      tags: [Management]
      operationId: deleteCrm
      summary: Usuń konfigurację CRM
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Usunięto.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/manage/crm/test:
    post:
      tags: [Management]
      operationId: testCrm
      summary: Test połączenia z CRM
      description: Wykonuje próbne żądanie do skonfigurowanego CRM.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Połączenie działa.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "502":
          description: CRM nie odpowiada lub odrzuca klucz.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
              example: { ok: false, reason: connection_failed }

  /v1/manage/devices:
    get:
      tags: [Management]
      operationId: listDevices
      summary: Urządzenia konsultantów
      description: Podgląd sparowanych urządzeń (bez fcm_token w odpowiedzi).
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Urządzenia.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        consultant_id: { type: string, format: uuid }
                        platform: { type: [string, "null"] }
                        app_version: { type: [string, "null"] }
                        active: { type: boolean }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /v1/manage/devices/{id}:
    patch:
      tags: [Management]
      operationId: setDeviceActive
      summary: Aktywuj lub dezaktywuj urządzenie
      description: Zdalna dezaktywacja (telefon zgubiony, pracownik odszedł). Nieaktywne urządzenie traci dostęp do /v1/app/*.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                active: { type: boolean }
              required: [active]
      responses:
        "200":
          description: Zaktualizowano.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Urządzenie nie istnieje.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/manage/users:
    get:
      tags: [Management]
      operationId: listUsers
      summary: Użytkownicy panelu
      description: Tylko rola owner.
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Użytkownicy tenanta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        email: { type: string }
                        role: { type: string, enum: [owner, agent, viewer] }
                        disabled: { type: boolean }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [Management]
      operationId: createUser
      summary: Dodaj użytkownika
      description: Tylko rola owner.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email: { type: string, format: email, maxLength: 160 }
                password: { type: string, minLength: 8, maxLength: 200 }
                role: { type: string, enum: [owner, agent, viewer] }
              required: [email, password, role]
      responses:
        "201":
          description: Utworzono.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, format: uuid }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: E-mail już zajęty.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: email_taken }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/manage/users/{id}:
    patch:
      tags: [Management]
      operationId: updateUser
      summary: Zmień rolę lub dezaktywuj użytkownika
      description: Tylko rola owner. Nie można zdegradować ani wyłączyć ostatniego ownera.
      security:
        - bearerAuth: []
      parameters:
        - $ref: "#/components/parameters/IdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                role: { type: string, enum: [owner, agent, viewer] }
                disabled: { type: boolean }
              minProperties: 1
      responses:
        "200":
          description: Zaktualizowano.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Użytkownik nie istnieje.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Ostatni owner nie może stracić uprawnień.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: last_owner }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/manage/export/{set}:
    get:
      tags: [Management]
      operationId: exportCsv
      summary: Eksport CSV
      description: CSV z BOM i średnikiem (polski Excel), daty w Europe/Warsaw. Historia stronicowana do 5000 wierszy (więcej = zawęź zakres dat).
      security:
        - bearerAuth: []
      parameters:
        - name: set
          in: path
          required: true
          schema:
            type: string
            enum: [historia, kolejka, konsultanci]
          description: Zestaw danych do eksportu.
        - name: from
          in: query
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
          description: Data od (tylko historia).
        - name: to
          in: query
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}$' }
          description: Data do (tylko historia).
        - name: status
          in: query
          schema: { $ref: "#/components/schemas/CallbackStatus" }
          description: Filtr po statusie (tylko historia).
      responses:
        "200":
          description: Plik CSV (content-disposition attachment).
          content:
            text/csv:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "422":
          description: Nieznany zestaw lub złe parametry.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_set }

  /v1/manage/billing/checkout:
    post:
      tags: [Management]
      operationId: billingCheckout
      summary: Start subskrypcji (Stripe Checkout)
      description: Tylko rola owner. Zwraca URL sesji Stripe Checkout, redirect wykonuje panel.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                plan: { type: string, enum: [start, pro] }
              required: [plan]
      responses:
        "200":
          description: Sesja utworzona.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, description: URL Stripe Checkout. }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Tenant ma już aktywną subskrypcję.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: already_subscribed }
        "422":
          $ref: "#/components/responses/InvalidPayload"
        "502":
          description: Błąd po stronie Stripe.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Stripe lub cennik nieskonfigurowany.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: stripe_not_configured }

  /v1/manage/billing/portal:
    post:
      tags: [Management]
      operationId: billingPortal
      summary: Stripe Customer Portal
      description: Tylko rola owner. Zwraca URL portalu zarządzania płatnościami (karta, plan, anulowanie).
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Sesja portalu utworzona.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: Tenant nie ma klienta Stripe (najpierw checkout).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: no_customer }
        "502":
          description: Błąd po stronie Stripe.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "503":
          description: Stripe nieskonfigurowany.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /v1/app/pair:
    post:
      tags: [Mobilne]
      operationId: appPair
      summary: Parowanie aplikacji
      description: Parowanie apki Android. public_key + numer telefonu konsultanta zwraca consultant_id do dalszych wywołań.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                public_key: { type: string, minLength: 6 }
                phone: { type: string, minLength: 6, maxLength: 24 }
              required: [public_key, phone]
      responses:
        "200":
          description: Sparowano.
          content:
            application/json:
              schema:
                type: object
                properties:
                  consultant_id: { type: string, format: uuid }
                  name: { type: string }
                  tenant_public_key: { type: string }
        "403":
          description: Nieznany public_key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_tenant }
        "404":
          description: Brak aktywnego konsultanta o tym numerze.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: consultant_not_found }
        "422":
          $ref: "#/components/responses/InvalidPayload"
        "503":
          description: Wymaga bazy danych.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: db_required }

  /v1/devices:
    post:
      tags: [Mobilne]
      operationId: registerDevice
      summary: Rejestracja urządzenia (FCM)
      description: Rejestruje token FCM urządzenia konsultanta pod push. Token pełni potem rolę tokenu urządzenia dla /v1/app/*.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                public_key: { type: string, minLength: 6 }
                consultant_id: { type: string, format: uuid }
                fcm_token: { type: string, minLength: 16, maxLength: 4096 }
                platform: { type: string, maxLength: 20 }
                app_version: { type: string, maxLength: 20 }
              required: [public_key, consultant_id, fcm_token]
      responses:
        "201":
          description: Zarejestrowano. fcm=true, gdy serwer ma skonfigurowane FCM.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  fcm: { type: boolean }
        "403":
          description: Nieznany public_key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404":
          description: Konsultant nie istnieje w tym tenancie.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/app/queue:
    post:
      tags: [Mobilne]
      operationId: appQueue
      summary: Kolejka nieodebranych (apka)
      description: Kolejka dla apki. Auth tokenem urządzenia (public_key + consultant_id + fcm_token aktywnego urządzenia). POST zamiast GET, żeby token nie lądował w URL-ach i logach proxy.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MobileAuth"
      responses:
        "200":
          description: Kolejka nieodebranych.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/QueueItem" }
        "403":
          description: Nieznany public_key albo nieaktywne urządzenie.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_device }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/app/queue/{id}/claim:
    post:
      tags: [Mobilne]
      operationId: appClaimQueueItem
      summary: Przejmij nieodebrane (apka)
      description: Konsultant przejmuje lead z kolejki na siebie. Auth tokenem urządzenia.
      parameters:
        - $ref: "#/components/parameters/IdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MobileAuth"
      responses:
        "200":
          description: Przejęto (zwraca numer do oddzwonienia).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "403":
          description: Nieaktywne urządzenie.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Zgłoszenie już przejęte.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/app/history:
    post:
      tags: [Mobilne]
      operationId: appHistory
      summary: Historia konsultanta (apka)
      description: Tylko własne rozmowy zalogowanego konsultanta. Auth tokenem urządzenia.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/MobileAuth"
      responses:
        "200":
          description: Ostatnie rozmowy konsultanta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        phone_e164: { type: string }
                        disposition:
                          oneOf:
                            - $ref: "#/components/schemas/Disposition"
                            - type: "null"
                        duration_seconds: { type: [integer, "null"] }
                        called_at: { type: string, description: "YYYY-MM-DD HH:mm w Europe/Warsaw." }
        "403":
          description: Nieaktywne urządzenie.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/calls/{id}/ack:
    post:
      tags: [Mobilne]
      operationId: ackCall
      summary: Potwierdź odbiór pusha
      description: Apka potwierdza, że dostała powiadomienie o zgłoszeniu. Auth przez public_key + consultant_id.
      parameters:
        - $ref: "#/components/parameters/IdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CallAct"
      responses:
        "200":
          description: Zanotowano.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "403":
          description: Nieznany public_key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/calls/{id}/accept:
    post:
      tags: [Mobilne]
      operationId: acceptCall
      summary: Przyjmij zgłoszenie
      description: Konsultant przyjmuje zgłoszenie (dzwoni do klienta ze swojego telefonu). CAS w bazie chroni przed podwójnym przyjęciem.
      parameters:
        - $ref: "#/components/parameters/IdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CallAct"
      responses:
        "200":
          description: Przyjęto.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "403":
          description: Nieznany public_key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Zgłoszenie przyjął już inny konsultant lub stan się zmienił.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
              example: { ok: false, reason: already_taken }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/calls/{id}/decline:
    post:
      tags: [Mobilne]
      operationId: declineCall
      summary: Odrzuć zgłoszenie
      description: Konsultant odrzuca zgłoszenie (routing próbuje następnego). Powód w polu reason, domyślnie rejected.
      parameters:
        - $ref: "#/components/parameters/IdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CallAct"
      responses:
        "200":
          description: Odrzucono.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "403":
          description: Nieznany public_key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/calls/{id}/report:
    post:
      tags: [Mobilne]
      operationId: reportCall
      summary: Raportuj wynik rozmowy
      description: Self-report dyspozycji po rozmowie. Metryka-prawdy dla billingu, statystyk i konwersji (inwariant 1).
      parameters:
        - $ref: "#/components/parameters/IdParam"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                public_key: { type: string, minLength: 6 }
                consultant_id: { type: string, format: uuid }
                disposition:
                  $ref: "#/components/schemas/Disposition"
                duration_seconds: { type: integer, minimum: 0, maximum: 86400 }
              required: [public_key, consultant_id, disposition]
            example: { public_key: pk_demo123, consultant_id: 2b9e6a10-4c1d-4f7e-8a2b-3c4d5e6f7a80, disposition: connected, duration_seconds: 240 }
      responses:
        "200":
          description: Zaraportowano.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "403":
          description: Nieznany public_key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "409":
          description: Raport nie pasuje do stanu zgłoszenia.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/presence:
    post:
      tags: [Mobilne]
      operationId: setPresence
      summary: Ustaw obecność konsultanta
      description: Zmiana statusu obecności (wpływa na routing). Auth przez public_key + consultant_id.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                public_key: { type: string, minLength: 6 }
                consultant_id: { type: string, format: uuid }
                presence:
                  $ref: "#/components/schemas/Presence"
              required: [public_key, consultant_id, presence]
      responses:
        "200":
          description: Ustawiono.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OkResult" }
        "403":
          description: Nieznany public_key.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "422":
          $ref: "#/components/responses/InvalidPayload"

  /v1/mcp:
    post:
      tags: [MCP]
      operationId: mcp
      summary: Serwer MCP (JSON-RPC 2.0)
      description: |
        Endpoint MCP dla agentów AI (Claude, ChatGPT, n8n). Protokół JSON-RPC 2.0, wspiera pojedyncze wiadomości i batch (tablica). Notyfikacje bez id dostają 202 bez ciała. Auth Bearer jak Management.
        Dostępne narzędzia (tools/list):
        1. get_stats: statystyki oddzwaniania na dziś.
        2. get_usage: zużycie w bieżącym miesiącu (utworzone, odebrane, połączone).
        3. list_missed: kolejka nieodebranych zgłoszeń.
        4. list_history: ostatnie zgłoszenia callback ze statusami (opcjonalny limit).
        5. list_consultants: lista konsultantów ze statusem obecności.
        6. create_callback: utwórz zgłoszenie oddzwonienia na podany numer (phone, opcjonalnie name).
        7. claim_missed: przypisz nieodebrany lead konsultantowi (request_id, opcjonalnie consultant_id).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  description: Pojedyncza wiadomość JSON-RPC 2.0.
                  properties:
                    jsonrpc: { type: string, const: "2.0" }
                    id: { type: [string, integer, "null"] }
                    method: { type: string, description: "initialize | tools/list | tools/call | ping" }
                    params: { type: object }
                  required: [jsonrpc, method]
                - type: array
                  description: Batch wiadomości JSON-RPC.
                  items: { type: object }
            example: { jsonrpc: "2.0", id: 1, method: tools/call, params: { name: get_stats, arguments: {} } }
      responses:
        "200":
          description: Odpowiedź JSON-RPC (obiekt lub tablica dla batch).
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                  - type: array
                    items: { type: object }
        "202":
          description: Notyfikacja przyjęta (bez odpowiedzi JSON-RPC).
        "401":
          $ref: "#/components/responses/Unauthorized"
