openapi: 3.0.3
info:
  title: UnaLabs Partner API
  description: |
    Mandantenfähige REST-API für externe Partner (Whitelabel / B2B).

    **Prinzipien:**
    - Read-Only (MVP)
    - Kundenbasiert – alle Daten über `/customers/{customerId}/…`
    - Mandantenisolation pro API-Key
    - 404 statt 403 bei fremden Ressourcen-IDs

    Vollständige Dokumentation: https://labor.unalabs.de/mxtools/partner-api/docs/
  version: 1.0.0
  contact:
    name: UnaLabs Support
    email: hello@unalabs.de
  license:
    name: Proprietary

servers:
  - url: https://api.unalabs.de/v1
    description: Produktion (Domain-Alias)
  - url: https://labor.unalabs.de/mxtools/partner-api/public/v1
    description: Produktion (direkter Pfad)

tags:
  - name: System
    description: Health & Status
  - name: Customers
    description: Kunden (Contacts des Partners)
  - name: Samples
    description: Laborproben eines Kunden
  - name: Documents
    description: Dokumente eines Kunden

security:
  - ApiKeyAuth: []

paths:
  /health:
    get:
      tags: [System]
      summary: Health Check
      description: Öffentlicher Endpunkt ohne Authentifizierung.
      operationId: getHealth
      security: []
      responses:
        '200':
          description: API ist erreichbar
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'

  /customers:
    get:
      tags: [Customers]
      summary: Kundenliste
      description: Alle Kunden des authentifizierten Partners.
      operationId: listCustomers
      parameters:
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/UpdatedSince'
      responses:
        '200':
          description: Paginierte Kundenliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CustomerList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/InsufficientScope'
        '429':
          $ref: '#/components/responses/RateLimited'

  /customers/{customerId}:
    get:
      tags: [Customers]
      summary: Einzelner Kunde
      operationId: getCustomer
      parameters:
        - $ref: '#/components/parameters/CustomerId'
      responses:
        '200':
          description: Kunde gefunden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Customer'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /customers/{customerId}/samples:
    get:
      tags: [Samples]
      summary: Proben eines Kunden
      operationId: listCustomerSamples
      parameters:
        - $ref: '#/components/parameters/CustomerId'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/UpdatedSince'
      responses:
        '200':
          description: Paginierte Probenliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SampleList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /customers/{customerId}/samples/{sampleId}:
    get:
      tags: [Samples]
      summary: Einzelne Probe
      operationId: getCustomerSample
      parameters:
        - $ref: '#/components/parameters/CustomerId'
        - $ref: '#/components/parameters/SampleId'
      responses:
        '200':
          description: Probe gefunden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Sample'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /customers/{customerId}/documents:
    get:
      tags: [Documents]
      summary: Dokumente eines Kunden
      operationId: listCustomerDocuments
      parameters:
        - $ref: '#/components/parameters/CustomerId'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/UpdatedSince'
      responses:
        '200':
          description: Paginierte Dokumentenliste
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DocumentList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /customers/{customerId}/documents/{documentId}:
    get:
      tags: [Documents]
      summary: Einzelnes Dokument (Metadaten)
      operationId: getCustomerDocument
      parameters:
        - $ref: '#/components/parameters/CustomerId'
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: Dokument gefunden
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Document'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

  /customers/{customerId}/documents/{documentId}/download:
    get:
      tags: [Documents]
      summary: Dokument herunterladen
      description: Binärer Datei-Download (PDF etc.).
      operationId: downloadCustomerDocument
      parameters:
        - $ref: '#/components/parameters/CustomerId'
        - $ref: '#/components/parameters/DocumentId'
      responses:
        '200':
          description: Datei-Stream
          content:
            application/pdf:
              schema:
                type: string
                format: binary
            application/octet-stream:
              schema:
                type: string
                format: binary
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        API-Key im Format `una_live_` + 32 Hex-Zeichen.
        Wird von UnaLabs pro Partner ausgestellt.

  parameters:
    CustomerId:
      name: customerId
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-f0-9]{17}$'
      description: EspoCRM Contact-ID
      example: 64f1a2b3c4d5e6f7a
    SampleId:
      name: sampleId
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-f0-9]{17}$'
      description: EspoCRM CLaborprobe-ID
      example: 71a2b3c4d5e6f7a8b
    DocumentId:
      name: documentId
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-f0-9]{17}$'
      description: EspoCRM Document-ID
      example: 88f9a0b1c2d3e4f5a
    Offset:
      name: offset
      in: query
      schema:
        type: integer
        minimum: 0
        default: 0
    Limit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 50
    UpdatedSince:
      name: updatedSince
      in: query
      schema:
        type: string
        format: date-time
      description: ISO 8601 – nur Datensätze mit updatedAt >= diesem Zeitpunkt
      example: '2026-06-01T00:00:00Z'

  responses:
    Unauthorized:
      description: Fehlender oder ungültiger API-Key
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: unauthorized
            message: Invalid or missing API key
            requestId: 550e8400-e29b-41d4-a716-446655440000
    InsufficientScope:
      description: API-Key hat nicht den erforderlichen Scope
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: Ressource nicht gefunden
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: not_found
            message: Customer not found
            requestId: 550e8400-e29b-41d4-a716-446655440000
    RateLimited:
      description: Rate-Limit überschritten
      headers:
        Retry-After:
          schema:
            type: integer
          description: Sekunden bis zum nächsten erlaubten Request
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Health:
      type: object
      required: [status, version, timestamp]
      properties:
        status:
          type: string
          enum: [ok]
        version:
          type: string
          example: 1.0.0
        timestamp:
          type: string
          format: date-time

    Error:
      type: object
      required: [error, message]
      properties:
        error:
          type: string
          enum:
            - bad_request
            - unauthorized
            - insufficient_scope
            - not_found
            - rate_limit_exceeded
            - internal_error
        message:
          type: string
        requestId:
          type: string
          format: uuid

    Address:
      type: object
      properties:
        street:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        postalCode:
          type: string
          nullable: true
        country:
          type: string
          nullable: true

    Customer:
      type: object
      required: [id, createdAt, updatedAt]
      properties:
        id:
          type: string
        customerNumber:
          type: integer
          nullable: true
        firstName:
          type: string
          nullable: true
        lastName:
          type: string
          nullable: true
        gender:
          type: string
          enum: [männlich, weiblich, leer]
          nullable: true
        dateOfBirth:
          type: string
          format: date
          nullable: true
        reportLanguage:
          type: string
          enum: [Deutsch, Englisch, Arabisch]
          nullable: true
        address:
          $ref: '#/components/schemas/Address'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CustomerList:
      type: object
      required: [total, offset, limit, items]
      properties:
        total:
          type: integer
        offset:
          type: integer
        limit:
          type: integer
        items:
          type: array
          items:
            $ref: '#/components/schemas/Customer'

    TestType:
      type: object
      required: [id, name]
      properties:
        id:
          type: string
        name:
          type: string

    Sample:
      type: object
      required: [id, status, createdAt, updatedAt]
      properties:
        id:
          type: string
        barcode:
          type: string
          nullable: true
        emgId:
          type: string
          nullable: true
        status:
          type: string
          enum:
            - Probe ausstehend
            - Probe gebucht
            - Probe verschickt
            - Laboreingang erfolgt
            - Probe untersucht
        orderId:
          type: string
          nullable: true
        testType:
          $ref: '#/components/schemas/TestType'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    SampleList:
      type: object
      required: [total, offset, limit, items]
      properties:
        total:
          type: integer
        offset:
          type: integer
        limit:
          type: integer
        items:
          type: array
          items:
            $ref: '#/components/schemas/Sample'

    Document:
      type: object
      required: [id, name, createdAt, updatedAt]
      properties:
        id:
          type: string
        name:
          type: string
        type:
          type: string
          enum: [Contract, NDA, EULA, 'License Agreement']
          nullable: true
        status:
          type: string
          enum: [Draft, Active, Canceled, Expired]
          nullable: true
        publishDate:
          type: string
          format: date
          nullable: true
        expirationDate:
          type: string
          format: date
          nullable: true
        fileName:
          type: string
          nullable: true
        fileSize:
          type: integer
          nullable: true
        mimeType:
          type: string
          nullable: true
        downloadUrl:
          type: string
          description: Relativer Pfad zum Download-Endpunkt
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    DocumentList:
      type: object
      required: [total, offset, limit, items]
      properties:
        total:
          type: integer
        offset:
          type: integer
        limit:
          type: integer
        items:
          type: array
          items:
            $ref: '#/components/schemas/Document'
