openapi: 3.0.3
info:
  title: poll-board
  version: 1.0.0
  description: >-
    Опросы и голосования с результатами в реальном времени (SSE и WebSocket).
    Повторное голосование блокируется подписанным токеном голосующего (cookie pb_voter или заголовок X-Voter-Token)
    и ограничением числа бюллетеней с одного хэша IP. Демо-проект.
  contact:
    name: Ewsir
    url: https://github.com/ewsir247
servers:
  - url: http://localhost:8119
paths:
  /healthz:
    get:
      summary: Проверка живости
      responses:
        '200':
          description: Сервис работает
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string, example: ok } } }
  /api/polls:
    post:
      summary: Создать опрос
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/NewPoll' }
      responses:
        '201':
          description: Опрос создан. admin_key показывается один раз.
          content:
            application/json:
              schema:
                type: object
                properties:
                  poll: { $ref: '#/components/schemas/Poll' }
                  admin_key: { type: string }
        '413': { $ref: '#/components/responses/Error' }
        '415': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/Error' }
        '503': { $ref: '#/components/responses/Error' }
  /api/polls/{id}:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      summary: Опрос и текущие результаты
      responses:
        '200':
          description: Опрос; you_voted и your_choices относятся к текущему токену голосующего
          content:
            application/json:
              schema:
                type: object
                properties:
                  poll: { $ref: '#/components/schemas/Poll' }
                  you_voted: { type: boolean }
                  your_choices: { type: array, items: { type: integer } }
        '404': { $ref: '#/components/responses/Error' }
  /api/polls/{id}/vote:
    parameters:
      - $ref: '#/components/parameters/Id'
    post:
      summary: Проголосовать
      description: При первом голосе в ответе приходит Set-Cookie pb_voter и поле voter_token.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [choices]
              properties:
                choices:
                  type: array
                  items: { type: integer, minimum: 0 }
                  description: Индексы вариантов без повторов
      responses:
        '200':
          description: Голос принят
          content:
            application/json:
              schema:
                type: object
                properties:
                  accepted: { type: boolean }
                  poll: { $ref: '#/components/schemas/Poll' }
                  voter_token: { type: string }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
        '422': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/Error' }
  /api/polls/{id}/close:
    parameters:
      - $ref: '#/components/parameters/Id'
    post:
      summary: Закрыть опрос досрочно
      parameters:
        - in: header
          name: X-Admin-Key
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Опрос закрыт
          content:
            application/json:
              schema:
                type: object
                properties:
                  poll: { $ref: '#/components/schemas/Poll' }
        '401': { $ref: '#/components/responses/Error' }
        '403': { $ref: '#/components/responses/Error' }
        '404': { $ref: '#/components/responses/Error' }
        '409': { $ref: '#/components/responses/Error' }
  /api/polls/{id}/events:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      summary: Поток результатов (Server-Sent Events)
      description: >-
        Первым приходит событие snapshot, затем vote и closed. Поле id равно seq опроса и строго растёт на единицу.
        После closed поток завершается.
      responses:
        '200':
          description: text/event-stream
          content:
            text/event-stream:
              schema: { type: string }
        '404': { $ref: '#/components/responses/Error' }
        '429': { $ref: '#/components/responses/Error' }
  /api/polls/{id}/ws:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      summary: Поток результатов (WebSocket)
      description: >-
        Рукопожатие RFC 6455 (Upgrade: websocket, версия 13). Сервер шлёт текстовые кадры
        {"event":"snapshot|vote|closed","poll":{...}} и закрывает соединение после closed. Клиентские сообщения игнорируются.
      responses:
        '101': { description: Переключение протокола }
        '403': { description: Origin не разрешён }
        '404': { description: Опрос не найден }
  /api/polls/{id}/export.csv:
    parameters:
      - $ref: '#/components/parameters/Id'
    get:
      summary: Результаты в CSV
      description: Ячейки, начинающиеся с = + - @ | % или табуляции, получают префикс апостроф (защита от CSV-инъекций).
      responses:
        '200':
          description: CSV в UTF-8 с BOM
          content:
            text/csv:
              schema: { type: string }
        '404': { $ref: '#/components/responses/Error' }
components:
  parameters:
    Id:
      in: path
      name: id
      required: true
      schema: { type: string, pattern: '^[0-9A-Z]{10}$' }
  responses:
    Error:
      description: Ошибка
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: object
                properties:
                  code: { type: string }
                  message: { type: string }
  schemas:
    NewPoll:
      type: object
      required: [question, options]
      properties:
        question: { type: string, maxLength: 200 }
        options:
          type: array
          minItems: 2
          maxItems: 10
          items: { type: string, maxLength: 100 }
        multiple: { type: boolean, default: false }
        max_choices: { type: integer, minimum: 2, description: Только для multiple=true }
        ttl_seconds: { type: integer, minimum: 10, maximum: 2592000, default: 86400 }
    Poll:
      type: object
      properties:
        id: { type: string }
        question: { type: string }
        multiple: { type: boolean }
        max_choices: { type: integer }
        created_at: { type: string, format: date-time }
        closes_at: { type: string, format: date-time }
        closed: { type: boolean }
        seq: { type: integer, description: Номер последнего события опроса }
        ballots: { type: integer, description: Число принятых бюллетеней }
        total: { type: integer, description: Сумма голосов по вариантам }
        options:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              label: { type: string }
              votes: { type: integer }
