openapi: 3.0.3
info:
  title: Movie Planner Unified API
  version: "1.2.2"
  description: |
    Единый API для Mini App, сайта movie-planner.ru, Chrome-расширения и
    нативных мобильных приложений (React Native).

    Билеты (`POST /v1/plans/{id}/tickets`) поддерживаются только для планов с `plan_type: cinema`.
    Для кино в `POST /v1/plans` передавайте реальное название и/или адрес кинотеатра; **не передавайте заглушки**
    вроде одного слова «Кинотеатр» без адреса — геокодер интерпретирует это как запрос и может выбрать случайную точку в городе.
    Если адрес пользователя ещё неизвестен, **опустите** `cinema_name` и `cinema_address`.
    При осмысленной строке и отсутствии координат сервер при `YANDEX_GEOCODER_API_KEY` нормализует адрес и подставит `cinema_lat` / `cinema_lon`.
    События в Google Calendar и .ics получают человекочитаемое описание (ссылка на Кинопоиск, кинотеатр, Яндекс.Карты).

    MCP Streamable HTTP: `/mcp` — те же Bearer-токены, что для `/v1` (инструменты для Cursor / Claude Desktop).

    Три группы авторизации:
      • `/api/miniapp/*`       — Telegram Mini App (initData) ИЛИ Bearer JWT (RN).
      • `/api/site/*`          — Bearer token из site_sessions.
      • `/v1/*`                — версия API для агентов; Bearer = JWT, site session или OAuth token.
        Для OAuth: если при authorize указан непустой `scope`, на /v1 проверяются права
        (profile.read, movies.read/write, plans.read/write, calendar.read). Пустой scope у OAuth = полный доступ.
      • `/oauth/*`             — OAuth2 code + PKCE: authorize (нужен уже залогиненный Bearer) и token.
      • `/api/extension/*`     — chat_id query param (legacy).
      • `/api/auth/*`          — регистрация / refresh / me, выдаёт JWT пары.
      • `/api/push/register`   — FCM-токен устройств RN.
      • `/api/maps/*`          — общие maps-подсказки (без авторизации).

    Стабильные поля в ответах всех плановых/фильмовых эндпоинтов совместимы,
    что даёт возможность генерировать единый клиент (openapi-typescript).

servers:
  - url: https://web-production-3921c.up.railway.app
    description: Production

security:
  - BearerAuth: []
  - SiteSession: []

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
    SiteSession:
      type: apiKey
      in: header
      name: Authorization
      description: "Bearer <site_session_token>"

  schemas:
    Film:
      type: object
      properties:
        film_id: { type: integer, nullable: true }
        kp_id: { type: integer }
        title: { type: string }
        year: { type: integer, nullable: true }
        poster: { type: string, nullable: true }
        description: { type: string, nullable: true }
        rating: { type: number, nullable: true }
        genres:
          oneOf:
            - type: array
              items: { type: string }
            - type: string
        is_series: { type: boolean }
        in_library: { type: boolean }
        watched: { type: boolean }
        my_rating: { type: integer, nullable: true, minimum: 1, maximum: 10 }
        online_link: { type: string, nullable: true }
        link: { type: string, nullable: true }

    Plan:
      type: object
      required: [id, plan_type, plan_datetime, title]
      properties:
        id: { type: integer }
        plan_type:
          type: string
          enum: [home, cinema]
        plan_datetime: { type: string, format: date-time }
        title: { type: string }
        film_id: { type: integer, nullable: true }
        kp_id: { type: integer, nullable: true }
        cinema_name: { type: string, nullable: true }
        cinema_address: { type: string, nullable: true }
        online_link: { type: string, nullable: true }
        is_series: { type: boolean }

    SearchItem:
      type: object
      properties:
        kp_id: { type: integer }
        title: { type: string }
        year: { type: integer, nullable: true }
        poster: { type: string, nullable: true }
        rating: { type: number, nullable: true }
        is_series: { type: boolean }

    DashboardData:
      type: object
      properties:
        name: { type: string }
        counts:
          type: object
          properties:
            unwatched: { type: integer }
            watched: { type: integer }
            series: { type: integer }
            plans: { type: integer }
            total: { type: integer }
        plans:
          type: array
          items: { $ref: "#/components/schemas/Plan" }
        unwatched:
          type: array
          items: { $ref: "#/components/schemas/Film" }
        series:
          type: array
          items: { $ref: "#/components/schemas/Film" }

    ProfileData:
      type: object
      properties:
        user:
          type: object
          properties:
            user_id: { type: integer }
            chat_id: { type: integer }
            username: { type: string, nullable: true }
            first_name: { type: string, nullable: true }
        stats:
          type: object
          properties:
            films: { type: integer }
            series: { type: integer }
            plans: { type: integer }
            unwatched: { type: integer }
        subscription:
          type: object
          properties:
            plan: { type: string }
            until: { type: string, format: date-time, nullable: true }

    ErrorResponse:
      type: object
      properties:
        success: { type: boolean, example: false }
        error: { type: string }

    MiniAppAuthRequest:
      type: object
      description: Telegram Mini App — строка initData из Telegram.WebApp.initData.
      properties:
        initData: { type: string }
        init_data: { type: string, description: "Alias для initData." }

    MiniAppTelegramUser:
      type: object
      properties:
        id: { type: integer }
        first_name: { type: string, nullable: true }
        last_name: { type: string, nullable: true }
        username: { type: string, nullable: true }
        photo_url: { type: string, nullable: true }
        language_code: { type: string, nullable: true }
        is_premium: { type: boolean }

    MiniAppAuthSuccess:
      type: object
      required: [success, token, user, chat_id, has_data, movies_count]
      properties:
        success: { type: boolean, example: true }
        token: { type: string, description: "Bearer site_session для последующих /api/miniapp/* и /api/site/*." }
        user: { $ref: "#/components/schemas/MiniAppTelegramUser" }
        chat_id: { type: integer }
        has_data: { type: boolean }
        movies_count: { type: integer }

paths:
  /api/miniapp/auth:
    post:
      tags: [miniapp]
      summary: Обмен Telegram initData на Bearer site_session.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MiniAppAuthRequest" }
      responses:
        "200":
          description: Успех.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MiniAppAuthSuccess" }
        "400":
          description: Нет initData.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401":
          description: Невалидный initData.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "500":
          description: Ошибка сервера.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }

  /api/auth/telegram-mobile/start:
    post:
      tags: [auth]
      summary: Выдать deep-link в бота для привязки мобильного устройства.
      responses: { '200': { description: ok } }

  /api/auth/telegram-mobile/check:
    post:
      tags: [auth]
      summary: Проверить статус привязки и получить access/refresh JWT.
      responses: { '200': { description: ok } }

  /api/auth/email/request-code:
    post:
      tags: [auth]
      summary: Запросить код на email.
      responses: { '200': { description: ok } }

  /api/auth/email/verify:
    post:
      tags: [auth]
      summary: Проверить код и получить JWT-пару.
      responses: { '200': { description: ok } }

  /api/auth/refresh:
    post:
      tags: [auth]
      summary: Обменять refresh-токен на новую access-токен пару.
      responses: { '200': { description: ok } }

  /api/auth/me:
    get:
      tags: [auth]
      summary: Профиль текущего пользователя.
      security: [{ BearerAuth: [] }]
      responses: { '200': { description: ok } }

  /api/push/register:
    post:
      tags: [push]
      summary: Привязать FCM-токен устройства к пользователю.
      security: [{ BearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [fcm_token, platform]
              properties:
                fcm_token: { type: string }
                platform: { type: string, enum: [android, ios] }
                app_version: { type: string }
      responses: { '200': { description: ok } }

  /api/miniapp/dashboard:
    get:
      tags: [miniapp]
      summary: Данные главной (план, непросмотренные, сериалы).
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/DashboardData" }

  /api/miniapp/profile:
    get:
      tags: [miniapp]
      summary: Профиль пользователя + подписка.
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProfileData" }

  /api/miniapp/search:
    get:
      tags: [miniapp]
      summary: Поиск фильмов/сериалов.
      parameters:
        - in: query
          name: q
          required: true
          schema: { type: string }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/SearchItem" }

  /api/miniapp/film/{kp_id}:
    get:
      tags: [miniapp]
      summary: Полная карточка фильма по kp_id (для Mini App/RN).
      parameters:
        - in: path
          name: kp_id
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Film" }

  /api/miniapp/film-by-kp/{kp_id}:
    get:
      tags: [extension]
      summary: Статус фильма для Chrome-расширения (in_library/watched/pinned/plan/rating).
      parameters:
        - in: path
          name: kp_id
          required: true
          schema: { type: integer }
        - in: query
          name: chat_id
          description: Для Chrome-extension (legacy-авторизация).
          schema: { type: integer }
      responses: { '200': { description: ok } }

  /api/miniapp/random:
    post:
      tags: [miniapp]
      summary: Случайный непросмотренный фильм из базы пользователя.
      responses: { '200': { description: ok } }

  /api/miniapp/shazam:
    post:
      tags: [miniapp]
      summary: Текстовый Shazam — найти фильм по описанию.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [query]
              properties:
                query: { type: string }
      responses: { '200': { description: ok } }

  /api/miniapp/plans/home:
    post:
      tags: [miniapp]
      summary: Создать план домашнего просмотра.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kp_id, plan_datetime]
              properties:
                kp_id: { type: integer }
                plan_datetime: { type: string, format: date-time }
                online_link: { type: string }
      responses: { '200': { description: ok } }

  /api/miniapp/plans/cinema:
    post:
      tags: [miniapp]
      summary: Создать план похода в кино.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kp_id, plan_datetime]
              properties:
                kp_id: { type: integer }
                plan_datetime: { type: string, format: date-time }
                cinema_name: { type: string }
                cinema_address: { type: string }
      responses: { '200': { description: ok } }

  /api/miniapp/plans/{plan_id}:
    delete:
      tags: [miniapp]
      summary: Удалить план.
      parameters:
        - in: path
          name: plan_id
          required: true
          schema: { type: integer }
      responses: { '200': { description: ok } }

  /api/miniapp/series/{film_id}/pin:
    post:
      tags: [miniapp]
      summary: Закрепить/открепить сериал.
      parameters:
        - in: path
          name: film_id
          required: true
          schema: { type: integer }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                pinned: { type: boolean }
      responses: { '200': { description: ok } }

  /api/site/plans:
    get:
      tags: [site]
      summary: Список всех планов пользователя.
      security: [{ SiteSession: [] }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items: { $ref: "#/components/schemas/Plan" }

  /api/site/film/{film_id}/rating:
    post:
      tags: [site]
      summary: Поставить оценку 1-10 фильму.
      parameters:
        - in: path
          name: film_id
          required: true
          schema: { type: integer }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                rating: { type: integer, minimum: 1, maximum: 10 }
      responses: { '200': { description: ok } }
    delete:
      tags: [site]
      summary: Снять оценку.
      parameters:
        - in: path
          name: film_id
          required: true
          schema: { type: integer }
      responses: { '200': { description: ok } }

  /api/site/film/{film_id}/watched:
    post:
      tags: [site]
      summary: Переключить «просмотрен».
      parameters:
        - in: path
          name: film_id
          required: true
          schema: { type: integer }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                watched: { type: boolean }
      responses: { '200': { description: ok } }

  /api/site/premieres/{kp_id}/notify:
    post:
      tags: [site]
      summary: Включить уведомление о премьере.
      parameters:
        - in: path
          name: kp_id
          required: true
          schema: { type: integer }
      responses: { '200': { description: ok } }
    delete:
      tags: [site]
      summary: Отключить уведомление.
      parameters:
        - in: path
          name: kp_id
          required: true
          schema: { type: integer }
      responses: { '200': { description: ok } }

  /api/maps/cinemas:
    get:
      tags: [maps]
      summary: Подсказки кинотеатров по запросу.
      security: []
      parameters:
        - in: query
          name: q
          required: true
          schema: { type: string }
        - in: query
          name: lat
          schema: { type: number }
        - in: query
          name: lon
          schema: { type: number }
      responses: { '200': { description: ok } }

  /api/maps/geocode:
    get:
      tags: [maps]
      summary: Прямое геокодирование адреса.
      security: []
      parameters:
        - in: query
          name: q
          required: true
          schema: { type: string }
      responses: { '200': { description: ok } }

  /v1/health:
    get:
      tags: [v1]
      summary: Проверка доступности версионированного API.
      security: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean }
                  api: { type: string }
                  version: { type: string }
                  release: { type: string }

  /v1/me:
    get:
      tags: [v1]
      summary: Текущий профиль (как /api/site/me) для агентов и интеграций.
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  api_version: { type: string, example: v1 }
                  name: { type: string }
                  has_data: { type: boolean }
                  is_virtual_room: { type: boolean }
                  chat_id: { type: integer }
                  user_id: { type: integer, nullable: true }
                  is_personal: { type: boolean }
                  room_emoji: { type: string, nullable: true }
        '401':
          description: Нет валидного Bearer (JWT или site session).

  /v1/agent/events:
    get:
      tags: [v1]
      summary: События для ИИ / автоматизаций (зеркало мобильных push и др.).
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      parameters:
        - name: limit
          in: query
          schema: { type: integer, default: 20, maximum: 100 }
        - name: since_id
          in: query
          description: Только события с id больше указанного (для опроса).
          schema: { type: integer }
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  api_version: { type: string }
                  events:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        kind: { type: string }
                        title: { type: string, nullable: true }
                        body: { type: string, nullable: true }
                        payload: { type: object }
                        created_at: { type: string }

  /v1/plans:
    get:
      tags: [v1]
      summary: Предстоящие планы текущей комнаты (как /api/site/plans).
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  api_version: { type: string, example: v1 }
                  home:
                    type: array
                    items: { $ref: "#/components/schemas/Plan" }
                  cinema:
                    type: array
                    items: { $ref: "#/components/schemas/Plan" }
                  premieres:
                    type: array
                    items: { $ref: "#/components/schemas/Plan" }
        '401':
          description: Нет валидного Bearer (JWT или site session).
    post:
      tags: [v1]
      summary: Создать или обновить план для фильма в текущей комнате.
      description: |
        Для просмотра **в кино** обязательно `plan_type: cinema`. Поля кинотеатра (`cinema_*`) — для карты и календаря;
        пока нет данных пользователя, **не заполняйте** их фиктивными значениями («Кинотеатр», «кино» и т.п.).
        Геокодинг выполняется только для осмысленных строк (не одни стоп-слова); иначе сервер не меняет координаты.
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [film_id, plan_type, plan_datetime]
              properties:
                film_id: { type: integer }
                plan_type: { type: string, enum: [home, cinema] }
                plan_datetime: { type: string, format: date-time }
                streaming_service: { type: string, nullable: true }
                streaming_url: { type: string, nullable: true }
                cinema_name: { type: string, nullable: true }
                cinema_address: { type: string, nullable: true }
                cinema_lat: { type: number, nullable: true }
                cinema_lon: { type: number, nullable: true }
      responses:
        '200': { description: ok }
        '400': { description: bad request }
        '401': { description: unauthorized }

  /v1/movies:
    post:
      tags: [v1]
      summary: Добавить фильм по kp_id или ссылке (как /api/site/add-film).
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                kp_id: { type: integer }
                link: { type: string }
                online_link: { type: string, nullable: true }
      responses:
        '200': { description: ok }
        '401': { description: unauthorized }

  /v1/groups:
    get:
      tags: [v1]
      summary: Список групп пользователя (виртуальные комнаты и привязанные чаты).
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      responses:
        '200': { description: ok }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope }
    post:
      tags: [v1]
      summary: Создать виртуальную группу.
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                emoji: { type: string }
      responses:
        '200': { description: ok }
        '400': { description: bad request }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope }

  /v1/groups/{chat_id}/invite:
    post:
      tags: [v1]
      summary: Создать invite-ссылку для группы (если пользователь состоит в группе).
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      parameters:
        - in: path
          name: chat_id
          required: true
          schema: { type: integer }
      responses:
        '200': { description: ok }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope or not_member }

  /v1/tv/launch:
    post:
      tags: [v1]
      summary: Команда ТВ-агенту open_film и cast_url (как /api/site/tv/launch).
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kp_id]
              properties:
                kp_id: { type: integer }
                title: { type: string }
      responses:
        '200': { description: ok }
        '400': { description: bad request }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope }

  /v1/plans/{plan_id}/calendar/google/push:
    post:
      tags: [v1]
      summary: |
        Создать событие в Google Календаре пользователя (Calendar API).
        Нужна предварительная связка аккаунта через OAuth в Mini App / на сайте.
      description: |
        Тело запроса не используется (можно отправить пустой JSON или без тела).
        Описание события формируется на сервере: ссылка на страницу фильма на Кинопоиске, данные кинотеатра, ссылка на Яндекс.Карты.
        Файлы билетов с плана загружаются в Drive пользователя и прикрепляются к событию при наличии scope OAuth Calendar + Drive.
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      parameters:
        - in: path
          name: plan_id
          required: true
          schema: { type: integer }
      responses:
        '200':
          description: Событие создано
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  event_id: { type: string, nullable: true }
                  html_link: { type: string, nullable: true }
                  plan_id: { type: integer }
                  tickets_on_plan: { type: integer, description: число билетов по данным на диске (tickets_paths) }
                  tickets_attached: { type: integer, description: сколько файлов реально добавлено во вложения события }
        '400': { description: календарь не подключён или нет даты плана }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope }

  /v1/plans/{plan_id}/tickets:
    get:
      tags: [v1]
      summary: Список файлов билетов у кино-плана (имя, mime, индекс для скачивания).
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      parameters:
        - in: path
          name: plan_id
          required: true
          schema: { type: integer }
      responses:
        '200': { description: JSON с массивом tickets }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope }
        '404': { description: plan not in room }
    post:
      tags: [v1]
      summary: Прикрепить билет (JPEG/PNG/GIF) к кино-плану — JSON base64, multipart file или сырое тело image/*.
      description: "Доступно только если у плана plan_type cinema. После загрузки билет доступен через GET этого же префикса и учитывается при push в Google Calendar."
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      parameters:
        - in: path
          name: plan_id
          required: true
          schema: { type: integer }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [image_base64]
              properties:
                image_base64: { type: string }
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                  description: Скрин билета (JPEG/PNG/GIF)
          image/jpeg:
            schema:
              type: string
              format: binary
          image/png:
            schema:
              type: string
              format: binary
          image/gif:
            schema:
              type: string
              format: binary
      responses:
        '200': { description: ok }
        '400': { description: invalid_request }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope or no_ticket_subscription }
        '404': { description: plan not found }

  /v1/plans/{plan_id}/tickets/{index}:
    get:
      tags: [v1]
      summary: |
        Скачать билет. Без query — бинарное тело (image/pdf).
        С query `format=base64` — JSON с полем data_base64 (для нейросетей и чат-ботов).
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      parameters:
        - in: path
          name: plan_id
          required: true
          schema: { type: integer }
        - in: path
          name: index
          required: true
          schema: { type: integer }
        - in: query
          name: format
          required: false
          schema: { type: string, enum: [base64, json] }
          description: base64 — ответ JSON с data_base64
      responses:
        '200': { description: файл или JSON }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope }
        '404': { description: not found }
        '413': { description: файл слишком большой для embed }

  /v1/library/unwatched:
    get:
      tags: [v1]
      summary: Непросмотренные фильмы текущей комнаты.
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      responses:
        '200': { description: ok }
        '401': { description: unauthorized }

  /v1/plans/{plan_id}.ics:
    get:
      tags: [v1]
      summary: |
        Календарное событие (iCalendar). Без `subscribe_token` — Bearer + scope `calendar.read`.
        С `subscribe_token` / `t` — подписанная ссылка, без заголовка Authorization.
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      parameters:
        - in: path
          name: plan_id
          required: true
          schema: { type: integer }
        - in: query
          name: subscribe_token
          required: false
          schema: { type: string }
          description: Подписанный токен (из GET /v1/plans/{id}/calendar-links)
        - in: query
          name: t
          required: false
          schema: { type: string }
          description: Алиас для subscribe_token
      responses:
        '200':
          description: text/calendar
          content:
            text/calendar:
              schema: { type: string }
        '401': { description: unauthorized }
        '403': { description: invalid_subscribe_token }
        '404': { description: plan not in this room }

  /v1/plans/{plan_id}/google-calendar:
    get:
      tags: [v1]
      summary: Ссылка «Добавить в Google Календарь» (шаблон, без OAuth в Google). Нужен scope `calendar.read` или `plans.read`.
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      parameters:
        - in: path
          name: plan_id
          required: true
          schema: { type: integer }
      responses:
        '200': { description: JSON с полем url }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope }
        '404': { description: not found }

  /v1/plans/{plan_id}/calendar-links:
    get:
      tags: [v1]
      summary: Подписанный путь к .ics, полный ics_url (если задан BACKEND_PUBLIC_URL) и ссылка Google.
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      parameters:
        - in: path
          name: plan_id
          required: true
          schema: { type: integer }
      responses:
        '200': { description: ok }
        '401': { description: unauthorized }
        '403': { description: insufficient_scope }
        '404': { description: not found }

  /oauth/authorize:
    post:
      tags: [oauth]
      summary: Выдать authorization code (PKCE). Нужен Bearer — уже существующая сессия пользователя.
      security: [{ BearerAuth: [] }, { SiteSession: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [client_id, redirect_uri, code_challenge]
              properties:
                client_id: { type: string }
                redirect_uri: { type: string }
                code_challenge: { type: string }
                code_challenge_method: { type: string, default: S256 }
                scope: { type: string }
                state: { type: string }
      responses:
        '200': { description: JSON с полем code }
        '400': { description: invalid_request }
        '401': { description: unauthorized }

  /oauth/token:
    post:
      tags: [oauth]
      summary: |
        Токен: `grant_type=authorization_code` (code + PKCE verifier) или
        `grant_type=refresh_token` (refresh_token + client_secret при необходимости).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [grant_type, client_id]
              properties:
                grant_type: { type: string, enum: [authorization_code, refresh_token] }
                client_id: { type: string }
                client_secret: { type: string }
                code: { type: string }
                redirect_uri: { type: string }
                code_verifier: { type: string }
                refresh_token: { type: string }
      responses:
        '200': { description: access_token (+ refresh_token при authorization_code) }
        '400': { description: invalid_grant }
        '401': { description: invalid_client }

  /oauth/revoke:
    post:
      tags: [oauth]
      summary: Отозвать access или refresh токен (RFC 7009-подобно, тело JSON или Bearer).
      security: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                token: { type: string }
      responses:
        '200': { description: пустое тело }

  /oauth/clients:
    post:
      tags: [oauth]
      summary: Зарегистрировать клиента (заголовок X-OAuth-Admin-Secret).
      security: []
      parameters:
        - in: header
          name: X-OAuth-Admin-Secret
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [redirect_uris]
              properties:
                name: { type: string }
                redirect_uris: { type: string, description: URI через запятую }
      responses:
        '200': { description: client_id и client_secret (один раз) }
        '401': { description: unauthorized }

  /v1/capabilities:
    get:
      tags: [v1]
      summary: Флаги возможностей API (без авторизации).
      security: []
      responses:
        '200':
          description: ok
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  api_version: { type: string }
                  features:
                    type: object
                    additionalProperties: { type: boolean }
