openapi: 3.1.0
info:
  title: API de Catálogo Covaly
  version: 1.0.0-mvp
  description: |
    API por tienda para consultar y administrar productos del catálogo Covaly.
    Cada token pertenece a una única tienda y debe enviarse como Bearer token.
  contact:
    name: Covaly
servers:
  - url: https://{store}.covaly.store/api/v1
    description: Tienda Covaly
    variables:
      store:
        default: mi-tienda
        description: Subdominio de la tienda
security:
  - bearerToken: []
tags:
  - name: Conexión
  - name: Productos
  - name: Categorías
paths:
  /:
    get:
      tags: [Conexión]
      summary: Descubre la versión y el contrato del API
      operationId: discoverApi
      security: []
      responses:
        "200":
          description: Metadatos públicos del API
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [name, version, authentication, openapi_url, healthcheck_url]
                    properties:
                      name: { type: string }
                      version: { type: string }
                      authentication: { type: string }
                      openapi_url: { type: string, format: uri }
                      healthcheck_url: { type: string, format: uri }
  /openapi.yaml:
    get:
      tags: [Conexión]
      summary: Descarga el contrato OpenAPI
      operationId: downloadOpenApi
      security: []
      responses:
        "200":
          description: Contrato OpenAPI 3.1 en YAML
          content:
            application/yaml:
              schema:
                type: string
  /me:
    get:
      tags: [Conexión]
      summary: Verifica el token y la tienda
      operationId: getCurrentIntegration
      responses:
        "200":
          description: Token válido
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "429":
          $ref: "#/components/responses/RateLimited"
  /products:
    get:
      tags: [Productos]
      summary: Lista productos
      operationId: listProducts
      description: Requiere `products:read` o `products:write`.
      parameters:
        - name: status
          in: query
          schema:
            $ref: "#/components/schemas/ProductStatus"
        - name: category_id
          in: query
          schema:
            type: integer
            minimum: 1
        - name: search
          in: query
          schema:
            type: string
            maxLength: 255
        - name: sort
          in: query
          schema:
            type: string
            enum: [-created_at, created_at, name, -name, unit_price, -unit_price]
            default: -created_at
        - name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: Listado paginado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductListResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/RateLimited"
    post:
      tags: [Productos]
      summary: Crea un producto como borrador
      operationId: createProductDraft
      description: Requiere `products:write`. La moneda siempre será PEN.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateProductRequest"
      responses:
        "201":
          description: Borrador creado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/RateLimited"
  /products/bulk:
    post:
      tags: [Productos]
      summary: Crea varios productos como borradores
      operationId: createProductsBulk
      description: Requiere `products:write`. Acepta hasta 50 productos y devuelve cada resultado con el mismo ProductResource y preview_url.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateProductsBulkRequest"
      responses:
        "200":
          description: Resultados de la creación masiva, incluyendo fallos parciales.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductsBulkResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/RateLimited"
  /products/{product}:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      tags: [Productos]
      summary: Obtiene un producto
      operationId: getProduct
      description: Requiere `products:read` o `products:write`.
      responses:
        "200":
          description: Producto encontrado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      tags: [Productos]
      summary: Edita parcialmente un producto
      operationId: updateProduct
      description: Requiere `products:write`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateProductRequest"
      responses:
        "200":
          description: Producto actualizado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/RateLimited"
    delete:
      tags: [Productos]
      summary: Elimina un producto
      operationId: deleteProduct
      description: Requiere `products:write`. La operación no se puede deshacer.
      responses:
        "204":
          description: Producto eliminado
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /products/{product}/publish:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    post:
      tags: [Productos]
      summary: Publica un producto
      operationId: publishProduct
      description: Requiere `products:publish`. La operación es idempotente.
      responses:
        "200":
          description: Producto publicado
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/RateLimited"
  /products/{product}/unpublish:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    post:
      tags: [Productos]
      summary: Oculta un producto
      operationId: unpublishProduct
      description: Requiere `products:publish`. La operación es idempotente.
      responses:
        "200":
          description: Producto movido a borrador
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /products/{product}/images:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    post:
      tags: [Productos]
      summary: Sube una imagen de producto
      operationId: uploadProductImage
      description: Requiere `products:write`. Multipart, campo `image` (jpg/png/webp, máx 5 MB).
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [image]
              properties:
                image:
                  type: string
                  format: binary
      responses:
        "201":
          description: Imagen creada
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductImageResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/ValidationFailed"
        "429":
          $ref: "#/components/responses/RateLimited"
  /products/{product}/images/{image}:
    parameters:
      - $ref: "#/components/parameters/ProductId"
      - name: image
        in: path
        required: true
        schema:
          type: integer
          minimum: 1
    delete:
      tags: [Productos]
      summary: Elimina una imagen de producto
      operationId: deleteProductImage
      description: Requiere `products:write`.
      responses:
        "204":
          description: Imagen eliminada
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
  /categories:
    get:
      tags: [Categorías]
      summary: Lista categorías
      operationId: listCategories
      description: Requiere `categories:read`.
      responses:
        "200":
          description: Categorías de la tienda
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Category"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
  /categories/{category}:
    get:
      tags: [Categorías]
      summary: Obtiene una categoría
      operationId: getCategory
      description: Requiere `categories:read`.
      parameters:
        - name: category
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: Categoría encontrada
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    $ref: "#/components/schemas/Category"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
components:
  securitySchemes:
    bearerToken:
      type: http
      scheme: bearer
      bearerFormat: cvly_ token
      description: Token generado en Configuración → API e integraciones.
  parameters:
    ProductId:
      name: product
      in: path
      required: true
      schema:
        type: integer
        minimum: 1
  schemas:
    ProductStatus:
      type: string
      enum: [draft, published]
    ProductInput:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          maxLength: 255
        description:
          type: string
        unit_price:
          type: number
          minimum: 0
        previous_unit_price:
          type: [number, "null"]
          minimum: 0
        show_previous_price:
          type: boolean
        stock:
          type: integer
          minimum: 0
        category_id:
          type: [integer, "null"]
          minimum: 1
        currency:
          type: string
          description: Se acepta por compatibilidad, pero se ignora. v1 siempre usa PEN.
    CreateProductRequest:
      $ref: "#/components/schemas/ProductInput"
      required: [name, description, unit_price]
    UpdateProductRequest:
      $ref: "#/components/schemas/ProductInput"
      description: Todos los campos son opcionales; se actualizan únicamente los enviados.
    CreateProductsBulkRequest:
      type: object
      additionalProperties: false
      required: [products]
      properties:
        products:
          type: array
          minItems: 1
          maxItems: 50
          items:
            allOf:
              - $ref: "#/components/schemas/CreateProductRequest"
            type: object
            properties:
              client_reference:
                type: string
                maxLength: 100
    ProductsBulkResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            type: object
            required: [source_index, action, product, warnings]
            properties:
              source_index:
                type: integer
              client_reference:
                type: [string, "null"]
              action:
                type: string
                enum: [created, failed]
              product:
                oneOf:
                  - $ref: "#/components/schemas/Product"
                  - type: "null"
              warnings:
                type: array
                items: { type: string }
        meta:
          type: object
          required: [received, created, failed]
          properties:
            received: { type: integer }
            created: { type: integer }
            failed: { type: integer }
    Product:
      type: object
      required:
        - id
        - name
        - description
        - status
        - preview_url
        - unit_price
        - show_previous_price
        - currency
        - stock
        - images
        - promos
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: string
        status:
          $ref: "#/components/schemas/ProductStatus"
        preview_url:
          type: [string, "null"]
          format: uri
          description: URL temporal firmada para revisar el producto en el storefront.
        published_at:
          type: [string, "null"]
          format: date-time
        unit_price:
          type: string
          pattern: "^[0-9]+\\.[0-9]{2}$"
          example: "39.90"
        previous_unit_price:
          type: [string, "null"]
          pattern: "^[0-9]+\\.[0-9]{2}$"
        show_previous_price:
          type: boolean
        currency:
          type: string
          const: PEN
        stock:
          type: integer
        category:
          oneOf:
            - $ref: "#/components/schemas/CategorySummary"
            - type: "null"
        images:
          type: array
          items:
            $ref: "#/components/schemas/ProductImage"
        promos:
          type: array
          items:
            $ref: "#/components/schemas/Promo"
        created_at:
          type: [string, "null"]
          format: date-time
        updated_at:
          type: [string, "null"]
          format: date-time
    ProductImage:
      type: object
      required: [id, url, sort_order, preview_url]
      properties:
        id:
          type: integer
        url:
          type: string
          format: uri
        sort_order:
          type: integer
        preview_url:
          type: [string, "null"]
          format: uri
    ProductImageResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [id, product_id, url, sort_order, created_at, preview_url]
          properties:
            id:
              type: integer
            product_id:
              type: integer
            url:
              type: string
              format: uri
            sort_order:
              type: integer
            created_at:
              type: [string, "null"]
              format: date-time
            preview_url:
              type: [string, "null"]
              format: uri
    Promo:
      type: object
      required: [id, quantity, price, currency]
      properties:
        id:
          type: integer
        quantity:
          type: integer
        price:
          type: string
          example: "99.00"
        currency:
          type: string
          example: PEN
    CategorySummary:
      type: object
      required: [id, name]
      properties:
        id:
          type: integer
        name:
          type: string
    Category:
      type: object
      required: [id, name, slug]
      properties:
        id:
          type: integer
        name:
          type: string
        description:
          type: [string, "null"]
        slug:
          type: string
    ProductResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/Product"
    PaginationMeta:
      type: object
      required: [current_page, per_page, total, last_page]
      properties:
        current_page:
          type: integer
        per_page:
          type: integer
        total:
          type: integer
        last_page:
          type: integer
    ProductListResponse:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Product"
        meta:
          $ref: "#/components/schemas/PaginationMeta"
    MeResponse:
      type: object
      required: [data]
      properties:
        data:
          type: object
          required: [token, store]
          properties:
            token:
              type: object
              required: [name, abilities]
              properties:
                name:
                  type: string
                abilities:
                  type: array
                  items:
                    type: string
                expires_at:
                  type: [string, "null"]
                  format: date-time
            store:
              type: object
              required: [slug, domain]
              properties:
                slug:
                  type: string
                domain:
                  type: string
    ErrorDetail:
      type: object
      required: [code, message, details]
      properties:
        code:
          type: string
        message:
          type: string
        details:
          type: object
          additionalProperties: true
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          $ref: "#/components/schemas/ErrorDetail"
  responses:
    Unauthorized:
      description: Token ausente, inválido o expirado
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    Forbidden:
      description: El token no tiene el scope requerido
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    NotFound:
      description: El recurso no existe en la tienda
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    ValidationFailed:
      description: Datos o filtros inválidos
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
    RateLimited:
      description: Límite de solicitudes excedido
      headers:
        Retry-After:
          schema:
            type: integer
          description: Segundos antes de reintentar.
        X-RateLimit-Limit:
          schema:
            type: integer
        X-RateLimit-Remaining:
          schema:
            type: integer
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorResponse"
