openapi: 3.1.0
info:
  title: Vyveska API
  version: '1.0.0'
  summary: SaaS Digital Signage API
  description: |
    REST API для платформи Vyveska — управління ТВ-пристроями, контентом і
    плейлистами в магазинах. Стек: PHP 8.3 + MySQL.

    ## Автентифікація

    - **Device endpoints (`/api/device/*`, `/api/content/*`)**:
      `Authorization: Bearer <device_token>` (видається після `/api/device/register`)
    - **Internal endpoints (`/api/internal/*`)** для cabinet:
      `X-Internal-Token: <api_token>` (видається tenant'у через `/api/internal/provision`)
    - **Webhook endpoints (`/api/billing/webhook/*`)**: HMAC-MD5 в payload
    - **Public landing endpoints (`/api/billing/checkout`, `/api/auth/*`)**: без auth

    ## Rate limits

    - Per-IP: 60 req/min default
    - Checkout: 20/min
    - Google OAuth init: 30/min
    - Device register: 60/min
    - Brute-force на failed auth/PIN: 5 fails / 15 min → 429

    ## Помилки

    Усі помилки повертаються у форматі `{"error": "<code>", "details": {...}}`
    зі стандартними HTTP статусами (400/401/402/403/404/409/415/422/429/500).
  contact:
    name: Vyveska Support
    email: support@viviska.com.ua
    url: https://www.viviska.com.ua/
  license:
    name: Proprietary

servers:
  - url: https://www.viviska.com.ua
    description: Production

tags:
  - name: Device
    description: Реєстрація та робота ТВ-пристроїв (Flutter Android TV)
  - name: Content
    description: Manifest для відтворення контенту на ТВ
  - name: Auth
    description: Google OAuth для cabinet
  - name: Billing
    description: WayForPay checkout + webhooks
  - name: Internal
    description: Cabinet API (X-Internal-Token, від cabinet Next.js на Hetzner VPS)
  - name: Legal
    description: Оферта, акцепти, Vchasno EDO

paths:

  # ─── DEVICE ───────────────────────────────────────────
  /api/device/register:
    post:
      tags: [Device]
      summary: Реєстрація нового ТВ-пристрою (Flutter)
      description: Викликається при першому запуску Flutter-додатку. Генерує device_token + 6-значний PIN.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '201':
          description: Створено
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegisterResponse'
        '200':
          description: Уже зареєстрований — повертає актуальний токен
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegisterResponse'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '429':
          $ref: '#/components/responses/RateLimit'

  /api/device/status:
    get:
      tags: [Device]
      summary: Статус активації пристрою (poll від Flutter)
      parameters:
        - name: device_id
          in: query
          schema: {type: string}
          description: device_uuid (альтернатива Bearer)
      security:
        - bearerAuth: []
        - {}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeviceStatus'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/device/activate:
    post:
      tags: [Device]
      summary: Активація через tenant_code (legacy flow)
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [tenant_code]
              properties:
                tenant_code: {type: string, example: ABC123}
      responses:
        '200':
          description: Активовано
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ActivationResult'
        '402':
          description: License limit reached

  /api/device/heartbeat:
    post:
      tags: [Device]
      summary: Heartbeat від ТВ (раз/60с)
      security:
        - bearerAuth: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                uptime_sec: {type: integer}
                current_item_id: {type: integer, nullable: true}
                errors_count: {type: integer}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  ack: {type: boolean}
                  force_refresh: {type: boolean}
                  server_time: {type: integer}

  /api/device/check_update:
    get:
      tags: [Device]
      summary: Перевірка чи доступне оновлення Flutter APK
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  latest_version_code: {type: integer, example: 6}
                  latest_version_name: {type: string, example: '0.1.0+6'}
                  apk_url: {type: string, format: uri}
                  apk_sha256: {type: string}
                  force_update: {type: boolean}

  /api/content/manifest:
    get:
      tags: [Content]
      summary: Manifest з плейлистом для ТВ
      security:
        - bearerAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Manifest'

  # ─── AUTH ───────────────────────────────────────────
  /api/auth/google:
    get:
      tags: [Auth]
      summary: Початок Google OAuth flow (302 на Google)
      responses:
        '302':
          description: Redirect to Google
        '503':
          description: OAuth не сконфігурований

  /api/auth/google/callback:
    get:
      tags: [Auth]
      summary: Google OAuth callback
      parameters:
        - {name: state, in: query, required: true, schema: {type: string}}
        - {name: code,  in: query, required: true, schema: {type: string}}
      responses:
        '302':
          description: Redirect на /account.php (з cookie vyv_sid)
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '429':
          description: Brute-force protection triggered

  /api/auth/logout:
    post:
      tags: [Auth]
      summary: Logout (видаляє session)
      responses:
        '302':
          description: Redirect на /

  # ─── BILLING ───────────────────────────────────────────
  /api/billing/checkout:
    post:
      tags: [Billing]
      summary: Створення checkout-сесії WayForPay
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CheckoutRequest'
      responses:
        '200':
          description: HTML autosubmit form OR JSON (якщо ?format=json)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckoutResponse'
            text/html:
              schema: {type: string}
        '422':
          description: Не проставлені обов'язкові акцепти
        '429':
          $ref: '#/components/responses/RateLimit'

  /api/billing/webhook/wayforpay:
    post:
      tags: [Billing]
      summary: WayForPay callback (server-to-server)
      description: HMAC-MD5 підпис у payload. Idempotent.
      responses:
        '200':
          description: ACK для WayForPay

  /api/billing/invoices:
    get:
      tags: [Billing]
      summary: Список інвойсів tenant'а (PDF посилання)
      security:
        - cookieAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  invoices:
                    type: array
                    items: {$ref: '#/components/schemas/Invoice'}

  # ─── INTERNAL (cabinet) ───────────────────────────────────────────
  /api/internal/me:
    get:
      tags: [Internal]
      summary: Поточний tenant за X-Internal-Token
      security:
        - internalAuth: []
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TenantInfo'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/internal/provision:
    post:
      tags: [Internal]
      summary: Створення X-Internal-Token для нового tenant'а (cabinet auto-provision)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [user_email]
              properties:
                user_email: {type: string, format: email}
                provision_secret: {type: string, description: Пре-shared secret з cabinet}
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  tenant_code: {type: string}
                  api_token: {type: string}

  /api/internal/devices:
    get:
      tags: [Internal]
      summary: Список ТВ-пристроїв tenant'а
      security: [{internalAuth: []}]
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  devices:
                    type: array
                    items: {$ref: '#/components/schemas/Device'}
                  used: {type: integer}
                  max:  {type: integer}
    post:
      tags: [Internal]
      summary: Активація ТВ через PIN (з cabinet)
      security: [{internalAuth: []}]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [pin]
              properties:
                pin: {type: string, pattern: '^\d{6}$', example: '123456'}
                name: {type: string}
                store_label: {type: string}
      responses:
        '200': {description: Активовано}
        '402': {description: License limit reached}
        '404': {description: PIN не знайдено або вже використано}
        '429': {description: Brute-force блок (5 fails / 15 min)}

  /api/internal/devices/{id}:
    parameters:
      - {name: id, in: path, required: true, schema: {type: integer}}
    get:
      tags: [Internal]
      security: [{internalAuth: []}]
      responses:
        '200': {description: OK, content: {application/json: {schema: {$ref: '#/components/schemas/Device'}}}}
    patch:
      tags: [Internal]
      summary: Оновлення (name, store, settings, playlist_id)
      security: [{internalAuth: []}]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                store_label: {type: string}
                playlist_id: {type: integer, nullable: true}
                settings: {$ref: '#/components/schemas/DeviceSettings'}
      responses:
        '200': {description: OK}
    delete:
      tags: [Internal]
      security: [{internalAuth: []}]
      responses:
        '200': {description: Removed}

  /api/internal/content:
    get:
      tags: [Internal]
      summary: Список контенту
      security: [{internalAuth: []}]
      responses:
        '200': {description: OK, content: {application/json: {schema: {type: object, properties: {items: {type: array, items: {$ref: '#/components/schemas/ContentItem'}}}}}}}
    post:
      tags: [Internal]
      summary: Завантаження медіа (multipart/form-data)
      security: [{internalAuth: []}]
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: {type: string, format: binary, description: 'mp4/webm/jpg/png, до 200 MB'}
                title: {type: string}
                duration: {type: integer, description: 'для зображень (сек)'}
      responses:
        '200': {description: Uploaded}
        '413': {description: 'Payload Too Large (>200 MB)'}
        '415': {description: MIME not allowed}

  /api/internal/playlists:
    get:
      tags: [Internal]
      security: [{internalAuth: []}]
      responses:
        '200': {description: OK, content: {application/json: {schema: {type: object, properties: {playlists: {type: array, items: {$ref: '#/components/schemas/Playlist'}}}}}}}
    post:
      tags: [Internal]
      summary: Створення плейлиста
      security: [{internalAuth: []}]
      requestBody: {content: {application/json: {schema: {type: object, properties: {name: {type: string}}}}}}
      responses: {'200': {description: Created}}

  /api/internal/playlists/{id}:
    parameters: [{name: id, in: path, required: true, schema: {type: integer}}]
    patch:
      tags: [Internal]
      security: [{internalAuth: []}]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: {type: string}
                items:
                  type: array
                  items:
                    type: object
                    properties:
                      content_id: {type: integer}
                      sort_order: {type: integer}
      responses: {'200': {description: Updated}}
    delete: {tags: [Internal], security: [{internalAuth: []}], responses: {'200': {description: Removed}}}

  /api/internal/playlists/{id}/assign:
    parameters: [{name: id, in: path, required: true, schema: {type: integer}}]
    post:
      tags: [Internal]
      summary: Прив'язати playlist до пристроїв
      security: [{internalAuth: []}]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                device_ids: {type: array, items: {type: integer}}
      responses: {'200': {description: OK}}

  /api/internal/legal:
    get:
      tags: [Internal]
      summary: Легальний статус tenant'а (оферта, договори)
      security: [{internalAuth: []}]
      responses:
        '200':
          description: OK
          content: {application/json: {schema: {$ref: '#/components/schemas/LegalStatus'}}}

  # ─── LEGAL ───────────────────────────────────────────
  /api/legal/oferta_current:
    get:
      tags: [Legal]
      summary: Поточна редакція оферти (HTML + SHA256)
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  version: {type: string}
                  sha256: {type: string}
                  html_url: {type: string}
                  published_at: {type: string, format: date-time}

  /api/legal/accept:
    post:
      tags: [Legal]
      summary: Реєстрація акцепту оферти/privacy
      requestBody:
        content:
          application/json:
            schema:
              type: object
              required: [doc_type]
              properties:
                doc_type: {type: string, enum: [oferta, privacy, license]}
                tenant_id: {type: integer, nullable: true}
      responses:
        '200': {description: Acceptance recorded}

  /api/legal/vchasno-callback:
    post:
      tags: [Legal]
      summary: Vchasno EDO callback (B2B договір підписано)
      responses:
        '200': {description: ACK}

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: device_token (hex64)
      description: Видається через /api/device/register
    internalAuth:
      type: apiKey
      in: header
      name: X-Internal-Token
      description: Tenant API token (cabinet-side)
    cookieAuth:
      type: apiKey
      in: cookie
      name: vyv_sid
      description: Session cookie (Google OAuth)

  responses:
    InvalidRequest:
      description: 400 Bad Request
      content:
        application/json:
          schema: {$ref: '#/components/schemas/Error'}
    Unauthorized:
      description: 401 Unauthorized
      content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}}
    NotFound:
      description: 404 Not Found
      content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}}
    RateLimit:
      description: 429 Too Many Requests
      content: {application/json: {schema: {$ref: '#/components/schemas/Error'}}}
      headers:
        Retry-After:
          schema: {type: integer, description: 'Секунд до повтору'}

  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error: {type: string, example: invalid_pin}
        details: {type: object, additionalProperties: true}

    RegisterRequest:
      type: object
      required: [device_id]
      properties:
        device_id:        {type: string, pattern: '^[a-zA-Z0-9_-]{8,128}$', example: 'vyv-abc123-uuid-...'}
        device_uuid:      {type: string, description: 'alias для device_id'}
        model:            {type: string, example: 'TCL P735'}
        android_version:  {type: string, example: '11'}
        app_version:      {type: string, example: '0.1.0+6'}

    RegisterResponse:
      type: object
      properties:
        device_token:    {type: string, example: 'a1b2c3...'}
        pin:             {type: string, pattern: '^\d{6}$', example: '123456'}
        status:          {type: string, enum: [pending, activated]}
        heartbeat_sec:   {type: integer, example: 60}

    DeviceStatus:
      type: object
      properties:
        status:          {type: string, enum: [pending, activated]}
        device_token:    {type: string}
        tenant_id:       {type: string, nullable: true}
        pin:             {type: string, nullable: true}
        activated_at:    {type: string, format: date-time, nullable: true}
        brand_name:      {type: string}
        primary_color:   {type: string, example: '#1a73e8'}
        logo_url:        {type: string, nullable: true}
        plan:            {type: string, nullable: true, enum: [free, basic]}
        branding:
          type: object
          properties:
            name:          {type: string}
            primary_color: {type: string}
            logo_url:      {type: string, nullable: true}

    ActivationResult:
      type: object
      properties:
        tenant_id:    {type: integer}
        plan:         {type: string}
        manifest_url: {type: string}
        branding:     {$ref: '#/components/schemas/Branding'}

    Branding:
      type: object
      properties:
        name:          {type: string}
        primary_color: {type: string}
        logo_url:      {type: string, nullable: true}

    Manifest:
      type: object
      properties:
        tenant_id:        {type: string}
        plan:             {type: string, enum: [free, basic]}
        brand_name:       {type: string}
        primary_color:    {type: string}
        logo_url:         {type: string, nullable: true}
        items:
          type: array
          items:
            type: object
            properties:
              id:           {type: integer}
              type:         {type: string, enum: [video, image]}
              media_url:    {type: string, format: uri}
              duration_sec: {type: integer}
              sha256:       {type: string}
        settings: {$ref: '#/components/schemas/DeviceSettings'}

    DeviceSettings:
      type: object
      properties:
        autostart_on_boot: {type: boolean, default: true}
        mute:              {type: boolean, default: false}
        brightness:        {type: integer, minimum: 0, maximum: 100, default: 100}
        schedule_on_at:    {type: string, nullable: true, example: '09:00'}
        schedule_off_at:   {type: string, nullable: true, example: '22:00'}

    Device:
      type: object
      properties:
        id:                {type: integer}
        name:              {type: string, nullable: true}
        store_label:       {type: string, nullable: true}
        model:             {type: string, nullable: true}
        android_version:   {type: string, nullable: true}
        app_version:       {type: string, nullable: true}
        last_heartbeat_at: {type: string, format: date-time, nullable: true}
        last_ip:           {type: string, nullable: true}
        playlist_id:       {type: integer, nullable: true}
        playlist_name:     {type: string, nullable: true}
        activated_at:      {type: string, format: date-time, nullable: true}
        created_at:        {type: string, format: date-time}
        status:            {type: string, enum: [pending, active]}
        online:            {type: boolean}
        settings:          {$ref: '#/components/schemas/DeviceSettings'}

    ContentItem:
      type: object
      properties:
        id:              {type: integer}
        title:           {type: string}
        media_url:       {type: string, format: uri}
        media_type:      {type: string, enum: [video, image]}
        thumbnail_url:   {type: string, nullable: true}
        duration_sec:    {type: integer}
        file_size_bytes: {type: integer}
        status:          {type: string, enum: [ready, processing]}
        created_at:      {type: string, format: date-time}

    Playlist:
      type: object
      properties:
        id:                 {type: integer}
        name:               {type: string}
        is_default:         {type: boolean}
        items_count:        {type: integer}
        total_duration_sec: {type: integer}
        device_ids:         {type: array, items: {type: integer}}
        created_at:         {type: string, format: date-time}
        items:
          type: array
          items: {$ref: '#/components/schemas/PlaylistItem'}

    PlaylistItem:
      type: object
      properties:
        id:           {type: integer}
        content_id:   {type: integer}
        sort_order:   {type: integer}
        title:        {type: string}
        media_url:    {type: string, format: uri}
        media_type:   {type: string, enum: [video, image]}
        duration_sec: {type: integer}

    TenantInfo:
      type: object
      properties:
        id:           {type: integer}
        tenant_code:  {type: string, example: ABC123}
        name:         {type: string}
        plan:         {type: string, enum: [free, basic]}
        max_devices:  {type: integer}
        is_company:   {type: boolean}
        legal_name:   {type: string, nullable: true}

    CheckoutRequest:
      type: object
      required: [email, plan, oferta_accepted, privacy_accepted, immediate_start_accepted]
      properties:
        email:                      {type: string, format: email}
        plan:                       {type: string, enum: [basic]}
        devices_count:              {type: integer, minimum: 1, maximum: 500, default: 1}
        oferta_accepted:            {type: boolean}
        privacy_accepted:           {type: boolean}
        immediate_start_accepted:   {type: boolean}
        is_company:                 {type: boolean}
        legal_name:                 {type: string}
        legal_edrpou:               {type: string}
        legal_address:              {type: string}
        authority_accepted:         {type: boolean}
        format:                     {type: string, enum: [json, html], default: html}

    CheckoutResponse:
      type: object
      properties:
        order_reference: {type: string}
        amount_uah:      {type: number, format: float}
        unit_price:      {type: number, format: float}
        savings_uah:     {type: number, format: float}
        plan:            {type: string}
        devices_count:   {type: integer}
        pay_url:         {type: string, format: uri}
        form: {type: object, additionalProperties: true}

    Invoice:
      type: object
      properties:
        id:              {type: integer}
        invoice_number:  {type: string}
        amount_uah:      {type: number}
        currency:        {type: string, default: UAH}
        status:          {type: string, enum: [pending, paid]}
        pdf_url:         {type: string, format: uri, nullable: true}
        created_at:      {type: string, format: date-time}

    LegalStatus:
      type: object
      properties:
        oferta_revision:  {$ref: '#/components/schemas/OfertaRevision'}
        acceptances:
          type: array
          items: {$ref: '#/components/schemas/Acceptance'}
        agreements:
          type: array
          items: {$ref: '#/components/schemas/Agreement'}

    OfertaRevision:
      type: object
      properties:
        version:    {type: string}
        sha256:     {type: string}
        html_url:   {type: string, format: uri}
        published_at: {type: string, format: date-time}

    Acceptance:
      type: object
      properties:
        id:           {type: integer}
        doc_type:     {type: string, enum: [oferta, privacy, license]}
        sha256:       {type: string}
        accepted_at:  {type: string, format: date-time}
        ip:           {type: string}

    Agreement:
      type: object
      properties:
        id:            {type: integer}
        provider:      {type: string, enum: [electronic, vchasno_qep, diia_qep]}
        document_id:   {type: string}
        status:        {type: string, enum: [draft, sent, signed, declined]}
        signed_at:     {type: string, format: date-time, nullable: true}
        pdf_url:       {type: string, format: uri, nullable: true}
