# Generated by src/Commands/OpenApi.php — do not edit by hand.
# Source: infrastructure/openapi/base.yaml + src/Domains/*/_*.openapi.yaml
openapi: 3.1.0
info:
  title: 'Raisetalk API'
  description: |
    Raisetalk is a call-intelligence platform that turns every recorded
    conversation into a measurable business signal. Sales teams, call centers
    and QA departments use it to replace manual sampling (2–5 % of calls) with
    full-coverage analysis: automated transcription and summaries in 15+
    languages, custom scoring grids written in natural language, sentiment
    and attitude detection, and weak-signal surfacing (churn risk, fraud,
    upsell opportunities, compliance breaches). Regulated industries
    (insurance, banking, leasing) rely on it to audit internal teams, AI
    bots and outsourced providers against a single benchmark.

    This API is the programmatic entry point to that platform. It exposes
    the full lifecycle: provisioning tenants (instances), teams and users;
    feeding the ingestion pipeline (direct upload, SFTP, email, connectors);
    reading transcripts, scores and AI-populated attributes; configuring
    evaluation models and the question library; computing live or saved
    KPIs; running automation rules that trigger alerts and workflows; and
    querying the RAG index to ask natural-language questions across the
    corpus.

    **Contact.**
    - Email: <span class="rt-contact-email">info [at] raisetalk [dot] com</span>
    - LinkedIn: [raisetalk on LinkedIn](https://www.linkedin.com/company/raisetalk/)

    **Generating a client SDK.**
    This spec is consumable by <a href="https://openapi-generator.tech/" target="_blank" rel="noopener">OpenAPI Generator</a>
    to produce a typed client in the language of your choice (PHP, TypeScript, Python, Go, Java, …).
    One-liner — no install needed, only Docker:
    ```bash
    docker run --rm \
      -v "${PWD}:/local" \
      openapitools/openapi-generator-cli generate \
      -i https://api.raisetalk.com/openapi.yaml \
      -g php \
      -o /local/sdk/php
    ```
    Swap `-g php` for any other generator key (`typescript-axios`, `python`, `go`, `java`, …)
    to target a different language. See the generator list on the link above.

    **Authentication.** `Authorization: Bearer <token>` (64 hex chars) on
    every route except a small public set (login, signup, password reset,
    provider webhooks). Public routes are explicitly marked `security: []`.

    **Authorization.** Each authenticated user carries a set of rights
    (`manage_users`, `view_talks`, `manage_grids`, …). A missing right
    returns `403`.

    **Multi-tenancy.** Resources are scoped to a tenant (`instance`).
    Partner instances see and manage a defined subset of client instances
    through their scopes — useful for outsourcers operating Raisetalk on
    behalf of several customers.

    **Error shape.**
    ```json
    {"result": "error", "message": "Unexpected error #<hex8>", "error": "<details>"}
    ```
    The 8-char hex in `message` is a stable internal error code — useful
    when opening a support ticket.
  version: 19.1.6.2
servers:
  -
    url: 'https://api.raisetalk.com'
    description: Production
security:
  -
    bearerAuth: []
tags:
  -
    name: Login
    description: 'Authentication, signup, password reset, impersonation.'
  -
    name: Users
    description: 'User CRUD, rights, team membership.'
  -
    name: Teams
    description: 'Multi-tenant team management.'
  -
    name: Instances
    description: 'Client accounts, billing, configuration.'
  -
    name: Upload
    description: 'Audio uploads (direct, SFTP, email) and processing lifecycle.'
  -
    name: Attributes
    description: 'Custom and AI-generated attributes.'
  -
    name: Library
    description: 'Reusable question and summary templates.'
  -
    name: Kpis
    description: 'Performance indicators and aggregations.'
  -
    name: Models
    description: 'Evaluation scorecards (scoring grids) — reusable compositions of categories and questions applied to uploads to produce the score.'
  -
    name: Providers
    description: 'AI provider configuration catalog.'
  -
    name: Automations
    description: 'Automation rules (triggers and actions).'
  -
    name: Rag
    description: "Retrieval-Augmented Generation over the tenant's indexed talks."
  -
    name: Meta
    description: 'Utility endpoints (version, languages, spec).'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: Token
      description: '64-char hex token issued by the login endpoints. Opaque to clients; send verbatim in the `Authorization` header.'
  schemas:
    Error:
      type: object
      required:
        - result
        - message
      properties:
        result:
          type: string
          enum:
            - error
        message:
          type: string
          example: 'Unexpected error #af51f873'
        error:
          description: 'Technical detail (string or object depending on the route).'
      example:
        result: error
        message: 'Unexpected error #af51f873'
        error: 'Not allowed through role'
    Success:
      type: object
      required:
        - result
      properties:
        result:
          type: string
          enum:
            - success
      example:
        result: success
    AttributeLabelEntry:
      type: object
      description: 'One localized label — returned as an array entry, one per active instance locale.'
      properties:
        locale:
          type: string
          enum:
            - fr_FR
            - en_GB
            - de_DE
            - es_ES
        label:
          type: string
          nullable: true
    AttributeLocaleMap:
      type: object
      description: "Locale-keyed string map. Keys must match every active locale of the caller's instance."
      additionalProperties:
        type: string
      example:
        fr_FR: Région
        en_GB: Region
        de_DE: Region
        es_ES: Región
    AttributeValueDetail:
      type: object
      description: 'One value of a `list`-type attribute, as returned by GET /attributes/{attributeId}.'
      properties:
        id:
          type: integer
        created_at:
          type: string
          description: "Datetime (`YYYY-MM-DD HH:MM:SS`) in the caller's timezone."
        disabled_at:
          type: string
          nullable: true
        labels:
          $ref: '#/components/schemas/AttributeLocaleMap'
    AttributeValueListRow:
      type: object
      description: 'One value as returned inside `/attributes/list?display=full` (no `created_at` here).'
      properties:
        id:
          type: integer
        disabled_at:
          type: string
          nullable: true
        labels:
          $ref: '#/components/schemas/AttributeLocaleMap'
    AttributeAutoFill:
      type: object
      description: 'Auto-fill configuration returned alongside an attribute. `active` is an integer 0/1 on read.'
      properties:
        active:
          type: integer
          enum:
            - 0
            - 1
        autoQuestion:
          type: array
          description: 'Per-locale prompt entries, one per active locale.'
          items:
            $ref: '#/components/schemas/AttributeLabelEntry'
    Attribute:
      type: object
      description: 'Full attribute shape returned by GET /attributes/{attributeId}.'
      properties:
        id:
          type: integer
        instance_id:
          type: integer
        type:
          type: string
          enum:
            - list
            - string
            - number
        colorScheme:
          type: string
          description: 'One of the theme keys returned by GET /attributes/colorSchemes.'
        created_by:
          type: integer
        created_name:
          type: string
          nullable: true
          description: '`fullname` of the user who created the attribute.'
        created_at:
          type: string
        disabled_at:
          type: string
          nullable: true
        labels:
          type: array
          description: 'Localized labels, one entry per active locale.'
          items:
            $ref: '#/components/schemas/AttributeLabelEntry'
        autoFill:
          $ref: '#/components/schemas/AttributeAutoFill'
        values:
          type: array
          description: 'Empty for `string` / `number` types; populated for `list`.'
          items:
            $ref: '#/components/schemas/AttributeValueDetail'
        isUsed:
          type: boolean
          description: 'True when at least one upload currently carries this attribute. Only set on GET /attributes/{attributeId}.'
    AttributeListRow:
      type: object
      description: |
        One row of GET /attributes/list. Fields depend on `display`:
          * `light`   — drops `created_by`, `valuesCountTotal`
          * `default` — baseline below
          * `full`    — adds `instance_id` and the nested `values` tree
      properties:
        id:
          type: integer
        instance_id:
          type: integer
          description: 'Only on `display=full`.'
        attribute_type:
          type: string
          enum:
            - list
            - string
            - number
        auto_attribute:
          type: integer
          enum:
            - 0
            - 1
        color_scheme:
          type: string
        created_by:
          type: integer
          description: 'Absent on `display=light`.'
        created_at:
          type: string
        disabled_at:
          type: string
          nullable: true
        label:
          type: string
          description: "Label in the caller's locale (joined from `attributes_labels`)."
        valuesCountTotal:
          type: integer
          description: 'Absent on `display=light`.'
        autoFill:
          $ref: '#/components/schemas/AttributeAutoFill'
        labels:
          type: array
          items:
            $ref: '#/components/schemas/AttributeLabelEntry'
        values:
          type: array
          description: 'Only populated on `display=full` for `list`-typed attributes.'
          items:
            $ref: '#/components/schemas/AttributeValueListRow'
    AttributesListResponse:
      type: object
      required:
        - show
        - total
        - page
        - pages
        - results
      properties:
        show:
          type: integer
        total:
          type: integer
        page:
          type: integer
        pages:
          type: integer
        results:
          type: array
          items:
            $ref: '#/components/schemas/AttributeListRow'
    AttributeAutoFillInput:
      type: object
      required:
        - active
        - autoQuestion
      description: |
        Auto-fill configuration sent on create/update. `active` MUST be a real
        boolean (not 0/1). `autoQuestion` is a locale-keyed map that MUST cover
        every active locale of the caller's instance.
      properties:
        active:
          type: boolean
        autoQuestion:
          $ref: '#/components/schemas/AttributeLocaleMap'
    AttributeCreate:
      type: object
      required:
        - entity
        - type
        - colorScheme
        - autoFill
      description: |
        Only `entity`, `type`, `colorScheme`, `autoFill`, `values` are accepted —
        any extra field is rejected with a 400. When `type` is `string` or
        `number`, `values` MUST be omitted or empty.
      properties:
        entity:
          allOf:
            -
              $ref: '#/components/schemas/AttributeLocaleMap'
          description: "Locale-keyed label map. Must list every active locale of the caller's instance."
        type:
          type: string
          enum:
            - list
            - string
            - number
        colorScheme:
          type: string
          description: 'One of the theme keys returned by GET /attributes/colorSchemes.'
        autoFill:
          $ref: '#/components/schemas/AttributeAutoFillInput'
        values:
          type: array
          description: |
            Required when `type` is `list`. Each entry is a locale-keyed label
            map that MUST list every active locale of the instance.
          items:
            $ref: '#/components/schemas/AttributeLocaleMap'
    AttributeUpdate:
      type: object
      required:
        - entity
        - colorScheme
      description: |
        Only `entity`, `colorScheme`, `autoFill`, `values` are accepted — any
        extra field is rejected with a 400. `type` is immutable after creation
        and cannot be sent here. `values` is only allowed when the attribute
        `type` is `list`.
      properties:
        entity:
          allOf:
            -
              $ref: '#/components/schemas/AttributeLocaleMap'
          description: 'Locale-keyed label map covering every active locale of the instance.'
        colorScheme:
          type: string
          description: 'One of the theme keys returned by GET /attributes/colorSchemes.'
        autoFill:
          $ref: '#/components/schemas/AttributeAutoFillInput'
        values:
          type: object
          description: |
            Object keyed either by the existing `value_id` (integer) to edit an
            existing value, or by a fresh key prefixed with `new` (e.g.
            `newA1`, `new_42`) to create a new value. Each entry is a
            locale-keyed label map covering every active locale of the instance.
          additionalProperties:
            $ref: '#/components/schemas/AttributeLocaleMap'
          example:
            122:
              fr_FR: 'Téléphone entrant'
              en_GB: 'Incoming call'
              de_DE: 'Eingehender Anruf'
              es_ES: 'Llamada entrante'
            newA1:
              fr_FR: 'Nouveau canal'
              en_GB: 'New channel'
              de_DE: 'Neuer Kanal'
              es_ES: 'Nuevo canal'
    AttributeMutationResponse:
      type: object
      required:
        - result
        - data
      description: 'Wrapper returned by POST /attributes, PUT /attributes/{id} and the PATCH enable/disable endpoints.'
      properties:
        result:
          type: string
          enum:
            - success
        data:
          $ref: '#/components/schemas/Attribute'
      example:
        result: success
        data:
          id: 34
          instance_id: 42
          type: list
          colorScheme: yellow
          created_by: 1
          created_name: 'John Smith'
          created_at: '2025-12-15 15:12:53'
          disabled_at: null
          labels:
            -
              locale: fr_FR
              label: Canal
            -
              locale: en_GB
              label: Channel
            -
              locale: de_DE
              label: Kanal
            -
              locale: es_ES
              label: Canal
          autoFill:
            active: 0
            autoQuestion:
              - { locale: fr_FR, label: null }
              - { locale: en_GB, label: null }
              - { locale: de_DE, label: null }
              - { locale: es_ES, label: null }
          values:
            -
              id: 122
              created_at: '2025-11-04 10:17:43'
              disabled_at: null
              labels: { fr_FR: 'Téléphone entrant', en_GB: 'Incoming call', de_DE: 'Eingehender Anruf', es_ES: 'Llamada entrante' }
    AutomationDefinition:
      type: object
      description: |
        Request body for POST /automation and PUT /automation/{id}, and shape of
        the stored `definition` for an existing automation. Only the listed
        keys are accepted; extra keys are rejected.

        `conditions` is mandatory when `trigger.event` is a non-null string.
      required:
        - metadata
        - trigger
        - actions
      properties:
        metadata:
          type: object
          required:
            - description
          properties:
            description:
              type: string
              description: 'Human-readable label for the rule. Non-empty.'
        trigger:
          type: object
          description: |
            Exactly one of `event` (non-null string) or `computed` (literally `true`)
            must be set; the other must be `null` or absent.

            - `event` — key from GET /automations/reference (`events[].value`).
            - `computed` — marks the automation as computed (scheduled aggregation).
              The aggregation itself is configured through a `purge` action (or
              any other computed-aware action) in `actions`. Requires
              `manage_own_instance` or `manage_instances`.
          properties:
            event:
              type: string
              nullable: true
              enum: [score.created, score.tested]
              description: 'Event key, or `null` for a computed trigger.'
            computed:
              type: boolean
              nullable: true
              description: '`true` marks the rule as computed. Must be `null` or absent when `event` is set.'
        conditions:
          type: array
          description: |
            Required when `trigger.event` is set. May be empty/absent for
            computed triggers.

            Some `data` values require an extra id field on the condition:
            - `upload.scores.answer_value` → `question_id` (integer)
            - `upload.scores.category_score` → `category_id` (integer)
            - `upload.attributes` → `attribute_id` (integer)
          items:
            $ref: '#/components/schemas/AutomationCondition'
        actions:
          type: array
          minItems: 1
          description: 'At least one action. `type` drives the required extra fields.'
          items:
            $ref: '#/components/schemas/AutomationAction'
        snooze_for:
          type: integer
          nullable: true
          minimum: 1
          description: 'Optional. If set, delays the next execution by this many seconds.'
    AutomationCondition:
      type: object
      required:
        - data
        - comparison
        - kind
        - value
      description: |
        One leaf of the condition tree. The type of `value` depends on `kind`:
        - `scalar` → `value` is an integer.
        - `list`   → `value` is a non-empty array of integers.
      properties:
        data:
          type: string
          enum:
            - upload.score
            - upload.model.id
            - upload.scores.answer_value
            - upload.scores.category_score
            - upload.attributes
            - billing_operations.instance_balance
        comparison:
          type: string
          enum:
            - equal
            - notEqual
            - lt
            - lte
            - gt
            - gte
            - in
            - notIn
        kind:
          type: string
          enum:
            - scalar
            - list
        value:
          oneOf:
            -
              type: integer
            -
              type: array
              items: { type: integer }
              minItems: 1
          description: 'Integer when `kind=scalar`, non-empty integer array when `kind=list`.'
        question_id:
          type: integer
          description: 'Required when `data=upload.scores.answer_value`.'
        category_id:
          type: integer
          description: 'Required when `data=upload.scores.category_score`.'
        attribute_id:
          type: integer
          description: 'Required when `data=upload.attributes`.'
    AutomationAction:
      type: object
      required:
        - type
      description: |
        Action to execute when the rule fires. Shape depends on `type`:
        - `webhook` → requires `connector` (object with `id`, `name`, `url`).
        - `notify`  → requires `recipients` (array; each item has at least one of `relatives`, `users`, `emails`).
        - `purge`   → requires `data` (non-empty string) and `period` (ISO-8601 duration, e.g. `P1D`, `P4W`, `P12W`). Requires `manage_own_instance` or `manage_instances`.
      properties:
        type:
          type: string
          enum:
            - webhook
            - notify
            - purge
        connector:
          type: object
          nullable: true
          description: '`webhook` only. Connector to POST to when the rule fires.'
          required:
            - id
            - name
            - url
          properties:
            id:
              type: integer
            name:
              type: string
            url:
              type: string
              description: 'HTTP(S) endpoint. May contain `{{connectorUserId}}` and other placeholders.'
        recipients:
          type: array
          nullable: true
          description: '`notify` only. Each recipient group must set at least one of `relatives`, `users`, `emails`.'
          items:
            type: object
            properties:
              relatives: { type: array, items: { type: string, enum: [creator, advisor, self_manager, above_manager, administrator] } }
              users: { type: array, items: { type: integer, description: 'User id.' } }
              emails: { type: array, items: { type: string, format: email } }
        data:
          type: string
          description: '`purge` only. Target data key.'
        period:
          type: string
          pattern: '^P(\d+[DWMY])+$'
          description: '`purge` only. ISO-8601 duration (e.g. `P1D`, `P4W`, `P12W`).'
    Automation:
      type: object
      description: 'Automation as returned by the read / create / update / enable / disable endpoints. `definition` is the parsed JSON stored in DB and mirrors AutomationDefinition.'
      properties:
        id:
          type: integer
        instance_id:
          type: integer
        type:
          type: string
          enum:
            - event
            - computed
          description: 'Kind of trigger, mirrored from `definition.trigger`.'
        definition:
          $ref: '#/components/schemas/AutomationDefinition'
        created_by:
          type: integer
          description: 'Id of the user who last modified the automation.'
        created_name:
          type: string
          description: 'Fullname of the original creator.'
        created_at:
          type: string
        disabled_at:
          type: string
          nullable: true
        snoozed_until:
          type: string
          nullable: true
        creator:
          type: object
          description: 'Original creator (stable identity).'
          properties:
            id:
              type: integer
            fullname:
              type: string
            email:
              type: string
              format: email
            locale:
              type: string
              enum: [fr_FR, en_GB, de_DE, es_ES]
        last_modifier:
          type: object
          description: 'Last user who updated the automation.'
          properties:
            id:
              type: integer
            fullname:
              type: string
            email:
              type: string
              format: email
            locale:
              type: string
              enum: [fr_FR, en_GB, de_DE, es_ES]
    AutomationListItem:
      type: object
      description: |
        Row shape in `/automations/list.results`. `definition` is:
        - the plain description string for `display=light` and `display=default`,
        - the full `AutomationDefinition` object for `display=full`.

        `event` mirrors `definition.trigger.event`; it is the literal string
        `"null"` when the trigger is computed.
      properties:
        id:
          type: integer
        instance_id:
          type: integer
        type:
          type: string
          enum:
            - event
            - computed
        definition:
          oneOf:
            -
              type: string
              description: 'Plain description (light/default).'
            -
              $ref: '#/components/schemas/AutomationDefinition'
        event:
          type: string
          description: 'Trigger event key, or the literal `"null"` for computed triggers.'
        created_at:
          type: string
        disabled_at:
          type: string
          nullable: true
        snoozed_until:
          type: string
          nullable: true
        creator:
          type: object
          properties:
            id:
              type: integer
            fullname:
              type: string
            email:
              type: string
              format: email
            locale:
              type: string
              enum: [fr_FR, en_GB, de_DE, es_ES]
        last_modifier:
          type: object
          properties:
            id:
              type: integer
            fullname:
              type: string
            email:
              type: string
              format: email
            locale:
              type: string
              enum: [fr_FR, en_GB, de_DE, es_ES]
    AutomationReference:
      type: object
      description: 'Grammar used by the rule builder. Entries with `restrictions.roleKey` are filtered out for callers whose `role_key` is not in the list.'
      properties:
        comparisons:
          type: array
          description: 'Authorized comparison operators.'
          items:
            type: object
            properties:
              value: { type: string, enum: [equal, lte, gte, lt, gt, in, not_in] }
              labelKey: { type: string }
        kinds:
          type: array
          description: 'Authorized value kinds in a condition.'
          items:
            type: object
            properties:
              value: { type: string, enum: [scalar, list] }
              labelKey: { type: string }
        dataTypes:
          type: array
          description: 'Condition `data` keys, with compatible kinds and optional extra-id requirement.'
          items:
            type: object
            properties:
              value: { type: string }
              labelKey: { type: string }
              requiresExtra: { description: '`false` or the name of the required extra id field (`question_id`, `category_id`, `attribute_id`).', oneOf: [{ type: boolean }, { type: string }] }
              compatibleKinds: { type: array, items: { type: string, enum: [scalar, list] } }
              useComponent: { type: string, description: 'Optional UI component hint.' }
              restrictions: { type: object, properties: { roleKey: { type: array, items: { type: string } } } }
        relatives:
          type: array
          description: 'People targetable via `actions[].recipients[].relatives`.'
          items:
            type: object
            properties:
              value: { type: string, enum: [administrator, creator, advisor, self_manager, above_manager] }
              labelKey: { type: string }
              restrictions: { type: object, properties: { roleKey: { type: array, items: { type: string } } } }
        placeholders:
          type: array
          description: 'Template placeholders usable in notification bodies.'
          items:
            type: object
            properties:
              value: { type: string }
              labelKey: { type: string }
              descriptionKey: { type: string }
              restrictions: { type: object, properties: { roleKey: { type: array, items: { type: string } } } }
        events:
          type: array
          description: 'Events that can fire an `event`-triggered automation.'
          items:
            type: object
            properties:
              value: { type: string }
              labelKey: { type: string }
        snoozeDurations:
          type: array
          description: 'Preset durations for `snooze_for`.'
          items:
            type: object
            properties:
              labelKey: { type: string }
              seconds: { type: integer }
    Instance:
      type: object
      description: |
        Full instance payload returned by `GET /instance`, `GET /instance/{id}`
        and `PUT /instance/{id}` (`data`).
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        status:
          type: integer
          enum:
            - 0
            - 1
            - 2
        rag_provider:
          type: string
          description: 'RAG backend selected for this instance (defaults to `Mistral` when unset). Controls which embedding/generation provider is used by the RAG query path.'
        host:
          type: string
          nullable: true
        balance:
          type: number
          description: 'Current credit balance in euros.'
        created_by:
          type: integer
          nullable: true
        created_at:
          type: string
          description: "Server timestamp, adjusted to the caller's timezone."
        locales:
          type: array
          items:
            $ref: '#/components/schemas/InstanceLocale'
        roles:
          type: array
          items:
            $ref: '#/components/schemas/InstanceRole'
        blockedFeatures:
          type: array
          items:
            type: string
          description: 'Feature flags disabled for this tenant. Absent from the payload when no feature is blocked.'
        is_partner:
          type: boolean
          description: '`true` when the admin role carries the `manage_instances` right.'
    InstanceCreateData:
      type: object
      description: 'Payload returned under `data` by `POST /instance/create`. Same base shape as `Instance` minus `balance` / `blockedFeatures` (the new tenant has no billing activity yet and no blocked features), plus `files`.'
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        status:
          type: integer
          enum:
            - 0
            - 1
            - 2
        rag_provider:
          type: string
        host:
          type: string
          nullable: true
        created_by:
          type: integer
          nullable: true
        created_at:
          type: string
        locales:
          type: array
          items:
            $ref: '#/components/schemas/InstanceLocale'
        roles:
          type: array
          items:
            $ref: '#/components/schemas/InstanceRole'
        is_partner:
          type: boolean
        files:
          type: string
          nullable: true
          description: "Absolute path to the instance's file root when `STORAGE_TYPE=local`, `null` when the deployment uses cloud storage."
    InstanceUpdate:
      type: object
      description: 'All fields optional. Unknown fields are rejected (`invalidField`).'
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 3
        status:
          type: integer
          enum:
            - 0
            - 1
            - 2
        host:
          type: string
          maxLength: 255
          description: 'Must be a valid URL (validated with `filter_var(..., FILTER_VALIDATE_URL)`).'
        locales:
          type: array
          description: 'Each item patches one existing locale on the instance. `locale_id` must already belong to the instance.'
          items:
            type: object
            required:
              - locale_id
              - locale_status
            properties:
              locale_id: { type: integer }
              locale_status: { type: integer, enum: [0, 1] }
        roles:
          type: array
          description: 'Each item patches one existing role on the instance. `role_id` must already belong to the instance.'
          items:
            type: object
            required:
              - role_id
              - role_status
            properties:
              role_id: { type: integer }
              role_status: { type: integer, enum: [0, 1] }
        is_partner:
          type: boolean
          description: 'Platform admin tenant only — toggles the `manage_instances` right on the admin role.'
    InstanceLocale:
      type: object
      properties:
        locale_id:
          type: integer
        locale:
          type: string
          enum:
            - fr_FR
            - en_GB
            - de_DE
            - es_ES
        locale_status:
          type: integer
          enum:
            - 0
            - 1
    InstanceRole:
      type: object
      properties:
        role_id:
          type: integer
        role_status:
          type: integer
          enum:
            - 0
            - 1
        role_key:
          type: string
          enum:
            - admin
            - leader
            - viewer
    InstanceScope:
      type: object
      properties:
        id:
          type: integer
        partner_instance_id:
          type: integer
        managed_instance_id:
          type: integer
        created_at:
          type: string
        created_by:
          type: integer
        managed_instance_name:
          type: string
        managed_instance_slug:
          type: string
        managed_instance_status:
          type: integer
          enum:
            - 0
            - 1
            - 2
    CreditHistoryRow:
      type: object
      properties:
        date:
          type: string
          description: '`YYYY-MM-DD` when `format=daily`, `YYYY-MM` when `format=monthly`.'
        debit:
          type: number
          description: 'Sum of debit operations on this period. Always ≤ 0.'
        credit:
          type: number
          description: 'Sum of credit operations on this period. Always ≥ 0.'
        balance:
          type: number
          nullable: true
          description: 'Closing instance balance for the period. `null` when no operation happened yet (the front carries forward the last known value).'
        unique_upload_count:
          type: integer
          description: 'Distinct uploads billed on the period.'
    InstancesListItemLight:
      type: object
      description: 'Returned when `display=light`.'
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        host:
          type: string
          nullable: true
        balance:
          type: number
        locales:
          type: array
          items:
            $ref: '#/components/schemas/InstanceLocale'
        roles:
          type: array
          items:
            $ref: '#/components/schemas/InstanceRole'
    InstancesListItemDefault:
      type: object
      description: 'Returned when `display=default` (the default).'
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        host:
          type: string
          nullable: true
        balance:
          type: number
        status:
          type: integer
          enum:
            - 0
            - 1
            - 2
        created_by:
          type: integer
          nullable: true
        created_at:
          type: string
        locales:
          type: array
          items:
            $ref: '#/components/schemas/InstanceLocale'
        roles:
          type: array
          items:
            $ref: '#/components/schemas/InstanceRole'
    InstancesListItemFull:
      type: object
      description: 'Returned when `display=full`. Adds counts, latest activity and billing aggregates.'
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        host:
          type: string
          nullable: true
        balance:
          type: number
        status:
          type: integer
          enum:
            - 0
            - 1
            - 2
        created_by:
          type: integer
          nullable: true
        created_at:
          type: string
        teamsCountTotal:
          type: integer
        usersCountTotal:
          type: integer
        uploadsCountTotal:
          type: integer
        instanceCogs:
          type: number
          description: 'Aggregate provider cost on the period. Platform admin tenant only; stripped otherwise.'
        instanceRevenues:
          type: number
          description: 'Aggregate P&L view for the instance. Platform admin tenant only; stripped otherwise.'
        raisetalkCogs:
          type: number
          description: 'Aggregate price billed to the partner (when applicable) or to the end customer.'
        partnerRevenues:
          type: number
          description: 'Aggregate price paid by the end customer (includes the partner markup when applicable).'
        partnerName:
          type: string
          nullable: true
          description: 'Name of the partner instance that carries this instance in its scope. `null` when not managed by a partner.'
        latestActivity:
          type: string
          nullable: true
          description: "Most recent login activity timestamp for this instance, in the caller's timezone. `null` when the instance has no login activity."
        locales:
          type: array
          items:
            $ref: '#/components/schemas/InstanceLocale'
        roles:
          type: array
          items:
            $ref: '#/components/schemas/InstanceRole'
    KpiLivePayload:
      type: object
      description: |
        Ad-hoc ("live") KPI definition — computed on the fly without
        persisting. Viewers (role `viewer`) see their own data only:
        `filters.users.include` is force-set to `[caller.id]` server-side.
      required:
        - definition
        - description
      additionalProperties: false
      properties:
        description:
          type: string
          description: 'Free-text label describing this KPI computation.'
          example: 'Average score — last 30 days'
        definition:
          $ref: '#/components/schemas/KpiDefinition'
    KpiDefinition:
      type: object
      required:
        - type
        - period
      additionalProperties: false
      properties:
        type:
          type: string
          description: 'Computation kind. Determines the shape of `results` in the response.'
          enum:
            - average
            - averagePerTeam
            - averagePerUser
            - averagePerAttributeValue
            - median
            - medianPerTeam
            - medianPerUser
            - medianPerAttributeValue
            - averageEvolutionPerUser
            - averageEvolutionPerQuestion
            - countEvolutionPerUser
            - countEvolutionPerTeam
            - averageFeeling
            - averageAttitudes
            - averageSentiments
            - averageEvolution
            - allAverageEvolution
            - topUploads
            - flopUploads
            - topQuestions
            - flopQuestions
            - topCategories
            - flopCategories
            - repartitionPerAttributeValue
            - repartitionPerAttributeValueEvolution
        period:
          type: object
          required:
            - start
            - end
          additionalProperties: false
          properties:
            start:
              type: string
              example: '2026-03-22 00:00:00'
              description: 'Inclusive. Format `YYYY-MM-DD HH:MM:SS`.'
            end:
              type: string
              example: '2026-04-21 23:59:59'
              description: 'Inclusive. Format `YYYY-MM-DD HH:MM:SS`.'
        filters:
          type: object
          description: |
            All filter groups use `{include, exclude}` pairs that are mutually exclusive.
            For `averagePerAttributeValue` / `medianPerAttributeValue` KPIs, exactly one
            `attributes` entry must carry `distribution: true`.
          additionalProperties: false
          properties:
            users:
              type: object
              additionalProperties: false
              properties: { include: { type: array, items: { type: integer } }, exclude: { type: array, items: { type: integer } } }
            teams:
              type: object
              additionalProperties: false
              properties: { include: { type: array, items: { type: integer } }, exclude: { type: array, items: { type: integer } } }
            models:
              type: object
              additionalProperties: false
              properties: { include: { type: array, items: { type: integer } }, exclude: { type: array, items: { type: integer } } }
            questions:
              type: object
              description: 'Question filter (ids of questions from the library). Accepted by the validator; not all KPI types honor it.'
              additionalProperties: false
              properties: { include: { type: array, items: { type: integer } }, exclude: { type: array, items: { type: integer } } }
            attributes:
              type: object
              description: 'Keys are attribute ids (digit strings); values describe inclusion / exclusion / distribution for that attribute.'
              additionalProperties: { type: object, additionalProperties: false, properties: { include: { type: array, items: { description: 'Integer id or string value.' } }, exclude: { type: array, items: { description: 'Integer id or string value.' } }, distribution: { type: boolean, description: 'Mark the attribute whose values drive the grouping (max 1 per KPI).' } } }
        order:
          type: object
          description: 'Truncation/ordering for grouped KPI types (`*PerUser`, `*PerTeam`, `*PerAttributeValue`).'
          additionalProperties: false
          properties:
            by:
              type: string
              description: 'Sort key. Free-form; the KPI class picks the supported field.'
            direction:
              type: string
              enum: [ASC, DESC]
            limit:
              type: integer
              description: 'Max entries. Capped server-side (default cap: 5); beyond that, the limit silently resets to 5.'
    KpiComputeResponse:
      type: object
      description: |
        Envelope returned by every documented `/kpi/compute/*` endpoint.
        `results` shape varies with `definition.type` — see
        `KpiComputeResults`.
      required:
        - description
        - originalDefinition
        - computedDefinition
        - results
      properties:
        description:
          type: string
          description: 'Echo of the `description` in the request.'
        originalDefinition:
          description: 'Deep clone of the KPI definition as received (before viewer-scoping and rolling-window defaults).'
          $ref: '#/components/schemas/KpiDefinition'
        computedDefinition:
          description: 'The actual definition used for the computation (with enforced `users.include` for viewers, filled-in period, etc.).'
          $ref: '#/components/schemas/KpiDefinition'
        results:
          description: 'Per-type payload. See `KpiComputeResults` for each variant.'
          $ref: '#/components/schemas/KpiComputeResults'
    KpiComputeResults:
      description: |
        `results` shape by `definition.type`:
          - `average` / `median` / `averageFeeling` / `averageAttitudes`
            / `averageSentiments` → `{score: number, count: integer}`
          - `averagePerUser` / `medianPerUser` →
            `[{user_id, fullname, items: [upload_id, …], count}]`
          - `averagePerTeam` / `medianPerTeam` →
            `[{team_id, team_name, team_path, items: [upload_id, …], count}]`
          - `averagePerAttributeValue` / `medianPerAttributeValue` →
            `[{attribute_value, items: [upload_id, …], count}]` (shape
            driven by the attribute flagged `distribution: true`)
          - `averageEvolution` / `allAverageEvolution` →
            `{average: {"YYYY-MM-DD": number|null, …}, count: integer}`
          - `averageEvolutionPerUser` → `{<userId>: {user_id, fullname, average: {date: number|null, …}, count}, …}`
          - `averageEvolutionPerQuestion` → `{<questionId>: {question_id, question, average, count}, …}`
          - `countEvolutionPerUser` / `countEvolutionPerTeam` → map keyed
            by entity id with a `count: {date: integer, …}` series
          - `topUploads` / `flopUploads` → `{results: [{upload_id, score, date, summaries, scores, user, team}, …]}`
          - `topQuestions` / `flopQuestions` → `{results: [{question_id, question, score, count}, …]}`
          - `topCategories` / `flopCategories` → `{results: [{category, score, count}, …]}`
          - `repartitionPerAttributeValue` → `{<value>: count, …}`
          - `repartitionPerAttributeValueEvolution` → `{<value>: {date: count, …}, …}`
      oneOf:
        -
          type: object
          description: 'Single-score variants (`average`, `median`, `averageFeeling`, `averageAttitudes`, `averageSentiments`).'
          properties:
            score:
              type: number
              example: 67.45
            count:
              type: integer
              example: 131
        -
          type: array
          description: 'Per-user/team/attribute-value breakdowns (`averagePerUser`, `averagePerTeam`, `averagePerAttributeValue` and their `median*` counterparts).'
          items:
            type: object
            example:
              user_id: 201
              fullname: 'John Smith'
              items: [575, 576, 577]
              count: 78
        -
          type: object
          description: 'Flat evolution series (`averageEvolution`, `allAverageEvolution`).'
          properties:
            average:
              type: object
              additionalProperties: { type: number, nullable: true }
              example: { '2026-01-05': 63.7, '2026-01-06': 100, '2026-01-07': null }
            count:
              type: integer
              example: 131
        -
          type: object
          description: 'Per-entity evolution maps (`averageEvolutionPerUser`, `averageEvolutionPerQuestion`, `countEvolutionPerUser`, `countEvolutionPerTeam`). Keys are entity ids as strings.'
          additionalProperties:
            type: object
            example:
              user_id: 201
              fullname: 'John Smith'
              average: { '2026-04-01': 0, '2026-04-02': 87.67, '2026-04-21': 100 }
              count: 12
        -
          type: object
          description: 'Top/flop list variants (`topUploads`, `flopUploads`, `topQuestions`, `flopQuestions`, `topCategories`, `flopCategories`).'
          properties:
            results:
              type: array
              items: { type: object, example: { upload_id: 4975, score: 88, date: '2026-04-21 12:34:56', scores: { global_score: 88, global_feeling: 100, client_sentiment: 100, advisor_attitude: 100 }, user: { user_id: 203, user_firstname: Alex, user_lastname: Taylor, user_fullname: 'Alex Taylor' }, team: { team_id: 114, team_name: 'Team A', team_path_id: '|1|113|114', team_path_name: '|Global|Region A|Team A' } } }
        -
          type: object
          description: 'Attribute-value repartition (`repartitionPerAttributeValue`, `repartitionPerAttributeValueEvolution`). Keys are attribute values.'
          additionalProperties: true
          example:
            'Value A': 42
            'Value B': 17
    UserProfile:
      type: object
      description: |
        User profile snapshot returned by authentication endpoints. Shape
        varies slightly between `/login` and `/logas` / `/logout`
        (impersonation resume) — see per-field notes. Naive datetimes are
        rendered in the caller's timezone (from the `Timezone` request
        header, defaults to GMT).
      required:
        - id
        - instance_id
        - email
        - firstname
        - lastname
        - fullname
        - status
        - role
        - role_key
        - locale
        - default_team_id
        - identifiers
      properties:
        id:
          type: integer
        instance_id:
          type: integer
        firstname:
          type: string
        lastname:
          type: string
        fullname:
          type: string
        created_at:
          type: string
          description: "Naive datetime in the caller's timezone."
        status:
          type: integer
          enum:
            - 0
            - 1
        role:
          type: integer
          description: 'Numeric role id. Use `role_key` for the symbolic value.'
        locale:
          type: string
          enum:
            - fr_FR
            - en_GB
            - de_DE
            - es_ES
        email:
          type: string
          format: email
        instance_name:
          type: string
        instance_status:
          type: integer
          enum:
            - 0
            - 1
          description: 'Only present on `/login`. Absent on `/logas` and `/logout` (impersonation resume).'
        default_team_id:
          type: integer
        default_team_name:
          type: string
        role_key:
          type: string
          enum:
            - admin
            - leader
            - viewer
        talks:
          type: integer
          description: 'Only present on `/logas` and `/logout` (impersonation resume). Count of uploads owned by the user.'
        identifiers:
          type: array
          description: 'External-system identifiers attached to this user (pairs tierapp → identifier). Array with a single null-pair when none exists.'
          items:
            type: object
            properties:
              tierapp: { type: string, nullable: true }
              identifier: { type: string, nullable: true }
    ConnectionAttempt:
      type: object
      description: 'One login attempt record, trimmed to the most recent entries for the caller (cap configured server-side).'
      required:
        - success
        - created_at
        - remote_os
        - remote_ip
      properties:
        success:
          type: integer
          enum:
            - 0
            - 1
        created_at:
          type: string
          description: "Naive datetime in the caller's timezone."
        remote_os:
          type: string
          description: 'Parsed User-Agent OS — or the raw UA for non-browser clients (e.g. `curl/8.5.0`).'
        remote_ip:
          type: string
    LoginSuccess:
      type: object
      required:
        - result
        - accessToken
        - expiration
        - user
        - rights
        - connectionAttempts
      properties:
        result:
          type: string
          enum:
            - success
        accessToken:
          type: string
          description: '64-char hex bearer token.'
        expiration:
          type: string
          description: "Absolute expiration, `YYYY-MM-DD HH:MM:SS±HH:MM` in the caller's timezone."
        user:
          $ref: '#/components/schemas/UserProfile'
        rights:
          type: array
          items:
            type: string
          description: 'Effective rights for the user (see `/user/{id}/rights`).'
        connectionAttempts:
          type: array
          items:
            $ref: '#/components/schemas/ConnectionAttempt'
    LogasSuccess:
      type: object
      required:
        - result
        - accessToken
        - expiration
        - user
        - rights
      properties:
        result:
          type: string
          enum:
            - success
        accessToken:
          type: string
          description: '64-char hex bearer token, flagged as logas server-side.'
        expiration:
          type: string
        user:
          $ref: '#/components/schemas/UserProfile'
        rights:
          type: array
          items:
            type: string
    LogoutSimple:
      type: object
      required:
        - result
        - accessToken
      properties:
        result:
          type: string
          enum:
            - success
        accessToken:
          type: string
          enum:
            - destroyed
    LogasResumeOriginal:
      description: "Returned by `POST /logout` when the caller was on an impersonation (logas) token. The original user's session is re-issued automatically."
      allOf:
        -
          $ref: '#/components/schemas/LogasSuccess'
    LocalizedLabels:
      type: object
      description: |
        Locale-keyed label map. Keys are locale codes active on the caller's
        instance (e.g. `fr_FR`, `en_GB`, `de_DE`, `en_US`). Values may be empty
        strings for unused locales.
      additionalProperties:
        type: string
      example:
        fr_FR: 'Évaluation Commerciale'
        en_GB: 'Sales Evaluation'
    ModelListCreator:
      type: object
      properties:
        id:
          type: integer
        firstname:
          type: string
        lastname:
          type: string
    ModelDetailCreator:
      type: object
      description: 'Detail responses expose creator fields under prefixed keys.'
      properties:
        creator_id:
          type: integer
        creator_firstname:
          type: string
        creator_lastname:
          type: string
    ModelCountOrDash:
      description: |
        Either an integer count, or the literal string `"-"` when the
        requested `display` level does not compute this count.
      oneOf:
        -
          type: integer
        -
          type: string
          enum:
            - '-'
    ModelsListRow:
      type: object
      properties:
        id:
          type: integer
        created_at:
          type: string
          description: 'Timezone-converted via the `X-Timezone` header.'
        disabled_at:
          description: 'Null when the model is active.'
          oneOf:
            -
              type: string
            -
              type: 'null'
        status:
          type: integer
          enum:
            - 0
            - 1
          description: '1 = active, 0 = disabled.'
        creator:
          $ref: '#/components/schemas/ModelListCreator'
        labels:
          $ref: '#/components/schemas/LocalizedLabels'
        categories:
          description: 'Category count. `"-"` on `display=light`.'
          $ref: '#/components/schemas/ModelCountOrDash'
        questions:
          description: 'Question count. `"-"` on `display=light`.'
          $ref: '#/components/schemas/ModelCountOrDash'
        uploads:
          description: 'Upload count referencing this model. Always `"-"` unless `display=full`.'
          $ref: '#/components/schemas/ModelCountOrDash'
    ModelsListResponse:
      type: object
      properties:
        show:
          type: integer
        total:
          type: integer
        page:
          type: integer
        pages:
          type: integer
        results:
          type: array
          items:
            $ref: '#/components/schemas/ModelsListRow'
    ModelDetailQuestion:
      type: object
      properties:
        id:
          type: integer
          description: 'models_questions row id.'
        library_question_id:
          type: integer
          description: 'Referenced library question.'
        question_position:
          type: integer
        question_type:
          type: string
          description: 'Library question type (e.g. `auto`, `manual`).'
        question_source:
          type: string
          enum:
            - generic
            - client
        question_options:
          type: array
          description: 'Scoring options — scalars (int/null/string) as defined on the library question.'
          items: []
          example:
            - 10
            - 0
            - null
        question_formats:
          type: array
          description: 'Output formats requested from the LLM.'
          items:
            type: string
          example:
            - provide_boolean
            - provide_citation
            - provide_explanation
        question_labels:
          type: object
          description: |
            Locale-keyed object. Each locale carries a `{label, question}` pair
            (short label + full prompt).
          additionalProperties:
            type: object
            properties:
              label: { type: string }
              question: { type: string }
          example:
            fr_FR:
              label: Empathie
              question: "Le conseiller fait preuve d'empathie."
        na_allowed:
          type: integer
          enum:
            - 0
            - 1
          description: '1 if the LLM may answer N/A.'
    ModelDetailCategory:
      type: object
      properties:
        id:
          type: integer
          description: 'models_categories row id.'
        library_category_id:
          type: integer
        category_position:
          type: integer
        category_source:
          type: string
          enum:
            - generic
            - client
        category_labels:
          $ref: '#/components/schemas/LocalizedLabels'
        questions:
          type: object
          description: 'Map keyed by models_questions id (stringified integer).'
          additionalProperties:
            $ref: '#/components/schemas/ModelDetailQuestion'
    ModelDetail:
      type: object
      properties:
        model:
          type: object
          properties:
            id:
              type: integer
            created_at:
              type: string
            disabled_at:
              oneOf: [{ type: string }, { type: 'null' }]
            creator:
              $ref: '#/components/schemas/ModelDetailCreator'
            labels:
              $ref: '#/components/schemas/LocalizedLabels'
            uploads:
              type: integer
              description: 'Count of uploads referencing this model.'
        categories:
          type: object
          description: 'Map keyed by models_categories id (stringified integer).'
          additionalProperties:
            $ref: '#/components/schemas/ModelDetailCategory'
    ModelCreate:
      type: object
      description: |
        Only `model` and `categories` are accepted on create; extra fields are
        rejected. A model binds library categories and questions together to
        form an evaluation grid.
      required:
        - model
        - categories
      additionalProperties: false
      properties:
        model:
          type: object
          required:
            - labels
          properties:
            labels:
              allOf: [{ $ref: '#/components/schemas/LocalizedLabels' }, { description: "Locale-keyed label map for the model. Keys must match the active locales\nof the caller's instance. At least one locale must have a non-empty label.\n" }]
        categories:
          type: array
          description: |
            Library categories included in the model, each listing the library question ids
            to use. Category and question ids must already exist in the library (see
            GET /library/list), and each question must belong to the referenced category.
          items:
            type: object
            required:
              - id
              - questions
            properties:
              id: { type: integer, description: 'Library category id.' }
              questions: { type: array, description: 'Library question ids — must belong to the referenced category.', items: { type: integer } }
          example:
            -
              id: 1
              questions: [101, 102, 103]
            -
              id: 42
              questions: [210]
    ModelUpdate:
      type: object
      description: |
        On update, `model`, `categories` and `status` are accepted; extra
        fields are rejected. Use PATCH /model/{id}/enable or /disable for a
        dedicated lifecycle toggle.
      required:
        - model
        - categories
      additionalProperties: false
      properties:
        model:
          type: object
          required:
            - labels
          properties:
            labels:
              $ref: '#/components/schemas/LocalizedLabels'
        categories:
          type: array
          items:
            type: object
            required:
              - id
              - questions
            properties:
              id: { type: integer }
              questions: { type: array, items: { type: integer } }
          example:
            -
              id: 1
              questions: [101, 102, 103]
        status:
          type: integer
          enum:
            - 0
            - 1
          description: '1 = active, 0 = disabled. Ignored on create.'
    ModelMutationResponse:
      type: object
      description: 'Envelope used by POST /model/create and PATCH /model/{id}/{enable,disable}.'
      required:
        - result
        - data
      properties:
        result:
          type: string
          enum:
            - success
        data:
          $ref: '#/components/schemas/ModelDetail'
    ModelUpdateResponse:
      type: object
      description: 'Envelope used by PUT /model/{id} — adds a redundant `id` acknowledgement string.'
      required:
        - result
        - id
        - data
      properties:
        result:
          type: string
          enum:
            - success
        id:
          type: string
          enum:
            - 'Model updated'
        data:
          $ref: '#/components/schemas/ModelDetail'
    Team:
      type: object
      description: |
        A team record. `path_id` and `path_name` are materialized hierarchy
        paths using `|` as separator, always starting with `|` (e.g.
        `|1|112|114`, `|Global|France|Paris`). Row shape varies slightly with
        the `display` parameter on `GET /teams/list`:
        - `light` omits `parent_id`, `path_name`, `status`, `created_by`
        - `default` returns the fields below
        - `full` adds `subteams` (count of descendants including self) and
          `totalUsers` (count of users attached to this team).
      properties:
        id:
          type: integer
        instance_id:
          type: integer
        parent_id:
          type: integer
          nullable: true
          description: 'Null on the root team.'
        name:
          type: string
        path_id:
          type: string
          description: 'Materialized id path, `|`-separated and leading-separated (e.g. `|1|112|114`).'
        path_name:
          type: string
          description: 'Materialized name path, `|`-separated and leading-separated (e.g. `|Global|France|Paris`).'
        status:
          type: integer
          enum:
            - 0
            - 1
          description: '0 = archived, 1 = active.'
        created_by:
          type: integer
          nullable: true
          description: 'Id of the user who created the team. Null on seeded/root teams, 0 when created by system flows.'
        created_at:
          type: string
          description: "Creation timestamp converted to the caller's timezone (from `Timezone` header)."
        subteams:
          type: integer
          description: 'Only returned when `display=full`. Count of descendants including self.'
        totalUsers:
          type: integer
          description: 'Only returned when `display=full`. Count of users attached to this team.'
    TeamWithChildren:
      allOf:
        -
          $ref: '#/components/schemas/Team'
        -
          type: object
          required:
            - children
          properties:
            children:
              type: object
              description: "Direct descendants of this team that are also in the caller's visibility perimeter."
              required: [found, results]
              properties: { found: { type: integer, description: 'Number of rows in `results`.' }, results: { type: array, items: { $ref: '#/components/schemas/Team' } } }
    UploadUserRef:
      type: object
      description: 'Reference to a user (creator or advisor) with their team metadata.'
      properties:
        id:
          type: integer
          nullable: true
        fullname:
          type: string
          nullable: true
        email:
          type: string
          nullable: true
        firstname:
          type: string
          nullable: true
        lastname:
          type: string
          nullable: true
        locale:
          type: string
          nullable: true
          description: 'e.g. `fr_FR`.'
        default_team_id:
          type: integer
          nullable: true
        teamid:
          type: integer
          nullable: true
        teamname:
          type: string
          nullable: true
        teampath:
          type: string
          nullable: true
          description: 'Human-readable `|Root|Child|Leaf` path.'
        team_path_id:
          type: string
          nullable: true
          description: 'Path as pipe-separated ids, e.g. `|1|7|42`.'
    UploadBasics:
      type: object
      description: |
        Canonical upload payload returned by the detail endpoints. `billing`
        is only injected on `GET /uploads/{id}/scores` for callers with the
        `manage_billing` right; it is absent or `null` elsewhere.
      properties:
        id:
          type: integer
        instance_id:
          type: integer
        originalFilename:
          type: string
        storedFilename:
          type: string
        created_at:
          type: string
          description: "Converted to the caller's timezone."
        region:
          type: string
          description: 'AI region picked for analysis (`eu`, `us`, `global`).'
        path_stored:
          type: string
        size:
          type: integer
          description: Bytes.
        length:
          type: number
          description: Seconds.
        audio_channels:
          type: string
          enum:
            - mono
            - stereo
        mediaType:
          type: string
        speakers:
          type: string
          nullable: true
        transcription_status:
          type: integer
          nullable: true
        summaries_status:
          type: integer
          nullable: true
        attributes_status:
          type: integer
          nullable: true
        transcription_id:
          type: string
          nullable: true
        transcription_path:
          type: string
          nullable: true
        transcription_token:
          type: string
          nullable: true
        callback:
          type: string
          nullable: true
        questions_status:
          type: integer
          nullable: true
        language:
          type: string
        analysis_language:
          type: string
        transcription_language:
          type: string
        purge_interaction_file:
          type: integer
          enum:
            - 0
            - 1
        purge_transcription:
          type: integer
          enum:
            - 0
            - 1
        score:
          type: number
          nullable: true
        creator:
          $ref: '#/components/schemas/UploadUserRef'
        advisor:
          $ref: '#/components/schemas/UploadUserRef'
        model:
          type: object
          properties:
            id:
              type: integer
            labels:
              type: object
              additionalProperties: { type: string }
              description: 'Map of locale → label.'
        ai_models:
          type: object
          properties:
            analysis_region:
              type: string
              enum: [eu, us, global]
            transcription:
              type: string
            summaries:
              type: string
            assertions:
              type: string
              description: 'Technical key of the model used for the questions/assertions feature.'
        billing:
          nullable: true
          $ref: '#/components/schemas/UploadBillingTotal'
    UploadBillingTotal:
      type: object
      description: 'Per-feature totals injected into `UploadBasics.billing` on `/uploads/{id}/scores` for `manage_billing` callers.'
      properties:
        transcription:
          type: number
        summaries:
          type: number
        questions:
          type: number
        grandtotal:
          type: number
    UploadBillingFull:
      type: object
      description: 'Full breakdown returned by `GET /uploads/{id}/billing`. Each array holds individual billing entries (one transcription call, one summary entry per type, one per questions chunk).'
      properties:
        transcription:
          type: array
          items:
            type: number
        summaries:
          type: array
          items:
            type: number
        questions:
          type: array
          items:
            type: number
        total:
          $ref: '#/components/schemas/UploadBillingTotal'
    UploadSummary:
      type: object
      description: 'Single summary entry produced by the summaries model.'
      properties:
        summary_type:
          type: string
          description: 'e.g. `general`, `clientsentiment`, `advisorsentiment`.'
        summary_score:
          type: integer
          nullable: true
        summary_items:
          type: object
          properties:
            items:
              type: array
              items: { type: object, properties: { item: { type: string }, positive: { type: boolean }, timespan: { type: object, properties: { start: { type: integer, description: Milliseconds. }, end: { type: integer, description: Milliseconds. } } }, justification: { type: string } } }
    UploadScores:
      type: object
      description: 'Scores payload shared by `GET /uploads/{id}/scores`, `GET /uploads/{id}/versions/{version}.scores`, and `PATCH /uploads/{id}/criteria.uptodateScores`.'
      properties:
        categories:
          type: array
          items:
            type: object
            properties:
              category: { type: object, properties: { category_id: { type: integer }, source: { type: string, description: '`generic` or `custom`.' }, instance_id: { type: integer, nullable: true }, labels: { type: object, additionalProperties: { type: string }, description: 'Map of locale → label.' }, maxValues: { type: integer }, answerValues: { type: integer }, categoryScore: { type: number, nullable: true } } }
              questions: { type: object, description: "**Object keyed by question id** (not an array), reflecting the\nserver's internal aggregation order. Iterate its values.\n", additionalProperties: { type: object, properties: { question: { type: object, properties: { question_id: { type: integer }, category_id: { type: integer }, type: { type: string }, source: { type: string }, instance_id: { type: integer, nullable: true }, options: { type: string, description: 'Raw JSON array as persisted in the DB, e.g. `"[10, 0, null]"`.' }, localisation: { type: object, description: 'Map of locale → {label, question}.' }, category: { type: integer }, maxValue: { type: integer, nullable: true }, minValue: { type: integer, nullable: true }, scoreRate: { type: number, nullable: true, description: 'Only present when the question has a `provide_score` answer.' }, score: { type: number, nullable: true, description: 'Only present when the question has a `provide_score` answer.' } } }, answers: { type: object, description: "Map keyed by `answer_format_key` (`provide_score`,\n`provide_citation`, `provide_explanation`). One or more\nkeys depending on the question type.\n", additionalProperties: { type: object, properties: { answer_value: { nullable: true, description: 'Integer for `provide_score`; string otherwise (may be empty).' }, format: { type: string, nullable: true }, format_key: { type: string } } } }, timestamps: { type: array, items: { type: object, properties: { start: { type: integer, description: Milliseconds. }, end: { type: integer, description: Milliseconds. } } } } } } }
    UploadAttributesMap:
      type: object
      description: 'Map of `attribute_id` → attribute payload. Returned by `GET /uploads/{id}/attributes` and embedded in several mutation responses.'
      additionalProperties:
        type: object
        properties:
          disabled_at:
            type: string
            nullable: true
          labels:
            type: object
            additionalProperties:
              type: string
            description: 'Map of locale → label.'
          colorScheme:
            type: string
            description: 'e.g. `grey`, `blue`, `orange`, or a hex color.'
          auto_attribute:
            type: integer
            enum:
              - 0
              - 1
          values:
            description: 'Object keyed by `value_id` when the attribute is typed `list`; empty array `[]` otherwise.'
            oneOf:
              - { type: object, additionalProperties: { type: object, properties: { disabled_at: { type: string, nullable: true }, labels: { type: object, additionalProperties: { type: string } } } } }
              - { type: array, maxItems: 0 }
          strings:
            type: array
            items:
              type: string
          numbers:
            type: array
            items:
              type: string
              description: 'Numeric value serialized as a string (trailing zeros trimmed server-side).'
    UploadListItemLight:
      type: object
      properties:
        id:
          type: integer
        created_at:
          type: string
        created_by:
          type: integer
          nullable: true
        user_id:
          type: integer
          nullable: true
        score:
          type: integer
          nullable: true
          description: 'Clamped to 0 via `GREATEST(global_score, 0)`.'
        model_id:
          type: integer
          nullable: true
        language:
          type: string
    UploadListItemDefault:
      allOf:
        -
          $ref: '#/components/schemas/UploadListItemLight'
        -
          type: object
          properties:
            originalFilename:
              type: string
            size:
              type: integer
            length:
              type: number
            transcription_status:
              type: integer
              nullable: true
            questions_status:
              type: integer
              nullable: true
            summaries_status:
              type: integer
              nullable: true
            analysis_language:
              type: string
            transcription_language:
              type: string
    UploadListItemFull:
      allOf:
        -
          $ref: '#/components/schemas/UploadListItemDefault'
        -
          type: object
          properties:
            created_by:
              type: object
              nullable: true
              properties: { id: { type: integer, nullable: true }, fullname: { type: string, nullable: true }, firstname: { type: string, nullable: true }, lastname: { type: string, nullable: true } }
            user:
              type: object
              nullable: true
              properties: { id: { type: integer, nullable: true }, fullname: { type: string, nullable: true }, firstname: { type: string, nullable: true }, lastname: { type: string, nullable: true } }
            purge_interaction_file:
              type: integer
              enum: [0, 1]
            purge_transcription:
              type: integer
              enum: [0, 1]
            model_labels:
              type: object
              additionalProperties: { type: string }
              nullable: true
            attributes:
              type: object
              nullable: true
              description: 'List-view subset of the attributes map (color_scheme, labels, auto_attribute, values).'
            general_sentiment:
              type: integer
              nullable: true
            general_summary:
              nullable: true
              description: 'Decoded summary_items JSON or null.'
            client_sentiment:
              type: integer
              nullable: true
            advisor_sentiment:
              type: integer
              nullable: true
    Identifier:
      type: object
      description: 'External-system identifier (third-party connector tag). `tierapp`/`identifier` are `null` when the user has no identifier row (LEFT JOIN).'
      properties:
        tierapp:
          type: string
          nullable: true
          maxLength: 100
        identifier:
          type: string
          nullable: true
          maxLength: 100
    User:
      type: object
      properties:
        id:
          type: integer
        instance_id:
          type: integer
        firstname:
          type: string
        lastname:
          type: string
        fullname:
          type: string
        created_at:
          type: string
        status:
          type: integer
          enum:
            - 0
            - 1
          description: '0 = inactive, 1 = active.'
        role:
          type: integer
          description: "Role id — references the instance's `roles` table."
        locale:
          type: string
          enum:
            - fr_FR
            - en_GB
            - de_DE
            - es_ES
        email:
          type: string
          format: email
        instance_name:
          type: string
        default_team_id:
          type: integer
          nullable: true
        default_team_name:
          type: string
          nullable: true
        role_key:
          type: string
          enum:
            - admin
            - leader
            - viewer
          description: "Stable string key of the user's role."
        identifiers:
          type: array
          items:
            $ref: '#/components/schemas/Identifier'
    UserSelf:
      allOf:
        -
          $ref: '#/components/schemas/User'
        -
          type: object
          properties:
            instance_status:
              type: integer
              enum: [0, 1]
              description: '1 = instance active.'
            connections_attempts:
              type: array
              items: { $ref: '#/components/schemas/ConnectionAttempt' }
    UserDetail:
      allOf:
        -
          $ref: '#/components/schemas/User'
        -
          type: object
          properties:
            talks:
              type: integer
              description: "Count of uploads authored by the user in the user's instance."
            connections_attempts:
              type: array
              items: { $ref: '#/components/schemas/ConnectionAttempt' }
    UserWithSentFlag:
      allOf:
        -
          $ref: '#/components/schemas/User'
        -
          type: object
          properties:
            instance_status:
              type: integer
              enum: [0, 1]
            sentWelcomeEmail:
              type: integer
              enum: [0, 1]
              description: '1 if the activation email was sent, 0 otherwise (mirrors the request flag).'
    UserUpdateResponse:
      type: object
      required:
        - result
        - id
        - data
      properties:
        result:
          type: string
          enum:
            - success
        id:
          type: string
          enum:
            - 'User updated'
          description: 'Legacy hard-coded marker string (not the numeric user id).'
        data:
          $ref: '#/components/schemas/UserDetail'
    UsersListResultLight:
      type: object
      description: '`display=light` projection.'
      properties:
        id:
          type: integer
        email:
          type: string
          format: email
        role:
          type: integer
        locale:
          type: string
          enum:
            - fr_FR
            - en_GB
            - de_DE
            - es_ES
        default_team_id:
          type: integer
          nullable: true
        role_key:
          type: string
          enum:
            - admin
            - leader
            - viewer
        created_at:
          type: string
    UsersListResultDefault:
      allOf:
        -
          $ref: '#/components/schemas/UsersListResultLight'
        -
          type: object
          description: '`display=default` projection — adds firstname/lastname/status.'
          properties:
            firstname:
              type: string
            lastname:
              type: string
            status:
              type: integer
              enum: [0, 1]
    UsersListResultFull:
      allOf:
        -
          $ref: '#/components/schemas/UsersListResultDefault'
        -
          type: object
          description: '`display=full` projection — adds instance/team labels, upload count, teams list, full identifiers.'
          properties:
            fullname:
              type: string
            created_by:
              type: integer
              nullable: true
            instance_id:
              type: integer
            instance_name:
              type: string
            default_team_name:
              type: string
              nullable: true
            default_team_path_id:
              type: string
              nullable: true
            default_team_path_name:
              type: string
              nullable: true
            talks:
              type: integer
            teams:
              type: array
              items: { type: object, properties: { team_id: { type: integer }, team_instance_id: { type: integer }, team_parent_id: { type: integer, nullable: true }, team_name: { type: string }, team_path_id: { type: string, nullable: true }, team_path_name: { type: string, nullable: true }, team_status: { type: integer, enum: [0, 1] }, team_created_by: { type: integer, nullable: true }, team_created_at: { type: string } } }
            identifiers:
              type: array
              items: { type: object, properties: { tierapp: { type: string, maxLength: 100 }, identifier: { type: string, maxLength: 100 }, created_at: { type: string }, created_by: { type: object, properties: { id: { type: integer, nullable: true }, email: { type: string, nullable: true }, firstname: { type: string, nullable: true }, lastname: { type: string, nullable: true } } } } }
    UsersListResponse:
      type: object
      required:
        - show
        - total
        - page
        - pages
        - results
      properties:
        show:
          type: integer
        total:
          type: integer
        page:
          type: integer
        pages:
          type: integer
        results:
          type: array
          description: |
            Per-row shape depends on the `display` query param: `light` →
            `UsersListResultLight`, `default` → `UsersListResultDefault`,
            `full` → `UsersListResultFull`.
          items:
            oneOf:
              - { $ref: '#/components/schemas/UsersListResultLight' }
              - { $ref: '#/components/schemas/UsersListResultDefault' }
              - { $ref: '#/components/schemas/UsersListResultFull' }
    UserCreate:
      type: object
      required:
        - email
        - firstname
        - lastname
        - role
        - locale
        - default_team_id
        - sendWelcomeEmail
      additionalProperties: false
      properties:
        email:
          type: string
          format: email
        firstname:
          type: string
          minLength: 3
        lastname:
          type: string
          minLength: 3
        role:
          type: integer
          description: "Role id — must exist in the caller's instance (see `GET /instance/{id}.roles`)."
        default_team_id:
          type: integer
          description: "Team id — must be one of the caller's visible teams (see `GET /teams/list`)."
        locale:
          type: string
          enum:
            - fr_FR
            - en_GB
            - de_DE
            - es_ES
          description: "Must be among the locales enabled on the caller's instance."
        sendWelcomeEmail:
          type: integer
          enum:
            - 0
            - 1
          description: 'If `1`, an activation email is sent so the user can set a password via `PUT /user/password`.'
        identifiers:
          type: array
          description: 'External-system identifiers (third-party connector tags).'
          items:
            type: object
            required:
              - tierapp
              - identifier
            properties:
              tierapp: { type: string, minLength: 1, maxLength: 100, description: 'Connector key (free string, set by the integrator).' }
              identifier: { type: string, minLength: 1, maxLength: 100, description: 'Identifier of the user in that connector.' }
    UserUpdate:
      type: object
      description: 'All fields optional. Server rejects any key outside this set.'
      additionalProperties: false
      properties:
        email:
          type: string
          format: email
        firstname:
          type: string
          minLength: 3
        lastname:
          type: string
          minLength: 3
        role:
          type: integer
          description: "Role id — must exist in the target user's instance."
        status:
          type: integer
          enum:
            - 0
            - 1
        locale:
          type: string
          enum:
            - fr_FR
            - en_GB
            - de_DE
            - es_ES
          description: "Must be among the locales enabled on the user's instance."
        default_team_id:
          type: integer
          description: "Must be one of the caller's visible teams."
        instance:
          type: integer
          description: 'Move the user to another instance (admin operation).'
        identifiers:
          type: array
          items:
            type: object
            required:
              - tierapp
              - identifier
            properties:
              tierapp: { type: string, minLength: 1, maxLength: 100 }
              identifier: { type: string, minLength: 1, maxLength: 100 }
  responses:
    Unauthorized:
      description: 'Missing, invalid or expired bearer token.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: 'Authenticated but insufficient rights for this route.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: 'Resource not found.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ValidationError:
      description: 'Invalid request payload.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ServerError:
      description: 'Unexpected server error.'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
paths:
  /version:
    get:
      tags:
        - Meta
      summary: 'API version'
      description: |
        Returns the deployed API version as a plain dotted-decimal string
        (major.minor.fix.status). Public (no auth required).
      security: []
      responses:
        200:
          description: 'Version string.'
          content:
            application/json:
              schema: { type: string, description: 'Dotted-decimal version `major.minor.fix.status`.' }
              example: 18.3.3.2
  /languages:
    get:
      tags:
        - Meta
      summary: 'Transcription languages supported'
      description: |
        Catalog of languages usable with the transcription pipeline. Keys are
        short language codes (`auto`, `fr`, `en`, `en_us`, `lb`, `ar`, …);
        each entry carries the technical `processing` locale used server-side
        and localized display labels in the four UI locales.
      responses:
        200:
          description: 'Language catalog keyed by short code.'
          content:
            application/json:
              schema: { type: object, additionalProperties: { type: object, required: [processing, fr_FR, en_EN, de_DE, es_ES], properties: { processing: { type: string, description: 'Technical locale / provider code (`fr_FR`, `en_UK`, `auto`, …).' }, fr_FR: { type: string }, en_EN: { type: string }, de_DE: { type: string }, es_ES: { type: string } } } }
              example: { auto: { processing: auto, fr_FR: '--Automatique', en_EN: '--Automatic', de_DE: '--Automatische', es_ES: '--Automática' }, fr: { processing: fr_FR, fr_FR: Français, en_EN: French, de_DE: Französisch, es_ES: Francés }, en_uk: { processing: en_UK, fr_FR: 'Anglais - Britannique', en_EN: 'English - British', de_DE: 'Englisch - Britisch', es_ES: 'Inglés - Británico' } }
  /openapi.yaml:
    get:
      tags:
        - Meta
      summary: 'OpenAPI spec (this document)'
      security: []
      responses:
        200:
          description: 'OpenAPI 3.1 spec describing every public endpoint of this API.'
          content:
            application/yaml:
              schema: { type: string }
        404:
          description: 'Spec missing from the deployed image.'
  /docs:
    get:
      tags:
        - Meta
      summary: 'Interactive Redoc viewer for this OpenAPI spec'
      security: []
      responses:
        200:
          description: 'HTML page rendering the spec as browsable documentation.'
          content:
            text/html:
              schema: { type: string }
  /attributes/colorSchemes:
    get:
      tags:
        - Attributes
      summary: 'List color schemes available for attributes'
      description: |
        Helper for the attribute editor — returns the palette preset keys that
        can be assigned when creating or editing an attribute. Values are the
        short color names consumed by the front-end theme (e.g. `red`, `blue`).
      responses:
        200:
          description: 'Color-scheme catalog (array of theme keys).'
          content:
            application/json:
              schema: { type: array, items: { type: string } }
              example: [red, pink, purple, yellow, orange, blue, green, grey]
  /attributes/list:
    get:
      tags:
        - Attributes
      summary: "List attributes of the caller's instance"
      description: |
        Powers the attributes admin page. Attributes are custom tags attached to
        uploads — either set by a reviewer or auto-populated by the AI pipeline.
      x-rights:
        - manage_grids
        - view_attributes
      parameters:
        -
          name: label
          in: query
          schema:
            type: string
          description: "LIKE filter on the attribute's French label (applied on `fr_FR`)."
        -
          name: type
          in: query
          schema:
            type: string
            enum:
              - list
              - string
              - number
        -
          name: orderby
          in: query
          schema:
            type: string
            enum:
              - label
              - id
              - created_at
              - type
              - disabled_at
            default: label
        -
          name: orderdirection
          in: query
          schema:
            type: string
            enum:
              - ASC
              - DESC
            default: ASC
        -
          name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        -
          name: show
          in: query
          schema:
            type: integer
            enum:
              - 10
              - 50
              - 100
          description: 'Page size. Defaults to the instance `PAGINATION_DEFAULT_LIMIT`.'
        -
          name: display
          in: query
          schema:
            type: string
            enum:
              - light
              - default
              - full
            default: default
          description: 'Row richness: `light` drops `created_by`/`valuesCountTotal`; `full` adds `instance_id` and the full `values` tree.'
      responses:
        200:
          description: 'Paginated attributes list.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttributesListResponse' }
              example: { show: 10, total: 17, page: 1, pages: 2, results: [{ id: 34, instance_id: 42, attribute_type: list, auto_attribute: 0, color_scheme: yellow, created_by: 1, created_at: '2025-12-15 15:12:53', disabled_at: '2025-12-15 15:12:56', label: Canal, valuesCountTotal: 2, autoFill: { active: 0, autoQuestion: [{ locale: fr_FR, label: null }, { locale: en_GB, label: null }, { locale: de_DE, label: null }, { locale: es_ES, label: null }] }, labels: [{ locale: fr_FR, label: Canal }, { locale: en_GB, label: Channel }, { locale: de_DE, label: Kanal }, { locale: es_ES, label: Canal }], values: [{ id: 122, disabled_at: null, labels: { fr_FR: 'Téléphone entrant', en_GB: 'Incoming call', de_DE: 'Eingehender Anruf', es_ES: 'Llamada entrante' } }] }] }
        403:
          $ref: '#/components/responses/Forbidden'
  '/attributes/{attributeId}':
    get:
      tags:
        - Attributes
      summary: 'Read an attribute with all its values'
      description: |
        Full detail view of one attribute, including the list of its values
        (for `list` type) and whether it is currently in use by any upload.
      x-rights:
        - manage_grids
        - view_attributes
      parameters:
        -
          name: attributeId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Attribute detail.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Attribute' }
              example: { id: 34, instance_id: 42, type: list, colorScheme: yellow, created_by: 1, created_name: 'John Smith', created_at: '2025-12-15 15:12:53', disabled_at: '2025-12-15 15:12:56', labels: [{ locale: fr_FR, label: Canal }, { locale: en_GB, label: Channel }, { locale: de_DE, label: Kanal }, { locale: es_ES, label: Canal }], autoFill: { active: 0, autoQuestion: [{ locale: fr_FR, label: null }, { locale: en_GB, label: null }, { locale: de_DE, label: null }, { locale: es_ES, label: null }] }, values: [{ id: 122, created_at: '2025-11-04 10:17:43', disabled_at: null, labels: { fr_FR: 'Téléphone entrant', en_GB: 'Incoming call', de_DE: 'Eingehender Anruf', es_ES: 'Llamada entrante' } }], isUsed: true }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
    put:
      tags:
        - Attributes
      summary: 'Update an attribute (labels, color scheme, values, auto-fill)'
      description: |
        Edit an attribute's localized labels, color scheme, value list and
        auto-fill prompt. Used by the attributes admin when adapting the
        tagging taxonomy over time.
      x-rights:
        - manage_grids
      parameters:
        -
          name: attributeId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AttributeUpdate'
            example:
              entity: { fr_FR: 'Motif de contact' }
              colorScheme: blue
              autoFill: { active: true, autoQuestion: { fr_FR: 'Quel est le principal motif de contact ?' } }
              values: { newA1: { fr_FR: "Demande d'information" }, newA2: { fr_FR: Réclamation }, newA3: { fr_FR: 'Ouverture de compte' }, newA4: { fr_FR: Paiement }, newA5: { fr_FR: Autre } }
      responses:
        200:
          description: 'Updated — returns the refreshed attribute (same shape as `GET /attributes/{attributeId}`, without `isUsed`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttributeMutationResponse' }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
    delete:
      tags:
        - Attributes
      summary: 'Delete an attribute and all its values'
      description: |
        Hard delete — the attribute and every value assigned to uploads is
        removed, including past attribute versions. Fails with 500 when the
        attribute is still referenced by any upload (prefer `disable` to keep
        historical data).
      x-rights:
        - manage_grids
      parameters:
        -
          name: attributeId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: Deleted.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Success' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  /attributes:
    post:
      tags:
        - Attributes
      summary: 'Create an attribute'
      description: |
        Add a new attribute to the instance taxonomy. Once created it can be
        used as a tag on uploads and (if `autoFill.active` is true) will be
        populated by the AI pipeline on every new upload.
      x-rights:
        - manage_grids
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AttributeCreate'
            example:
              entity: { fr_FR: Churn }
              type: list
              colorScheme: red
              autoFill: { active: true, autoQuestion: { fr_FR: 'Le client évoque-t-il un risque de churn ?' } }
              values: [{ fr_FR: 'Risque churn' }]
      responses:
        200:
          description: 'Created — returns the created attribute (same shape as `GET /attributes/{attributeId}`, without `isUsed`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttributeMutationResponse' }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  '/attributes/{attributeId}/enable':
    patch:
      tags:
        - Attributes
      summary: 'Enable an attribute'
      description: |
        Re-activate a previously disabled attribute. Response wraps the
        refreshed attribute under `data`.
      x-rights:
        - manage_grids
      parameters:
        -
          name: attributeId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Enabled (returns the refreshed attribute).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttributeMutationResponse' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  '/attributes/{attributeId}/disable':
    patch:
      tags:
        - Attributes
      summary: 'Disable an attribute'
      description: |
        Hide an attribute from new uploads without deleting it. Existing
        assignments on past uploads are preserved for reporting continuity.
        Response wraps the refreshed attribute (with `disabled_at` set) under
        `data`.
      x-rights:
        - manage_grids
      parameters:
        -
          name: attributeId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Disabled (returns the refreshed attribute).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttributeMutationResponse' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  '/attributes/{attributeId}/{attributeValueId}/enable':
    patch:
      tags:
        - Attributes
      summary: 'Enable a specific value of an attribute'
      description: |
        Re-activate one value of a `list`-type attribute. Response wraps the
        refreshed parent attribute under `data`.
      x-rights:
        - manage_grids
      parameters:
        -
          name: attributeId
          in: path
          required: true
          schema:
            type: integer
        -
          name: attributeValueId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Value enabled (returns the refreshed parent attribute).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttributeMutationResponse' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  '/attributes/{attributeId}/{attributeValueId}/disable':
    patch:
      tags:
        - Attributes
      summary: 'Disable a specific value of an attribute'
      description: |
        Retire one value of a `list`-type attribute without deleting past
        assignments — useful when cleaning up taxonomies that evolved over time.
        Response wraps the refreshed parent attribute under `data`.
      x-rights:
        - manage_grids
      parameters:
        -
          name: attributeId
          in: path
          required: true
          schema:
            type: integer
        -
          name: attributeValueId
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Value disabled (returns the refreshed parent attribute).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AttributeMutationResponse' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  /automations/reference:
    get:
      tags:
        - Automations
      summary: 'Catalog of comparisons, kinds, data types, relatives, placeholders, events and snooze durations supported by the rule builder'
      description: |
        Feeds the dropdowns of the automation-rule builder so users can pick a
        trigger, a condition field and an action without knowing the backend
        grammar by heart. Returns the full grammar the UI and API callers can
        use to build automation rules.

        Some entries (admin-only data types, admin/leader-only relatives and
        placeholders) are filtered out of the response based on the caller's
        `role_key`.
      x-rights:
        - manage_automations
      responses:
        200:
          description: 'Reference data.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutomationReference' }
              example: { comparisons: [{ value: equal, labelKey: Equal }, { value: lte, labelKey: LessThanOrEqual }, { value: gte, labelKey: GreaterThanOrEqual }, { value: lt, labelKey: LessThan }, { value: gt, labelKey: GreaterThan }, { value: in, labelKey: In }, { value: not_in, labelKey: NotIn }], kinds: [{ value: scalar, labelKey: Scalar }, { value: list, labelKey: List }], dataTypes: [{ value: upload.score, labelKey: InteractionScore, requiresExtra: false, compatibleKinds: [scalar] }, { value: upload.model.id, labelKey: UploadModel, requiresExtra: false, compatibleKinds: [scalar], useComponent: FilterModelSelector }, { value: upload.scores.answer_value, labelKey: UploadScoresAnswerValue, requiresExtra: question_id, compatibleKinds: [scalar], useComponent: FilterQuestionSelector }, { value: upload.scores.category_score, labelKey: UploadScoresCategoryScore, requiresExtra: category_id, compatibleKinds: [scalar], useComponent: FilterCategorySelector }, { value: upload.attributes, labelKey: UploadAttributes, requiresExtra: attribute_id, compatibleKinds: [list] }, { value: billing_operations.instance_balance, labelKey: BillingOperationsBalance, requiresExtra: false, compatibleKinds: [scalar], restrictions: { roleKey: [admin] } }], relatives: [{ value: administrator, labelKey: Administrator, restrictions: { roleKey: [admin] } }, { value: creator, labelKey: Creator, restrictions: { roleKey: [admin, leader] } }, { value: advisor, labelKey: Advisor }, { value: self_manager, labelKey: SelfManager, restrictions: { roleKey: [admin, leader] } }, { value: above_manager, labelKey: AboveManager, restrictions: { roleKey: [admin, leader] } }], placeholders: [{ value: '[[interaction_link]]', labelKey: InteractionLink, descriptionKey: InteractionLinkDescription }, { value: '[[interaction_score]]', labelKey: InteractionScore, descriptionKey: InteractionScoreDescription }, { value: '[[advisor_name]]', labelKey: AdvisorName, descriptionKey: AdvisorNameDescription }], events: [{ value: score.created, labelKey: ScoreCreated }], snoozeDurations: [{ labelKey: FiveMinutes, seconds: 300 }, { labelKey: FifteenMinutes, seconds: 900 }, { labelKey: OneHour, seconds: 3600 }, { labelKey: SixHours, seconds: 21600 }, { labelKey: TwelveHours, seconds: 43200 }, { labelKey: TwentyFourHours, seconds: 86400 }, { labelKey: SevenDays, seconds: 604800 }] }
        403:
          $ref: '#/components/responses/Forbidden'
  /automations/list:
    get:
      tags:
        - Automations
      summary: 'List automations'
      description: |
        Admin table of every automation configured for the instance — each row
        is a trigger-conditions-actions rule (e.g. "notify the manager when a
        score drops below 60%").

        Callers without `manage_own_instance` or `manage_instances` are silently
        restricted to `trigger=event` rows (the `trigger` query param is
        overridden). Callers with `viewer` role only see their own rules.

        The `display` query param controls row richness:
        - `light` / `default` — `definition` is the plain description string.
        - `full` — `definition` is the full JSON body (metadata + trigger + conditions + actions).
      x-rights:
        - manage_automations
        - view_automations
      parameters:
        -
          name: status
          in: query
          schema:
            type: integer
            enum:
              - 0
              - 1
          description: '`1` = active, `0` = disabled.'
        -
          name: trigger
          in: query
          schema:
            type: string
            enum:
              - event
              - computed
          description: 'Filter by trigger kind. Forced to `event` for callers without `manage_own_instance`/`manage_instances`.'
        -
          name: event
          in: query
          schema:
            type: string
            enum:
              - score.created
              - score.tested
          description: 'Filter on `definition.trigger.event`.'
        -
          name: instance
          in: query
          schema:
            type: integer
          description: "Target instance id. Requires `manage_instances`; without it the caller's own instance is used."
        -
          name: orderby
          in: query
          schema:
            type: string
            enum:
              - id
              - created_at
            default: id
        -
          name: orderdirection
          in: query
          schema:
            type: string
            enum:
              - ASC
              - DESC
            default: DESC
        -
          name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        -
          name: show
          in: query
          schema:
            type: integer
            enum:
              - 10
              - 50
              - 100
        -
          name: display
          in: query
          schema:
            type: string
            enum:
              - light
              - default
              - full
            default: default
      responses:
        200:
          description: 'Paginated list. Shape of each `results[].definition` depends on `display`.'
          content:
            application/json:
              schema: { type: object, properties: { show: { type: integer }, total: { type: integer }, page: { type: integer }, pages: { type: integer }, results: { type: array, items: { $ref: '#/components/schemas/AutomationListItem' } } } }
              example: { show: 10, total: 70, page: 1, pages: 7, results: [{ id: 217, instance_id: 42, type: event, definition: 'Notify the manager when a score drops below 60%', event: score.created, created_at: '2026-04-16 09:08:31', disabled_at: null, snoozed_until: null, creator: { id: 201, fullname: 'John Smith', email: john@acme.com, locale: fr_FR }, last_modifier: { id: 201, fullname: 'John Smith', email: john@acme.com, locale: fr_FR } }, { id: 216, instance_id: 42, type: computed, definition: 'Monthly purge of old interactions', event: 'null', created_at: '2026-04-16 09:08:30', disabled_at: null, snoozed_until: null, creator: { id: 201, fullname: 'John Smith', email: john@acme.com, locale: fr_FR }, last_modifier: { id: 201, fullname: 'John Smith', email: john@acme.com, locale: fr_FR } }] }
        403:
          $ref: '#/components/responses/Forbidden'
  /automation:
    post:
      tags:
        - Automations
      summary: 'Create an automation'
      description: |
        Publish a new rule — the engine will evaluate it on every matching
        event (or on a schedule for `computed` triggers) and run its actions
        when the conditions are met.

        A `computed` trigger (and any `purge` action) requires
        `manage_own_instance` or `manage_instances`.
      x-rights:
        - manage_automations
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AutomationDefinition'
      responses:
        200:
          description: Created.
          content:
            application/json:
              schema: { type: object, properties: { result: { type: string, enum: [success] }, data: { $ref: '#/components/schemas/Automation' } } }
              example: { result: success, data: { id: 218, instance_id: 42, type: event, definition: { metadata: { description: 'Notify the manager when a score drops below 60%' }, trigger: { event: score.created, computed: null }, conditions: [{ data: upload.score, comparison: lt, kind: scalar, value: 60 }], actions: [{ type: notify, recipients: [{ emails: [manager@acme.com] }] }] }, created_by: 201, created_name: 'John Smith', created_at: '2026-04-21 10:00:00', disabled_at: null, snoozed_until: null, creator: { id: 201, fullname: 'John Smith', email: john@acme.com, locale: fr_FR }, last_modifier: { id: 201, fullname: 'John Smith', email: john@acme.com, locale: fr_FR } } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  '/automation/{id}':
    get:
      tags:
        - Automations
      summary: 'Read an automation'
      description: |
        Detail view of one rule — used to inspect the current configuration
        and open it in the builder for editing.
      x-rights:
        - manage_automations
        - view_automations
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Automation detail.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Automation' }
              example: { id: 217, instance_id: 42, type: event, definition: { metadata: { description: 'Notify the manager when a score drops below 60%' }, trigger: { event: score.created, computed: null }, conditions: [{ data: upload.score, comparison: lte, kind: scalar, value: 70 }, { data: upload.model.id, comparison: equal, kind: scalar, value: 10 }, { data: upload.scores.answer_value, comparison: equal, kind: scalar, value: 10, question_id: 19 }, { data: upload.attributes, comparison: in, kind: list, value: [14, 17, 28], attribute_id: 105 }], actions: [{ type: webhook, connector: { id: 23, url: 'https://client.salesforces.com/user/{{connectorUserId}}/', name: salesforce } }, { type: notify, recipients: [{ relatives: [creator, advisor] }, { users: [2] }, { emails: [supervision@client.com] }] }] }, created_by: 201, created_name: 'John Smith', created_at: '2026-04-16 09:08:31', disabled_at: null, snoozed_until: null, creator: { id: 201, fullname: 'John Smith', email: john@acme.com, locale: fr_FR }, last_modifier: { id: 201, fullname: 'John Smith', email: john@acme.com, locale: fr_FR } }
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - Automations
      summary: 'Update an automation'
      description: |
        Replace a rule's definition — trigger, conditions, actions or snooze
        window. Changes apply to the next evaluation cycle; past executions
        are unaffected. A new version of the previous definition is archived
        automatically.
      x-rights:
        - manage_automations
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AutomationDefinition'
      responses:
        200:
          description: Updated.
          content:
            application/json:
              schema: { type: object, properties: { result: { type: string, enum: [success] }, data: { $ref: '#/components/schemas/Automation' } } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
  '/automation/{id}/enable':
    patch:
      tags:
        - Automations
      summary: 'Activate an automation'
      description: 'Resume evaluation of a previously disabled rule. Returns the refreshed automation.'
      x-rights:
        - manage_automations
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: Activated.
          content:
            application/json:
              schema: { type: object, properties: { result: { type: string, enum: [success] }, data: { $ref: '#/components/schemas/Automation' } } }
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
  '/automation/{id}/disable':
    patch:
      tags:
        - Automations
      summary: 'Deactivate an automation'
      description: |
        Stop a rule from firing without deleting its definition — useful to
        pause noisy alerts or freeze automations during maintenance. Returns
        the refreshed automation.
      x-rights:
        - manage_automations
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: Deactivated.
          content:
            application/json:
              schema: { type: object, properties: { result: { type: string, enum: [success] }, data: { $ref: '#/components/schemas/Automation' } } }
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
  /instances/list:
    get:
      tags:
        - Instances
      summary: 'List instances accessible to the caller'
      description: |
        Admin table of every instance the caller can see — used by platform
        admins to manage the whole customer base, and by partner instances to
        manage the sub-accounts in their scope.

        Paginated. Scoped to the caller's `accessibleInstanceIds` (wildcard
        for the platform admin tenant, managed subset for a partner instance,
        own instance otherwise).

        The `display` query param controls row richness:
        - `light` — minimal columns (no `status`, no `created_*`).
        - `default` — light + `status`, `created_by`, `created_at`.
        - `full` — default + billing aggregates, counts, latest activity, partner info.

        Billing aggregate fields (`instanceCogs`, `instanceRevenues`) are
        stripped from every row unless the caller belongs to the platform
        admin tenant (including partner instances).
      x-rights:
        - manage_instances
      parameters:
        -
          name: name
          in: query
          schema:
            type: string
          description: 'Case-insensitive LIKE match on instance name.'
        -
          name: status
          in: query
          schema:
            type: integer
            enum:
              - 0
              - 1
              - 2
        -
          name: orderby
          in: query
          schema:
            type: string
            enum:
              - name
              - id
              - created_at
            default: name
        -
          name: orderdirection
          in: query
          schema:
            type: string
            enum:
              - ASC
              - DESC
            default: ASC
        -
          name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        -
          name: show
          in: query
          schema:
            type: integer
            enum:
              - 10
              - 50
              - 100
        -
          name: display
          in: query
          schema:
            type: string
            enum:
              - light
              - default
              - full
            default: default
      responses:
        200:
          description: 'Paginated list. Shape of each `results` item depends on `display`.'
          content:
            application/json:
              schema: { type: object, properties: { show: { type: integer }, total: { type: integer }, page: { type: integer }, pages: { type: integer }, results: { type: array, items: { oneOf: [{ $ref: '#/components/schemas/InstancesListItemLight' }, { $ref: '#/components/schemas/InstancesListItemDefault' }, { $ref: '#/components/schemas/InstancesListItemFull' }] } } } }
              example: { show: 10, total: 130, page: 1, pages: 13, results: [{ id: 73, name: 'Example Instance', slug: example-instance, host: null, balance: 1, status: 1, created_by: null, created_at: '2026-01-22 15:53:32', teamsCountTotal: 1, usersCountTotal: 16, uploadsCountTotal: 35, instanceCogs: 0, instanceRevenues: 0, raisetalkCogs: 0, partnerRevenues: 0, partnerName: 'Example Partner', latestActivity: null, locales: [{ locale_id: 285, locale: fr_FR, locale_status: 1 }], roles: [{ role_id: 214, role_status: 1, role_key: admin }] }] }
        403:
          $ref: '#/components/responses/Forbidden'
  /instance:
    get:
      tags:
        - Instances
      summary: "Read the caller's own instance"
      description: |
        Return the configuration of the caller's own instance (locales, roles,
        balance, blocked features). Called on app boot so the UI can adapt to
        the tenant's capabilities.
      responses:
        200:
          description: "The caller's instance."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Instance' }
              example: { id: 5, name: Acme, slug: acme, status: 1, rag_provider: Mistral, host: 'https://acme.example.com', balance: 120.5, created_by: 3, created_at: '2026-01-10 14:30:00', locales: [{ locale_id: 1, locale: fr_FR, locale_status: 1 }, { locale_id: 2, locale: en_GB, locale_status: 1 }], roles: [{ role_id: 10, role_status: 1, role_key: admin }, { role_id: 11, role_status: 1, role_key: leader }, { role_id: 12, role_status: 1, role_key: viewer }], is_partner: false }
  /instance/credithistory:
    get:
      tags:
        - Instances
      summary: "Billing operations history for the caller's own instance"
      description: |
        Timeline of credit top-ups and debits, shown in the billing area for
        admins reconciling their account balance.

        Shape of each row is the same for `daily` and `monthly`; only the `date`
        format changes (`YYYY-MM-DD` vs `YYYY-MM`). `balance` carries forward from the
        last known value when no operation happened on that day/month.
      x-rights:
        - manage_billing
      parameters:
        -
          name: format
          in: query
          required: true
          schema:
            type: string
            enum:
              - daily
              - monthly
        -
          name: fromday
          in: query
          schema:
            type: string
            format: date
          description: 'Required when `format=daily`. ISO date `YYYY-MM-DD`.'
        -
          name: today
          in: query
          schema:
            type: string
            format: date
          description: 'Required when `format=daily`.'
        -
          name: frommonth
          in: query
          schema:
            type: string
            example: 2026-01
          description: 'Required when `format=monthly`. `YYYY-MM`.'
        -
          name: tomonth
          in: query
          schema:
            type: string
            example: 2026-04
          description: 'Required when `format=monthly`.'
      responses:
        200:
          description: 'Credit history rows.'
          content:
            application/json:
              schema: { allOf: [{ $ref: '#/components/schemas/Success' }, { type: object, properties: { data: { type: array, items: { $ref: '#/components/schemas/CreditHistoryRow' } } } }] }
              example: { result: success, data: [{ date: '2026-04-19', debit: 0, credit: 0, balance: null, unique_upload_count: 0 }, { date: '2026-04-20', debit: 0, credit: 0, balance: null, unique_upload_count: 0 }, { date: '2026-04-21', debit: -1.9633380831, credit: 0, balance: 64.1309236277, unique_upload_count: 1 }] }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  /instance/create:
    post:
      tags:
        - Instances
      summary: 'Create an instance (with default locales, roles, rights, global team, support user)'
      description: |
        Onboard a new tenant — typically called by platform admins when
        opening an account for a new customer, or by a partner provisioning
        a managed client.

        Side effect: if the creator belongs to a partner instance (any
        non-admin tenant carrying `manage_instances`), the new instance is
        automatically added to that partner's scope.
      x-rights:
        - manage_instances
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              additionalProperties: false
              properties: { name: { type: string, minLength: 3 }, is_partner: { type: boolean, description: 'Platform admin tenant only — grants `manage_instances` to the admin role (403 otherwise).' } }
      responses:
        201:
          description: 'Instance created. `files` is the on-disk path in local-storage mode, `null` otherwise.'
          content:
            application/json:
              schema: { allOf: [{ $ref: '#/components/schemas/Success' }, { type: object, properties: { data: { $ref: '#/components/schemas/InstanceCreateData' } } }] }
              example: { result: success, data: { id: 123, name: Acme, slug: acme, status: 2, rag_provider: Mistral, host: null, created_by: 1, created_at: '2026-04-21 14:30:00', files: null, is_partner: false, locales: [{ locale_id: 500, locale: fr_FR, locale_status: 1 }], roles: [{ role_id: 400, role_status: 1, role_key: admin }, { role_id: 401, role_status: 1, role_key: leader }, { role_id: 402, role_status: 1, role_key: viewer }] } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  '/instance/{id}':
    get:
      tags:
        - Instances
      summary: 'Read a specific instance'
      description: |
        Detail view of an arbitrary instance — used by platform admins and
        partner admins managing their sub-accounts.
      x-rights:
        - manage_instances
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Instance detail.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Instance' }
              example: { id: 122, name: 'Example Sub-Instance 2', slug: example-sub-instance-2, status: 1, rag_provider: Mistral, host: null, balance: 4.1528207973, created_by: null, created_at: '2026-04-15 10:06:16', locales: [{ locale_id: 481, locale: fr_FR, locale_status: 1 }, { locale_id: 482, locale: en_GB, locale_status: 1 }, { locale_id: 483, locale: es_ES, locale_status: 1 }, { locale_id: 484, locale: de_DE, locale_status: 1 }], roles: [{ role_id: 361, role_status: 1, role_key: admin }, { role_id: 362, role_status: 1, role_key: leader }, { role_id: 363, role_status: 1, role_key: viewer }], is_partner: false }
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - Instances
      summary: 'Update an instance (archives the previous version for audit)'
      description: |
        Edit tenant configuration — locales, roles, host, partner status.
        Every change snapshots the previous state for audit and rollback.

        Field-level restrictions:
        - `is_partner` toggle is platform-admin-tenant only (403 `e71c2308` otherwise).
        - Other fields follow standard `manage_instances` access.
      x-rights:
        - manage_instances
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InstanceUpdate'
      responses:
        200:
          description: 'Instance updated. `data` is the full refreshed instance.'
          content:
            application/json:
              schema: { allOf: [{ $ref: '#/components/schemas/Success' }, { type: object, properties: { message: { type: string }, data: { $ref: '#/components/schemas/Instance' } } }] }
              example: { result: success, message: 'Instance updated', data: { id: 123, name: 'Acme Corp', slug: acme, status: 1, rag_provider: Mistral, host: 'https://acme.example.com', balance: 42.5, created_by: 1, created_at: '2026-01-10 14:30:00', locales: [{ locale_id: 1, locale: fr_FR, locale_status: 1 }], roles: [{ role_id: 10, role_status: 1, role_key: admin }], is_partner: false } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  '/instance/{id}/scopes':
    get:
      tags:
        - Instances
      summary: 'List scopes (managed instances) of a partner instance'
      description: |
        List the customer instances a partner manages. Used by the partner's
        admin console to pick which client to operate on, and by platform
        admins to audit a partner's portfolio.

        Readable by the platform admin tenant (all scopes) and by the partner
        instance itself (its own scope), both with `manage_instances`.
      x-rights:
        - manage_instances
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'List of scopes.'
          content:
            application/json:
              schema: { type: object, properties: { results: { type: array, items: { $ref: '#/components/schemas/InstanceScope' } } } }
              example: { results: [{ id: 4, partner_instance_id: 118, managed_instance_id: 73, created_at: '2026-04-14 17:10:55', created_by: 1, managed_instance_name: 'Example Instance', managed_instance_slug: example-instance, managed_instance_status: 1 }] }
        403:
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
        - Instances
      summary: "Add a managed instance to a partner's scope (idempotent)"
      description: |
        Assign a customer instance to a partner — grants the partner
        visibility and impersonation over that client. Platform admin tenant
        only, with `manage_instances`.

        Idempotent: re-posting the same pair returns `created: false`.
      x-rights:
        - manage_instances
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
          description: 'Partner instance id.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [managed_instance_id]
              properties: { managed_instance_id: { type: integer, description: "Id of the instance to place in the partner's scope. Must differ from `{id}`." } }
      responses:
        201:
          description: '`created:false` when the relation already existed.'
          content:
            application/json:
              schema: { allOf: [{ $ref: '#/components/schemas/Success' }, { type: object, properties: { created: { type: boolean } } }] }
              example: { result: success, created: true }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
  '/instance/{id}/scopes/{managedId}':
    delete:
      tags:
        - Instances
      summary: "Remove a managed instance from a partner's scope"
      description: |
        Revoke a partner's access to a customer instance — used when a
        client leaves a partner's portfolio. Platform admin tenant only,
        with `manage_instances`.
      x-rights:
        - manage_instances
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
          description: 'Partner instance id.'
        -
          name: managedId
          in: path
          required: true
          schema:
            type: integer
          description: 'Managed instance id to remove.'
      responses:
        200:
          description: '`deleted:false` when the relation did not exist.'
          content:
            application/json:
              schema: { allOf: [{ $ref: '#/components/schemas/Success' }, { type: object, properties: { deleted: { type: boolean } } }] }
              example: { result: success, deleted: true }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  /kpi/compute/score:
    post:
      tags:
        - Kpis
      summary: 'Compute a single KPI score from an ad-hoc definition'
      description: |
        Interactive exploration path — the dashboard builder posts the KPI
        definition currently being edited and previews the resulting score
        without persisting anything. Viewers (role `viewer`) are silently
        scoped to their own uploads: `filters.users.include` is overwritten
        server-side with the caller's id.
      x-rights:
        - manage_users
        - view_users
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/KpiLivePayload'
      responses:
        200:
          description: |
            Computed KPI envelope. The `results` field's shape depends on
            `definition.type` — see `KpiComputeResults` for the per-type
            variants.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KpiComputeResponse' }
              example: { description: 'Average score — last 30 days', originalDefinition: { type: average, period: { start: '2026-03-22 00:00:00', end: '2026-04-21 23:59:59' } }, computedDefinition: { type: average, period: { start: '2026-03-22 00:00:00', end: '2026-04-21 23:59:59' }, filters: { teams: { include: [42, 112, 113] } } }, results: { score: 67.45, count: 131 } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  /kpi/compute/list:
    post:
      tags:
        - Kpis
      summary: 'Compute KPI results broken down by group'
      description: |
        Ad-hoc computation returning a grouped breakdown (by user, team,
        attribute value, top/flop upload, top/flop question/category …).
        Used by the builder to preview grouped widgets like leaderboards
        and stacked bars. For `averagePerAttributeValue` and
        `medianPerAttributeValue` exactly one entry under
        `filters.attributes` must carry `distribution: true`.
      x-rights:
        - manage_users
        - view_users
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/KpiLivePayload'
      responses:
        200:
          description: |
            Computed KPI envelope. The `results` field's shape depends on
            `definition.type` — see `KpiComputeResults`. For
            `averagePerUser` / `averagePerTeam` / `averagePerAttributeValue`
            (and their `median*` counterparts) the array is truncated to
            `definition.order.limit` entries, capped server-side by a
            default of 5.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KpiComputeResponse' }
              example: { description: 'Average score per user', originalDefinition: { type: averagePerUser, period: { start: '2026-03-22 00:00:00', end: '2026-04-21 23:59:59' } }, computedDefinition: { type: averagePerUser, period: { start: '2026-03-22 00:00:00', end: '2026-04-21 23:59:59' }, filters: { teams: { include: [42, 112] } } }, results: [{ user_id: 201, fullname: 'John Smith', items: [575, 576, 577], count: 78 }, { user_id: 202, fullname: 'Jane Doe', items: [436, 440], count: 26 }] }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  /kpi/compute/evolution:
    post:
      tags:
        - Kpis
      summary: 'Compute KPI evolution over a time range'
      description: |
        Ad-hoc time series for trend charts (score-over-time, count-per-day).
        Used to preview line/area widgets during dashboard design. The
        series is keyed by `YYYY-MM-DD` and carries `null` for days with
        no data.
      x-rights:
        - manage_users
        - view_users
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/KpiLivePayload'
      responses:
        200:
          description: |
            Computed KPI envelope. For `averageEvolution` /
            `allAverageEvolution` the payload is
            `{average: {date: score|null, …}, count: n}`. For the per-user
            / per-team / per-question variants it becomes a map keyed by
            entity id, each value carrying its own `average` series.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KpiComputeResponse' }
              example: { description: 'Score evolution — Q1 2026', originalDefinition: { type: averageEvolution, period: { start: '2026-01-01 00:00:00', end: '2026-03-31 23:59:59' } }, computedDefinition: { type: averageEvolution, period: { start: '2026-01-01 00:00:00', end: '2026-03-31 23:59:59' }, filters: { teams: { include: [42, 112] } } }, results: { average: { '2026-01-01': null, '2026-01-05': 63.7, '2026-01-06': 100, '2026-01-07': 67.89 }, count: 131 } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  /library/list:
    get:
      tags:
        - Library
      summary: 'List all library questions and categories (joined rows)'
      description: |
        Powers the grid designer when the user picks which questions to plug
        into an evaluation model. Returns each question joined with its
        category so the picker can group them visually. Rows include both
        generic (platform-wide) and client-specific entries; a category with
        no question appears as a row with `question.id: null`.
      x-rights:
        - manage_grids
        - view_models
      parameters:
        -
          name: label
          in: query
          description: "Case-insensitive LIKE filter matched against the question label (in the caller's locale) or the category label."
          schema:
            type: string
        -
          name: status
          in: query
          description: '1 = active questions (disabled_at IS NULL), 0 = inactive (disabled_at IS NOT NULL). Filters questions only.'
          schema:
            type: integer
            enum:
              - 0
              - 1
        -
          name: questions
          in: query
          description: 'Comma-separated list of question ids to restrict the result to.'
          schema:
            type: string
            example: '101,102,105'
        -
          name: models
          in: query
          description: 'Comma-separated list of model ids; returns rows scoped to questions used by those models.'
          schema:
            type: string
            example: '1,3'
        -
          name: orderby
          in: query
          schema:
            type: string
            enum:
              - category_label
              - category_id
              - question_label
              - question_id
              - category_status
              - question_status
              - created_at
            default: created_at
        -
          name: orderdirection
          in: query
          schema:
            type: string
            enum:
              - ASC
              - DESC
            default: ASC
        -
          name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        -
          name: show
          in: query
          description: 'Page size. Defaults to the platform `PAGINATION_DEFAULT_LIMIT`.'
          schema:
            type: integer
            enum:
              - 10
              - 50
              - 100
        -
          name: display
          in: query
          description: |
            Controls response richness:
              * `light`   — ids and metadata only (no labels / no options / no formats).
              * `default` — adds full category `labels` and question `localisation` for all locales.
              * `full`    — `default` plus question `options` and `formats`.
          schema:
            type: string
            enum:
              - light
              - default
              - full
            default: default
      responses:
        200:
          description: 'Paginated joined rows.'
          content:
            application/json:
              schema: { type: object }
              example: { show: 50, total: 1, page: 1, pages: 1, results: [{ category: { id: 141, source: client, instance: 1, labels: { de_DE: '', en_GB: '', fr_FR: '0-Critères manuels' }, created_at: '2026-02-17 15:48:03', disabled_at: null }, question: { id: 225, category: 141, type: manual, source: client, instance: 1, options: [10, 0, null], formats: [provide_boolean, provide_explanation], localisation: { fr_FR: { label: Test1, question: 'bla bla bla' } }, created_at: '2026-02-17 15:48:16', disabled_at: null, na_allowed: 1 }, models: [226] }] }
        403:
          $ref: '#/components/responses/Forbidden'
  /library/categories:
    get:
      tags:
        - Library
      summary: 'List library categories'
      description: |
        Lightweight payload used by the category picker — returns every
        category (generic + client-specific) with its question counts, no
        questions attached. Ordered alphabetically by label in the caller's
        locale.
      x-rights:
        - manage_grids
        - view_models
      responses:
        200:
          description: 'Categories list.'
          content:
            application/json:
              schema: { type: object }
              example: { total: 2, results: [{ id: 141, source: client, instance: 1, labels: { de_DE: '', en_GB: '', fr_FR: '0-Critères manuels' }, created_at: '2026-02-17 15:48:03', created_by: 1, disabled_at: null, total_questions: 2, total_active_questions: 2, total_inactive_questions: 0 }, { id: 1, source: generic, instance: null, labels: { en_US: Greetings, fr_FR: Accueil }, created_at: '2025-11-04 10:17:33', created_by: null, disabled_at: null, total_questions: 64, total_active_questions: 0, total_inactive_questions: 0 }] }
        403:
          $ref: '#/components/responses/Forbidden'
  /library/category/create:
    post:
      tags:
        - Library
      summary: 'Create a library category'
      description: |
        Add a new category to group related evaluation questions (e.g.
        "Discovery", "Closing"). Categories are reused across multiple models.
        `labels` is a locale-keyed map; allowed locale keys are the active
        locales of the caller's instance — see `GET /languages`.
      x-rights:
        - manage_grids
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [labels, status]
              additionalProperties: false
              properties: { labels: { type: object, description: 'Locale-keyed map; each value is the category label for that locale. Max 300 chars per value. At least one locale is required.', additionalProperties: { type: string, maxLength: 300 }, example: { fr_FR: Découverte, en_GB: Discovery } }, status: { type: integer, enum: [0, 1] } }
      responses:
        201:
          description: 'Category created. `data` is the newly-created category as returned by `GET /library/category/{id}` (without the `questions` array).'
          content:
            application/json:
              schema: { type: object }
              example: { result: success, data: { id: 192, source: client, instance_id: 42, labels: { fr_FR: Découverte, en_GB: Discovery }, created_by: 1, created_at: '2026-04-21 10:00:00', disabled_at: null } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  '/library/category/{id}':
    get:
      tags:
        - Library
      summary: 'Read a library category with its questions'
      description: |
        Detail view of a category, including its full list of questions
        (generic + client-specific, all statuses).
      x-rights:
        - manage_grids
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Category detail.'
          content:
            application/json:
              schema: { type: object }
              example: { id: 141, source: client, instance_id: 42, labels: { de_DE: '', en_GB: '', fr_FR: '0-Critères manuels' }, created_by: 1, created_at: '2026-02-17 15:48:03', disabled_at: null, questions: [{ id: 225, category: 141, type: manual, source: client, instance: 1, options: [10, 0, null], formats: [provide_boolean, provide_explanation], labels: { fr_FR: { label: Test1, question: 'bla bla bla' } }, created_at: '2026-02-17 15:48:16', created_by: 1, disabled_at: null }] }
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - Library
      summary: 'Update a library category'
      description: |
        Rename a category or toggle its status. Two modes:
          * **status-only** — body `{status: 0|1}` flips `disabled_at`
            without touching labels.
          * **full update** — `labels` and `status` are both required.
        Each update saves a new version of the category.
      x-rights:
        - manage_grids
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf: [{ type: object, title: 'Status only', required: [status], additionalProperties: false, properties: { status: { type: integer, enum: [0, 1] } } }, { type: object, title: 'Full update', required: [labels, status], additionalProperties: false, properties: { labels: { type: object, description: "Locale-keyed map; each value is the category label for that locale. Max 300 chars per value. Allowed locales are the active locales of the caller's instance.", additionalProperties: { type: string, maxLength: 300 }, example: { fr_FR: Découverte, en_GB: Discovery } }, status: { type: integer, enum: [0, 1] } } }]
      responses:
        200:
          description: 'Updated. `data` is the refreshed category (same shape as `GET /library/category/{id}` but without `questions`).'
          content:
            application/json:
              schema: { type: object }
              example: { result: success, id: 'Category updated.', data: { id: 141, source: client, instance_id: 42, labels: { fr_FR: 'Critères manuels v2' }, created_by: 1, created_at: '2026-02-17 15:48:03', disabled_at: null } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
  /library/question/create:
    post:
      tags:
        - Library
      summary: 'Create a library question'
      description: |
        Author a new evaluation question that can then be plugged into one
        or several models. Library questions are the building blocks of the
        grading grids.

        Key constraints:
          * `options` must be an array of at least two values, must contain
            `null`, and every value is either an integer (score) or `null`.
          * `formats` is a non-empty subset of
            `[provide_boolean, provide_citation, provide_explanation]`.
          * `labels` is a locale-keyed map (allowed locale keys are the
            active locales of the caller's instance — see `GET /languages`);
            each entry carries a short `label` and a longer `question`
            (prompt), both max 300 chars.
          * `category` must be one of the caller's visible category ids
            (see `GET /library/categories`).
          * When `type = manual`, `na_allowed` must be `1`.
      x-rights:
        - manage_grids
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [options, formats, labels, category, status, na_allowed, type]
              additionalProperties: false
              properties: { options: { type: array, minItems: 2, description: 'Score values offered for this question. Must contain at least one `null` entry (representing "N/A" or similar). Non-null entries are integers.', items: { type: integer, nullable: true }, example: [10, 0, null] }, formats: { type: array, minItems: 1, description: 'Output shape requested from the LLM at evaluation time.', items: { type: string, enum: [provide_boolean, provide_citation, provide_explanation] }, example: [provide_boolean, provide_explanation] }, labels: { type: object, description: 'Locale-keyed map. Each value contains the picker `label` (short) and the LLM `question` prompt. Both are required per locale, max 300 chars each.', additionalProperties: { type: object, required: [label, question], properties: { label: { type: string, maxLength: 300 }, question: { type: string, maxLength: 300 } } }, example: { fr_FR: { label: Salutation, question: "Le conseiller a-t-il salué l'interlocuteur ?" } } }, category: { type: integer, description: "Id of the parent category. Must belong to the caller's instance (or be a generic category when the instance has the default library enabled)." }, status: { type: integer, enum: [0, 1] }, na_allowed: { type: integer, enum: [0, 1], description: "Whether 'N/A' is an acceptable answer. Must be `1` when `type = manual`." }, type: { type: string, enum: [auto, manual], description: '`auto` — evaluated by the LLM; `manual` — evaluated by a human.' } }
      responses:
        201:
          description: 'Question created. `data` mirrors `GET /library/question/{id}`.'
          content:
            application/json:
              schema: { type: object }
              example: { result: success, data: { question: { id: 227, category: 141, type: auto, source: client, instance: 1, options: [10, 0, null], formats: [provide_boolean, provide_explanation], labels: { fr_FR: { label: Salutation, question: "Le conseiller a-t-il salué l'interlocuteur ?" } }, created_at: '2026-04-21 10:00:00', created_by: 1, disabled_at: null, na_allowed: 0 }, category: { id: 141, source: client, instance: 1, labels: { fr_FR: '0-Critères manuels' }, created_at: '2026-02-17 15:48:03', created_by: 1, disabled_at: null } } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  '/library/question/{id}':
    get:
      tags:
        - Library
      summary: 'Read a library question with its category'
      description: 'Detail view of a question, plus the full category it belongs to.'
      x-rights:
        - manage_grids
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Question detail.'
          content:
            application/json:
              schema: { type: object }
              example: { question: { id: 225, category: 141, type: manual, source: client, instance: 1, options: [10, 0, null], formats: [provide_boolean, provide_explanation], labels: { fr_FR: { label: Test1, question: 'bla bla bla' } }, created_at: '2026-02-17 15:48:16', created_by: 1, disabled_at: null, na_allowed: 1 }, category: { id: 141, source: client, instance: 1, labels: { de_DE: '', en_GB: '', fr_FR: '0-Critères manuels' }, created_at: '2026-02-17 15:48:03', created_by: 1, disabled_at: null } }
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - Library
      summary: 'Update a library question'
      description: |
        Edit a question's prompts, score options, formats, or reassign it
        to a different category. Changes are reflected on every model that
        embeds the question. Two modes:
          * **status-only** — body `{status: 0|1}` flips `disabled_at`
            without touching the rest.
          * **full update** — all fields required, same rules as
            `POST /library/question/create`.
        Each update saves a new version of the question.
      x-rights:
        - manage_grids
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf: [{ type: object, title: 'Status only', required: [status], additionalProperties: false, properties: { status: { type: integer, enum: [0, 1] } } }, { type: object, title: 'Full update', required: [options, formats, labels, category, status, na_allowed, type], additionalProperties: false, properties: { options: { type: array, minItems: 2, items: { type: integer, nullable: true }, example: [10, 0, null] }, formats: { type: array, minItems: 1, items: { type: string, enum: [provide_boolean, provide_citation, provide_explanation] } }, labels: { type: object, additionalProperties: { type: object, required: [label, question], properties: { label: { type: string, maxLength: 300 }, question: { type: string, maxLength: 300 } } } }, category: { type: integer }, status: { type: integer, enum: [0, 1] }, na_allowed: { type: integer, enum: [0, 1] }, type: { type: string, enum: [auto, manual] } } }]
      responses:
        200:
          description: 'Updated. `data` mirrors `GET /library/question/{id}`.'
          content:
            application/json:
              schema: { type: object }
              example: { result: success, id: 'Question updated.', data: { question: { id: 225, category: 141, type: manual, source: client, instance: 1, options: [10, 0, null], formats: [provide_boolean, provide_explanation], labels: { fr_FR: { label: Test1, question: 'bla bla bla' } }, created_at: '2026-02-17 15:48:16', created_by: 1, disabled_at: null, na_allowed: 1 }, category: { id: 141, source: client, instance: 1, labels: { fr_FR: '0-Critères manuels' }, created_at: '2026-02-17 15:48:03', created_by: 1, disabled_at: null } } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          $ref: '#/components/responses/NotFound'
  /login:
    post:
      tags:
        - Login
      summary: 'Authenticate with email + password; returns a bearer token'
      description: |
        Entry point of every authenticated session (web UI, mobile, integrator scripts).
        Exchanges credentials for a bearer token that must then be sent on every
        subsequent call.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              additionalProperties: false
              properties: { email: { type: string, format: email }, password: { type: string, minLength: 8, maxLength: 120, format: password } }
            example:
              email: john@acme.com
              password: ••••••••
      responses:
        200:
          description: "Authenticated. Returns a 64-hex bearer token, its absolute expiration, the user profile snapshot, the effective rights, and the caller's recent connection history."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LoginSuccess' }
              example: { result: success, accessToken: e5e6029a7f6a9bfbb1be65fbcb1a61350609948d7670cf64db3e82bd1124bf68, expiration: '2026-04-21 19:09:30+00:00', user: { id: 201, instance_id: 42, firstname: John, lastname: Smith, fullname: 'John Smith', created_at: '2026-03-20 08:39:28', status: 1, role: 10, locale: fr_FR, email: john@acme.com, instance_name: acme, instance_status: 1, default_team_id: 5, default_team_name: Global, role_key: admin, identifiers: [{ tierapp: null, identifier: null }] }, rights: [manage_users, manage_automations, manage_api, manage_connectors, manage_billing, manage_grids, impersonate, view_talks, manage_talks, evaluate_talk, manage_instances, manage_own_instance, use_rag], connectionAttempts: [{ success: 1, created_at: '2026-04-21 18:39:30', remote_os: curl/8.5.0, remote_ip: 203.0.113.10 }, { success: 1, created_at: '2026-04-21 08:09:29', remote_os: 'Mac OS', remote_ip: 198.51.100.42 }] }
        403:
          description: |
            Credentials rejected. Covers every failure mode: unknown email, wrong password,
            disabled user, disabled instance, disabled default team, extra/forbidden body
            fields, or internal token-save error. The API does not distinguish these cases
            on purpose (avoids credential enumeration).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /signup:
    post:
      tags:
        - Login
      summary: 'Register a new user (public, provisions a new instance)'
      description: |
        Self-service onboarding flow for prospects signing up from the marketing site.
        Creates a brand-new instance, a default "Global" team, a support user, the
        admin user for the signer, and two default models. The password is **not**
        provided here — the signer receives an activation email with a one-time token
        and sets the password afterwards via `PUT /user/password`.

        Disposable and generic email domains (gmail, yahoo, yopmail, mailinator, …)
        are rejected. Domains containing the name of a direct competitor are rejected
        as well.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, firstname, lastname, company, terms_accepted, locale]
              additionalProperties: false
              properties: { email: { type: string, format: email, description: 'Must be a valid email on a valid (non-disposable, non-blacklisted) domain. MX lookup is performed.' }, firstname: { type: string, minLength: 3 }, lastname: { type: string, minLength: 3 }, company: { type: string, minLength: 3 }, terms_accepted: { description: 'Must be truthy (`true`, `1`, or `"1"`).', oneOf: [{ type: boolean, enum: [true] }, { type: integer, enum: [1] }, { type: string, enum: ['1'] }] }, locale: { type: string, enum: [fr_FR, en_GB, de_DE, es_ES], description: 'UI locale for the welcome email and the initial instance. Individual instances may later enable a different subset.' } }
            example:
              email: bob@newcompany.com
              firstname: Bob
              lastname: Wilson
              company: NewCompany
              terms_accepted: true
              locale: en_GB
      responses:
        201:
          description: |
            Instance, default team, support user, admin user and default models created.
            The activation token generated server-side is sent by email only — it is
            **not** returned in the response.
          content:
            application/json:
              schema: { type: object, required: [result, data], properties: { result: { type: string, enum: [success] }, data: { type: object, description: 'Newly created admin user, same shape as `GET /user/{id}` augmented with `sentWelcomeEmail`.', properties: { id: { type: integer }, instance_id: { type: integer }, firstname: { type: string }, lastname: { type: string }, fullname: { type: string }, created_at: { type: string, description: "Naive datetime in the caller's timezone." }, status: { type: integer, enum: [0, 1] }, role: { type: integer, description: 'Numeric role id (see `role_key` for the symbolic value).' }, locale: { type: string, enum: [fr_FR, en_GB, de_DE, es_ES] }, email: { type: string, format: email }, instance_name: { type: string }, default_team_id: { type: integer }, default_team_name: { type: string }, role_key: { type: string, enum: [admin, leader, viewer] }, talks: { type: integer, description: 'Number of uploads owned by the user (always 0 at signup).' }, identifiers: { type: array, items: { type: object, properties: { tierapp: { type: string, nullable: true }, identifier: { type: string, nullable: true } } } }, sentWelcomeEmail: { type: integer, enum: [1], description: 'Always 1 on success.' } } } } }
              example: { result: success, data: { id: 9, instance_id: 42, firstname: Bob, lastname: Wilson, fullname: 'Bob Wilson', created_at: '2026-04-21 10:00:00', status: 1, role: 12, locale: en_GB, email: bob@newcompany.com, instance_name: NewCompany-a1b2c3d4, default_team_id: 5, default_team_name: Global, role_key: admin, talks: 0, identifiers: [{ tierapp: null, identifier: null }], sentWelcomeEmail: 1 } }
        403:
          description: |
            Validation failed. Covers invalid/blacklisted email, missing fields, terms
            not accepted, fields too short, extra fields, **and the "email already
            registered" case** (the action uses a single 403 path for all pre-insert
            rejections — not 400/409).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        500:
          description: 'Server-side failure during instance/team/user/model provisioning or when the welcome email cannot be sent.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /logout:
    post:
      tags:
        - Login
      summary: "Invalidate the caller's bearer token"
      description: |
        Called when a user explicitly signs out. Destroys the current token
        server-side so it can no longer be replayed.

        **Two branches depending on token type:**
        - **Normal session** — returns `{result: success, accessToken: "destroyed"}`.
        - **Impersonation session (logas)** — destroys the logas token and **re-issues
          a fresh token for the original user**, returning a full session payload so
          the client can resume as the original user without another `/login`.
      responses:
        200:
          description: 'Token revoked. Payload depends on whether the caller was impersonating (see `description`).'
          content:
            application/json:
              schema: { oneOf: [{ $ref: '#/components/schemas/LogoutSimple' }, { $ref: '#/components/schemas/LogasResumeOriginal' }] }
              examples: { simple: { summary: 'Normal logout', value: { result: success, accessToken: destroyed } }, logasResume: { summary: "Logout from an impersonation session — returns the original user's new session", value: { result: success, accessToken: 2b89476114d4f988bd558cc1ffe8f1f8668a22abd9e0cb155ca44731850982c6, expiration: '2026-04-21 19:09:48+00:00', user: { id: 201, instance_id: 42, firstname: John, lastname: Smith, fullname: 'John Smith', created_at: '2026-03-20 08:39:28', status: 1, role: 10, locale: fr_FR, email: john@acme.com, instance_name: acme, default_team_id: 5, default_team_name: Global, role_key: admin, talks: 285, identifiers: [{ tierapp: null, identifier: null }] }, rights: [manage_users, view_talks, manage_automations] } } }
        401:
          $ref: '#/components/responses/Unauthorized'
        403:
          description: 'Re-issuing the original-user token failed during a logas-logout.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        500:
          description: 'Token deletion failed.'
          content:
            text/plain:
              schema: { type: string }
              example: 'Unexpected error #c0351b46.'
  /refreshToken:
    post:
      tags:
        - Login
      summary: "Extend the caller's session by issuing a fresh bearer token"
      description: |
        Extends an active session without prompting the user to re-enter credentials.
        Typically called by the web client a few minutes before the current token
        expires. The incoming token is destroyed and a new one, valid for
        `TOKEN_DEFAULT_DURATION`, replaces it.

        No request body — the caller is identified by the `Authorization` header alone.
        If the current token is an impersonation token, the new token is emitted as a
        logas token for the same original user (chaining preserved).
      responses:
        200:
          description: 'New bearer token issued; the previous one is destroyed.'
          content:
            application/json:
              schema: { type: object, required: [result, accessToken, expiration], properties: { result: { type: string, enum: [success] }, accessToken: { type: string, description: 'Fresh 64-hex bearer token.' }, expiration: { type: string, description: "Absolute expiration, ISO-like datetime with offset in the caller's timezone." } } }
              example: { result: success, accessToken: 2b89476114d4f988bd558cc1ffe8f1f8668a22abd9e0cb155ca44731850982c6, expiration: '2026-04-21 19:09:48+00:00' }
        401:
          $ref: '#/components/responses/Unauthorized'
        403:
          description: 'Token refresh failed (DB error while saving the new token).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /logas:
    post:
      tags:
        - Login
      summary: 'Impersonate another user (admin / partner feature)'
      description: |
        Lets an authorized user open a support session as another user without
        knowing their password. Used by platform admins (`manage_instances`
        across every instance) and by partner instances (`manage_instances`
        within their declared scope), and by regular admins (`impersonate`)
        within their own instance.

        **Authorization rules** (at least one must hold):
        - Caller has `impersonate` **and** target user's `instance_id` equals caller's `instance_id`, **or**
        - Caller has `manage_instances` **and** target user's `instance_id` is in the caller's accessible scope (unrestricted for the platform admin tenant, constrained by partner scopes otherwise).

        Chaining is forbidden — a caller already on a logas token is rejected with
        error code `#6035a7a4`.
      x-rights:
        - impersonate
        - manage_instances
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [instance, user]
              additionalProperties: false
              properties: { instance: { type: integer, minimum: 1, description: "Target user's `instance_id`. Must match the target user's instance; included explicitly to detect client-side mistakes." }, user: { type: integer, minimum: 1, description: "Target user's `id`." } }
            example:
              instance: 42
              user: 203
      responses:
        200:
          description: 'Impersonation session opened. The returned token is flagged as a logas token server-side.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/LogasSuccess' }
              example: { result: success, accessToken: e5f75127a83c8def9da4be8e28a99fe2d7995fba2c3e7d061ad6d49c0442d089, expiration: '2026-04-21 19:09:55+00:00', user: { id: 203, instance_id: 42, firstname: Alex, lastname: Taylor, fullname: 'Alex Taylor', created_at: '2026-03-20 08:39:28', status: 1, role: 10, locale: fr_FR, email: alex@acme.com, instance_name: acme, default_team_id: 5, default_team_name: Global, role_key: admin, talks: 285, identifiers: [{ tierapp: null, identifier: null }] }, rights: [manage_users, manage_automations, manage_api, manage_connectors, manage_billing, manage_grids, impersonate, view_talks, manage_talks, evaluate_talk, manage_instances, manage_own_instance, use_rag] }
        401:
          $ref: '#/components/responses/Unauthorized'
        403:
          description: |
            Impersonation refused. Covers: target user not found, target user not
            member of the declared `instance`, target user's team not accessible to
            caller, caller lacks both `impersonate` and `manage_instances`, target
            instance out of caller's scope, caller already on a logas token
            (chaining), or internal token-save failure.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /models/list:
    get:
      tags:
        - Models
      summary: 'List evaluation models'
      description: |
        Shown in the model-management admin page. Each row also carries usage
        counts (categories, questions, uploads) so admins can gauge adoption.
      x-rights:
        - manage_grids
        - view_models
      parameters:
        -
          name: label
          in: query
          schema:
            type: string
          description: "LIKE filter on the model's label (in the caller's locale)."
        -
          name: status
          in: query
          schema:
            type: integer
            enum:
              - 0
              - 1
          description: '1 = active (disabled_at IS NULL), 0 = disabled.'
        -
          name: orderby
          in: query
          schema:
            type: string
            enum:
              - label
              - status
              - model_id
              - created_at
              - disabled_at
              - categories
              - questions
              - uploads
            default: label
        -
          name: orderdirection
          in: query
          schema:
            type: string
            enum:
              - ASC
              - DESC
            default: ASC
        -
          name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        -
          name: show
          in: query
          schema:
            type: integer
            enum:
              - 10
              - 50
              - 100
        -
          name: display
          in: query
          schema:
            type: string
            enum:
              - light
              - default
              - full
            default: default
          description: 'Richness of each row: `light` omits counts, `default` adds categories+questions counts, `full` also adds uploads count.'
      responses:
        200:
          description: 'Paginated list of models with per-row usage counts.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelsListResponse' }
              example: { show: 50, total: 2, page: 1, pages: 1, results: [{ id: 308, created_at: '2026-04-02 10:27:37', disabled_at: null, status: 1, creator: { id: 201, firstname: John, lastname: Smith }, labels: { fr_FR: '0-Alertes groupe' }, categories: 2, questions: 6, uploads: '-' }, { id: 1, created_at: '2026-01-07 11:43:58', disabled_at: null, status: 1, creator: { id: 201, firstname: John, lastname: Smith }, labels: { en_GB: 'Init model', fr_FR: '1-Simple avec NA' }, categories: 4, questions: 8, uploads: 268 }] }
        403:
          $ref: '#/components/responses/Forbidden'
  /model/create:
    post:
      tags:
        - Models
      summary: 'Create an evaluation model with its categories and questions'
      description: |
        Assemble a new scoring grid by composing library categories and
        questions. Once published, the model can be selected on new uploads
        to drive the automatic grading pipeline.
      x-rights:
        - manage_grids
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ModelCreate'
      responses:
        201:
          description: 'Model created — full detail returned under `data`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelMutationResponse' }
              example: { result: success, data: { model: { id: 400, created_at: '2026-04-21 10:00:00', disabled_at: null, creator: { creator_id: 201, creator_firstname: John, creator_lastname: Smith }, labels: { fr_FR: 'Évaluation Commerciale', en_GB: 'Sales Evaluation' }, uploads: 0 }, categories: { 1678: { id: 1678, library_category_id: 5, category_position: 0, category_source: generic, category_labels: { en_US: "Advisor's Behavior", fr_FR: 'Attitude du conseiller' }, questions: { 8235: { id: 8235, library_question_id: 13, question_position: 0, question_type: auto, question_source: generic, question_options: [10, 0, null], question_formats: [provide_boolean, provide_citation, provide_explanation], question_labels: { fr_FR: { label: Empathie, question: "Le conseiller fait preuve d'empathie." } }, na_allowed: 0 } } } } } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
  '/model/{id}':
    get:
      tags:
        - Models
      summary: 'Read a model with its categories and questions'
      description: 'Full structural view of a model — used by the grid designer when editing.'
      x-rights:
        - manage_grids
        - view_models
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Model detail (unwrapped — no `result`/`data` envelope).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelDetail' }
              example: { model: { id: 308, created_at: '2026-04-02 10:27:37', disabled_at: null, creator: { creator_id: 201, creator_firstname: John, creator_lastname: Smith }, labels: { fr_FR: '0-Alertes groupe' }, uploads: 5 }, categories: { 1678: { id: 1678, library_category_id: 5, category_position: 0, category_source: generic, category_labels: { en_US: "Advisor's Behavior", fr_FR: 'Attitude du conseiller' }, questions: { 8235: { id: 8235, library_question_id: 13, question_position: 0, question_type: auto, question_source: generic, question_options: [10, 0, null], question_formats: [provide_boolean, provide_citation, provide_explanation], question_labels: { fr_FR: { label: Empathie, question: "Le conseiller fait preuve d'empathie." } }, na_allowed: 0 } } } } }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
    put:
      tags:
        - Models
      summary: 'Update a model'
      description: |
        Change a model's labels or rework its category/question composition.
        Changes apply to uploads scored with this model from now on; past
        scores remain tied to the version they were computed under.
      x-rights:
        - manage_grids
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ModelUpdate'
      responses:
        200:
          description: 'Updated. Response adds an `id` acknowledgement string alongside `data`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelUpdateResponse' }
              example: { result: success, id: 'Model updated', data: { model: { id: 308, created_at: '2026-04-02 10:27:37', disabled_at: null, creator: { creator_id: 201, creator_firstname: John, creator_lastname: Smith }, labels: { fr_FR: '0-Alertes groupe (v2)' }, uploads: 5 }, categories: { 1678: { id: 1678, library_category_id: 5, category_position: 0, category_source: generic, category_labels: { en_US: "Advisor's Behavior", fr_FR: 'Attitude du conseiller' }, questions: { 8235: { id: 8235, library_question_id: 13, question_position: 0, question_type: auto, question_source: generic, question_options: [10, 0, null], question_formats: [provide_boolean, provide_citation, provide_explanation], question_labels: { fr_FR: { label: Empathie, question: "Le conseiller fait preuve d'empathie." } }, na_allowed: 0 } } } } } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  '/model/{id}/enable':
    patch:
      tags:
        - Models
      summary: 'Enable a model'
      description: 'Make the model selectable again on the upload-create screen.'
      x-rights:
        - manage_grids
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Enabled — returns the refreshed model detail.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelMutationResponse' }
              example: { result: success, data: { model: { id: 308, created_at: '2026-04-02 10:27:37', disabled_at: null, creator: { creator_id: 201, creator_firstname: John, creator_lastname: Smith }, labels: { fr_FR: '0-Alertes groupe' }, uploads: 5 }, categories: [] } }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  '/model/{id}/disable':
    patch:
      tags:
        - Models
      summary: 'Disable a model'
      description: |
        Retire a model from the upload-create picker. Past uploads keep the
        scores they already have under this model.
      x-rights:
        - manage_grids
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Disabled — returns the refreshed model detail (now with `disabled_at`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ModelMutationResponse' }
              example: { result: success, data: { model: { id: 308, created_at: '2026-04-02 10:27:37', disabled_at: '2026-04-21 10:00:00', creator: { creator_id: 201, creator_firstname: John, creator_lastname: Smith }, labels: { fr_FR: '0-Alertes groupe' }, uploads: 5 }, categories: [] } }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  /aiproviders:
    get:
      tags:
        - Providers
      summary: 'List AI analysis providers (attributes, questions, summaries)'
      description: |
        Read-only catalog of the LLMs available for attribute auto-fill,
        question answering and summary generation. Feeds the provider
        dropdown when creating or editing a model / attribute / summary.
      responses:
        200:
          description: 'Provider catalog.'
          content:
            application/json:
              schema: { type: array, items: { type: object, properties: { providerName: { type: string, description: 'Vendor display name.', example: Mistral }, providerModel: { type: string, description: 'Model family label shown in the UI.', example: 'Ministral 3 - ' }, providerTechnicalModelName: { type: string, description: 'Technical identifier sent to the vendor API.', example: ministral-14b-latest }, providerVersion: { type: string, description: 'Version suffix displayed alongside the model.', example: 14B }, features: { type: array, description: 'Analysis capabilities exposed by this model.', items: { type: string, enum: [summaries, questions, attributes, translation] }, example: [summaries, questions, attributes, translation] }, regions: { type: array, description: 'Regions where this model is served.', items: { type: object, properties: { regionName: { type: string, example: EU } } } } } }, example: [{ providerName: Mistral, providerModel: 'Ministral 3 - ', providerTechnicalModelName: ministral-14b-latest, providerVersion: 14B, features: [summaries, questions, attributes, translation], regions: [{ regionName: EU }, { regionName: Global }] }, { providerName: Google, providerModel: Gemini, providerTechnicalModelName: gemini-2.5-flash, providerVersion: 2.5-flash, features: [summaries, questions, attributes, translation], regions: [{ regionName: EU }, { regionName: Global }, { regionName: US }] }] }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          description: 'Providers definition file missing or invalid.'
          content:
            application/json:
              schema: { type: object, properties: { error: { type: string } } }
              example: { error: 'Providers definition not found' }
  /transcriptionproviders:
    get:
      tags:
        - Providers
      summary: 'List transcription providers (AssemblyAI, Mistral, Pyannote, Gemini)'
      description: |
        Read-only catalog of transcription engines with their per-provider
        capabilities (languages, translation, entity anonymization). Used by
        the upload form and the configuration UI to pick the right engine.
      responses:
        200:
          description: 'Provider catalog (with per-provider constraints).'
          content:
            application/json:
              schema: { type: array, items: { type: object, properties: { providerModel: { type: string, description: 'Model family label.', example: Gemini }, providerTechnicalModelName: { type: string, description: 'Technical identifier.', example: gemini-2.5-flash }, providerVersion: { type: string, example: 2.5-flash }, regions: { type: array, items: { type: object, properties: { regionName: { type: string, example: EU } } } }, constraints: { type: object, description: 'Capability flags (0 = not supported, 1 = supported).', properties: { canTranslateTranscript: { type: integer, enum: [0, 1] }, canAnonymizeEntities: { type: integer, enum: [0, 1] } } } } }, example: [{ providerModel: Voxtral, providerTechnicalModelName: voxtral-mini-latest, providerVersion: Mini, regions: [{ regionName: EU }], constraints: { canTranslateTranscript: 0, canAnonymizeEntities: 0 } }, { providerModel: Gemini, providerTechnicalModelName: gemini-2.5-flash, providerVersion: 2.5-flash, regions: [{ regionName: EU }, { regionName: US }], constraints: { canTranslateTranscript: 1, canAnonymizeEntities: 1 } }, { providerModel: Best, providerTechnicalModelName: best, providerVersion: best, regions: [{ regionName: EU }, { regionName: US }], constraints: { canTranslateTranscript: 0, canAnonymizeEntities: 1 } }] }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          description: 'Providers definition file missing or invalid.'
          content:
            application/json:
              schema: { type: object, properties: { error: { type: string } } }
              example: { error: 'Providers definition not found' }
  /speakersroles:
    get:
      tags:
        - Providers
      summary: 'List predefined speaker roles'
      description: |
        Canonical list of speaker roles (manager, customer, …) used by the
        diarization pipeline to tag who said what. Consumed by the upload
        form to let a reviewer assign a role to each detected speaker.
      responses:
        200:
          description: 'Roles catalog.'
          content:
            application/json:
              schema: { type: array, items: { type: object, properties: { key: { type: string, description: 'Stable identifier of the speaker-role pair.', example: advisor_customer }, companyRoleLabels: { type: object, description: 'Localized label for the company-side speaker. The `default` key is the fallback; other keys are ISO 639-1 locale codes.', additionalProperties: { type: string }, example: { default: advisor, fr: conseiller, en: advisor, es: asesor, de: Berater, lb: Beroder } }, customerRoleLabels: { type: object, description: 'Localized label for the customer-side speaker. Same locale keys as `companyRoleLabels`.', additionalProperties: { type: string }, example: { default: customer, fr: client, en: customer, es: cliente, de: Kunde, lb: Client } } } }, example: [{ key: advisor_customer, companyRoleLabels: { default: advisor, fr: conseiller, en: advisor, es: asesor, de: Berater, lb: Beroder }, customerRoleLabels: { default: customer, fr: client, en: customer, es: cliente, de: Kunde, lb: Client } }, { key: salesperson_prospect, companyRoleLabels: { default: salesperson, fr: vendeur, en: salesperson, es: comercial, de: Verkaufer, lb: Verkeefer }, customerRoleLabels: { default: prospect, fr: prospect, en: prospect, es: prospecto, de: Interessent, lb: Prospekt } }] }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          description: 'Speakers definition file missing or invalid.'
          content:
            application/json:
              schema: { type: object, properties: { error: { type: string } } }
              example: { error: 'Speakers definition not found' }
  /rag/query:
    post:
      tags:
        - Rag
      summary: "Ask a natural-language question across the caller's indexed talks"
      description: |
        "Ask your talks" search bar — embeds the question, retrieves the most
        relevant chunks from the instance's vector index (transcript segments,
        summaries, criteria answers and attribute chunks), reranks them with
        keyword overlap, diversifies them (one best chunk per question, capped
        per upload), then asks the LLM to write a structured answer grounded
        in those chunks.

        Visibility is enforced server-side: `viewer` callers only see their
        own uploads, other roles see the uploads of the teams they belong to
        (falling back to uploads with no team for first-level users).

        The billing of the rewrite + embedding + generation calls is debited
        from the instance balance.
      x-rights:
        - view_talks
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [question]
              properties: { question: { type: string, maxLength: 500, description: "User question in natural language. Rewritten by the LLM\nbefore embedding unless the string contains the literal\ntoken `norewrite` (stripped before use).\n" }, limit: { type: integer, minimum: 1, maximum: 200, default: 50, description: 'Max number of final chunks returned in `sources`. Clamped to 200.' }, min_score: { description: "Minimum cosine similarity on retrieved results (0-1). Omit\nfor the default `0.65`. Pass `null`, `false`, `0` or\n`0.0` to disable the threshold entirely.\n", oneOf: [{ type: number, minimum: 0, maximum: 1 }, { type: 'null' }] }, response_type: { type: string, enum: [ranked_list, analysis, playbook], description: "Force a response shape. Omit to let the LLM auto-detect\nfrom the question. Unknown values are silently ignored\n(falls back to auto).\n" }, chunk_types: { type: array, description: 'Restrict retrieval to these chunk types.', items: { type: string, enum: [transcript_segment, summary, criteria_answer, attributes, attribute] } }, timezone: { type: string, default: UTC, description: 'IANA timezone used to interpret `period.start` / `period.end` (e.g. `Europe/Paris`).' }, period: { type: object, description: 'Filter uploads by creation date. Both bounds optional.', properties: { start: { type: string, description: 'Parseable by PHP DateTime (e.g. `2026-03-22 00:00:00`).' }, end: { type: string, description: 'Parseable by PHP DateTime (e.g. `2026-04-21 23:59:59`).' } } }, filters: { type: object, description: "KPI-style filters. Each group uses mutually exclusive\n`{include, exclude}` arrays. Team filters are intersected\nwith the caller's allowed teams; excluded users/models\nare applied as negative filters on the retrieval.\n", properties: { users: { type: object, properties: { include: { type: array, items: { type: integer } }, exclude: { type: array, items: { type: integer } } } }, teams: { type: object, properties: { include: { type: array, items: { type: integer } }, exclude: { type: array, items: { type: integer } } } }, models: { type: object, properties: { include: { type: array, items: { type: integer } }, exclude: { type: array, items: { type: integer } } } }, attributes: { type: object, description: "Keys are attribute ids (digit strings); values are `{include, exclude}` on the attribute's stored string values.", additionalProperties: { type: object, properties: { include: { type: array, items: { type: string } }, exclude: { type: array, items: { type: string } } } } }, questions: { type: object, description: 'Keep only uploads whose `criteria_answer` chunks match a question + status tuple. Multiple entries are AND-ed.', properties: { include: { type: array, items: { type: object, required: [id], properties: { id: { type: integer, description: 'Question id from the library.' }, status: { description: "Match by answer status:\n- `1` — acquired (answer_value == max_value)\n- `0` — not acquired (answer_value != max_value)\n- `null` — N/A (answer_value is null)\n- any other value (e.g. `\"any\"`) — just needs to exist\n" } } } } } } } } }
      responses:
        200:
          description: |
            Structured LLM answer grounded in `sources`. An empty filter set
            (no matching uploads) returns a 200 with `answer` =
            "Aucun résultat ne correspond aux filtres appliqués." and empty
            `sources` / zeroed billing.
          content:
            application/json:
              schema: { type: object, required: [answer, sources, billing, ragInfo], properties: { structured_answer: { type: object, description: 'Parsed structured output from the LLM. Omitted on the empty-result shortcut.', properties: { response_type: { type: string, enum: [ranked_list, analysis, playbook] }, summary: { type: string }, items: { type: array, items: { type: object, properties: { title: { type: string }, detail: { type: string }, mentions: { type: integer, nullable: true, description: 'For `ranked_list`, equals `upload_ids.length` after server-side validation. Null otherwise.' }, upload_ids: { type: array, items: { type: integer }, description: 'Server-validated subset of `sources[].upload_id` — hallucinated ids are dropped.' } } } }, stats: { type: object, properties: { confidence: { type: string, enum: [low, medium, high] }, calls_analyzed: { type: integer, description: 'Number of distinct uploads referenced in `sources`.' } } }, charts: { type: array, description: 'Up to 3 validated charts. Malformed charts are silently dropped.', items: { type: object, properties: { type: { type: string, enum: [bar, line, pie, radar] }, title: { type: string }, data: { type: object, properties: { labels: { type: array, items: { type: string } }, series: { type: array, items: { type: object, properties: { name: { type: string }, values: { type: array, items: { type: number } } } } } } } } } } } }, answer: { type: string, description: 'Backward-compatible plain-text rendering of `structured_answer.summary` followed by each item.' }, sources: { type: array, description: 'Retrieved chunks used to ground the answer, ordered by `combined_score` (desc).', items: { type: object, properties: { upload_id: { type: integer }, chunk_type: { type: string, enum: [transcript_segment, summary, criteria_answer, attributes, attribute] }, text: { type: string, description: 'Chunk text, truncated to 200 characters.' }, score: { type: number, description: 'Cosine similarity on the retrieved chunk, rounded to 4 decimals.' }, keyword_score: { type: number, description: 'Share of question keywords matched in the chunk (0-1).' }, combined_score: { type: number, description: '`alpha * score + (1 - alpha) * keyword_score`, rounded to 4 decimals.' }, metadata: { type: object, description: 'Free-form payload metadata — shape depends on `chunk_type` (e.g. `question_id`, `answer_value`, `max_value`, `summary_score`, `timespan`).' }, creator_id: { type: integer, nullable: true }, creator_fullname: { type: string, nullable: true }, advisor_id: { type: integer, nullable: true }, advisor_fullname: { type: string, nullable: true }, advisor_team_id: { type: integer, nullable: true }, advisor_team_name: { type: string, nullable: true }, advisor_team_path_id: { type: string, nullable: true, description: 'Pipe-delimited id path, e.g. `|1|112|115`.' }, advisor_team_path_name: { type: string, nullable: true, description: 'Pipe-delimited name path, e.g. `|Global|Region A|Team A`.' }, upload_locale: { type: string, nullable: true }, transcription_language: { type: string, nullable: true }, analysis_language: { type: string, nullable: true }, upload_created_at: { type: string, nullable: true, description: 'ISO-8601 UTC (`YYYY-MM-DDTHH:MM:SSZ`).' }, attributes: { type: object, description: 'Map of attribute id (digit string) to `{label, values[]}`.' } } } }, billing: { type: object, description: 'Per-step billing debited from the instance balance. `value` is the client-facing amount (cost + margin), 8-decimal string.', properties: { rewrite: { type: object, properties: { model: { type: string }, input_tokens: { type: integer }, output_tokens: { type: integer }, value: { type: string } } }, embedding: { type: object, properties: { model: { type: string }, tokens: { type: integer }, value: { type: string } } }, generation: { type: object, properties: { model: { type: string }, input_tokens: { type: integer }, output_tokens: { type: integer }, value: { type: string } } }, total: { type: object, properties: { value: { type: string } } } } }, ragInfo: { type: object, description: "Free-form debug/telemetry envelope — pipeline knobs,\nper-step timings, token counts and retrieval stats.\nInternal shape, subject to change without notice; do\nnot rely on specific keys in production clients.\n", additionalProperties: true, properties: { provider: { type: string, description: 'Resolved RAG provider name (e.g. `Mistral`, `Gemini`).' } } } } }
              example: { structured_answer: { response_type: analysis, summary: "L'analyse des conversations révèle une gestion efficace des procédures de contestation et de résiliation.", items: [{ title: 'Gestion proactive du suivi par email', detail: "Les conseillers proposent systématiquement l'envoi d'emails de confirmation.", mentions: null, upload_ids: [423, 444, 420] }], stats: { confidence: high, calls_analyzed: 35 }, charts: [{ type: bar, title: 'Performance par conseiller', data: { labels: ['John Smith', 'Jane Doe'], series: [{ name: 'Score moyen', values: [10, 10] }] } }] }, answer: "L'analyse des conversations révèle une gestion efficace des procédures de contestation et de résiliation.\n\n- Gestion proactive du suivi par email: Les conseillers proposent systématiquement l'envoi d'emails de confirmation.", sources: [{ upload_id: 430, chunk_type: summary, text: 'Le client recevra un formulaire de contestation par email.', score: 0.7885, keyword_score: 1, combined_score: 0.8519, metadata: { summary_score: 80, positive: true, timespan: { start: 139401, end: 158301 } }, creator_id: 2, creator_fullname: 'Jane Doe', advisor_id: 203, advisor_fullname: 'Alex Taylor', advisor_team_id: 115, advisor_team_name: 'Team A', advisor_team_path_id: '|1|112|115', advisor_team_path_name: '|Global|Region A|Team A', upload_locale: fr, transcription_language: fr, analysis_language: fr, upload_created_at: '2025-12-29T11:09:41Z', attributes: { 1: { label: Langue, values: [fr_FR] }, 23: { label: Catégorie, values: [Réclamation] } } }], billing: { rewrite: { model: gemini-3.1-flash-lite-preview, input_tokens: 127, output_tokens: 38, value: '0.00088750' }, embedding: { model: text-embedding-005, tokens: 43, value: '0.00001075' }, generation: { model: gemini-3-flash-preview, input_tokens: 7030, output_tokens: 885, value: '0.06170000' }, total: { value: '0.06259825' } }, ragInfo: { provider: Gemini } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  /teams/list:
    get:
      tags:
        - Teams
      summary: "List teams in the caller's instance"
      description: |
        Powers the team picker, the org-chart viewer and filter dropdowns.
        Teams form a hierarchy used for user scoping, visibility, and reporting.

        The listing is already scoped to the caller's instance and to the
        subset of teams the caller is allowed to see (`visibleTeams`).
      x-rights:
        - manage_users
        - view_users
      parameters:
        -
          name: name
          in: query
          schema:
            type: string
          description: 'Case-insensitive LIKE filter on team name (wrapped with `%...%`).'
        -
          name: status
          in: query
          schema:
            type: integer
            enum:
              - 0
              - 1
          description: '0 = archived, 1 = active.'
        -
          name: orderby
          in: query
          schema:
            type: string
            enum:
              - path_name
              - name
              - id
              - created_at
            default: path_name
        -
          name: orderdirection
          in: query
          schema:
            type: string
            enum:
              - ASC
              - DESC
            default: ASC
        -
          name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        -
          name: show
          in: query
          schema:
            type: integer
            enum:
              - 10
              - 50
              - 100
              - 1000
          description: "Page size. Defaults to the server's `PAGINATION_DEFAULT_LIMIT` (50 in the reference deployment)."
        -
          name: display
          in: query
          schema:
            type: string
            enum:
              - light
              - default
              - full
            default: default
          description: 'Controls the row richness. `light` = minimal fields (no status/parent/path_name); `default` = standard team fields; `full` = adds `subteams` and `totalUsers` counters.'
      responses:
        200:
          description: 'Paginated list. Row shape depends on `display`.'
          content:
            application/json:
              schema: { type: object, properties: { show: { type: integer }, total: { type: integer }, page: { type: integer }, pages: { type: integer }, results: { type: array, items: { $ref: '#/components/schemas/Team' } } } }
              example: { show: 50, total: 3, page: 1, pages: 1, results: [{ id: 1, instance_id: 42, parent_id: null, name: Global, path_id: '|1', path_name: '|Global', status: 1, created_by: null, created_at: '2025-11-04 10:17:31' }, { id: 112, instance_id: 42, parent_id: 1, name: France, path_id: '|1|112', path_name: '|Global|France', status: 1, created_by: 0, created_at: '2026-04-16 09:10:08' }, { id: 114, instance_id: 42, parent_id: 112, name: Paris, path_id: '|1|112|114', path_name: '|Global|France|Paris', status: 1, created_by: 0, created_at: '2025-11-06 08:34:52' }] }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  /team/create:
    post:
      tags:
        - Teams
      summary: 'Create a team'
      description: |
        Create a new team, optionally nested under a parent. Use teams to model
        the customer's org structure — user scoping, KPI breakdowns and
        partner-scope visibility all hinge on this hierarchy.

        Both `parent` and `status` are required. `parent` must reference a team
        that belongs to the caller's instance and is within the caller's
        visibility perimeter.
      x-rights:
        - manage_users
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, parent, status]
              properties: { name: { type: string, minLength: 3, description: 'Team display name. Minimum 3 characters.' }, parent: { type: integer, minimum: 1, description: "Parent team id. Must exist in the caller's instance and be visible to the caller." }, status: { type: integer, enum: [0, 1], description: '0 = archived, 1 = active.' } }
      responses:
        201:
          description: 'Team created. Response wraps the newly inserted team under `data`.'
          content:
            application/json:
              schema: { allOf: [{ $ref: '#/components/schemas/Success' }, { type: object, required: [data], properties: { data: { $ref: '#/components/schemas/Team' } } }] }
              example: { result: success, data: { id: 500, instance_id: 42, parent_id: 1, name: Engineering, path_id: '|1|500', path_name: '|Global|Engineering', status: 1, created_by: 42, created_at: '2026-04-21 10:00:00' } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  '/team/{id}':
    get:
      tags:
        - Teams
      summary: 'Read a team with its direct children'
      description: |
        Read a single team with its direct children — used to render a subtree
        in the team hierarchy viewer or to check a team's full membership path.

        The team identified by the path `id` must be within the caller's
        visibility perimeter. The optional `parent` query param is accepted by
        the implementation but is overridden by the path `id` on the same
        request, so it has no practical effect; keep using the path `id`.
      x-rights:
        - manage_users
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
          description: 'Id of the team to read.'
        -
          name: parent
          in: query
          schema:
            type: integer
          description: 'Accepted but ignored — the path `id` always wins. Kept for backwards compatibility with older clients.'
      responses:
        200:
          description: 'Team detail with its direct children under `children`.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/TeamWithChildren' }
              example: { id: 112, instance_id: 42, name: France, path_id: '|1|112', path_name: '|Global|France', parent_id: 1, status: 1, created_by: 0, created_at: '2026-04-16 09:10:08', children: { found: 2, results: [{ id: 115, instance_id: 42, name: Marseille, path_id: '|1|112|115', path_name: '|Global|France|Marseille', parent_id: 112, status: 1, created_by: 0, created_at: '2025-11-06 08:34:58' }, { id: 114, instance_id: 42, name: Paris, path_id: '|1|112|114', path_name: '|Global|France|Paris', parent_id: 112, status: 1, created_by: 0, created_at: '2025-11-06 08:34:52' }] } }
        400:
          description: "Team id is outside the caller's visibility perimeter."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
    put:
      tags:
        - Teams
      summary: 'Update a team'
      description: |
        Rename a team, re-parent it to another node, or toggle its active
        status. All body fields are optional — only the supplied fields are
        updated.

        Re-parenting or renaming the team rebuilds `path_id` / `path_name` for
        the team itself and for every descendant. The new `parent` must belong
        to the same instance, be visible to the caller, and cannot be the team
        itself nor one of its descendants (no cycles).
      x-rights:
        - manage_users
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
          description: "Id of the team to update. Must be within the caller's visibility perimeter."
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties: { name: { type: string, minLength: 3, description: 'New team name. Any `|` character is stripped (reserved as the path separator).' }, parent: { type: integer, minimum: 1, description: 'New parent team id. Subject to the same rules as creation plus the no-cycle check.' }, status: { type: integer, enum: [0, 1], description: '0 = archived, 1 = active.' } }
      responses:
        200:
          description: |
            Updated. Response echoes the refreshed team under `data`. Note the
            unusual `id: "Team updated"` literal — it is a human-readable
            status label, not the team id (which is inside `data.id`).
          content:
            application/json:
              schema: { allOf: [{ $ref: '#/components/schemas/Success' }, { type: object, required: [id, data], properties: { id: { type: string, enum: ['Team updated'], description: 'Literal status label.' }, data: { $ref: '#/components/schemas/Team' } } }] }
              example: { result: success, id: 'Team updated', data: { id: 112, instance_id: 42, parent_id: 1, name: France, path_id: '|1|112', path_name: '|Global|France', status: 1, created_by: 0, created_at: '2026-04-16 09:10:08' } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  /uploads/list:
    post:
      tags:
        - Upload
      summary: 'Search / list uploads with filters and pagination'
      description: |
        Main endpoint behind the "Talks" list view. Filters support free-text
        search, date ranges, users, teams, attributes and processing status —
        everything a user needs to find one call in a large corpus.

        POST is used (instead of GET) so filters can be deep objects. Non-admin callers
        are automatically scoped to their own uploads (`filters.users` is overridden
        server-side).

        The shape of each item in `results` depends on `display`:
        `light` (ids + basic metadata), `default` (adds filename, size, status),
        `full` (adds owner/creator objects, attributes map and sentiment scores).
      x-rights:
        - view_talks
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { filters: { type: object, description: 'Free-form filter set. Non-admin callers cannot override `filters.users` — it is pinned to the caller.', properties: { instance: { type: integer, description: "Super-admin only; defaults to the caller's instance." }, users: { type: integer, description: 'Auto-set to the caller for non-admin roles.' } } }, orderby: { type: string, default: created_at, description: "Column to sort by. `users.fullname` is a special join syntax to sort by the uploader's full name.", enum: [id, created_at, originalFilename, length, size, score, transcription_status, questions_status, user_id, users.fullname] }, orderdirection: { type: string, default: ASC, enum: [ASC, DESC] }, page: { type: integer, minimum: 1, default: 1 }, show: { type: integer, enum: [10, 50, 100], description: 'Page size. Defaults to the `PAGINATION_DEFAULT_LIMIT` server setting.' }, display: { type: string, enum: [light, default, full], default: default, description: 'Serialization group applied to each item in `results`.' } }
      responses:
        200:
          description: 'Paginated list. Item shape varies by `display`.'
          content:
            application/json:
              schema: { type: object, required: [show, total, page, pages, results], properties: { show: { type: integer }, total: { type: integer }, page: { type: integer }, pages: { type: integer }, results: { type: array, items: { oneOf: [{ $ref: '#/components/schemas/UploadListItemLight' }, { $ref: '#/components/schemas/UploadListItemDefault' }, { $ref: '#/components/schemas/UploadListItemFull' }] } } } }
              examples: { default: { summary: display=default, value: { show: 10, total: 550, page: 1, pages: 55, results: [{ id: 2854, created_at: '2025-10-06 15:40:09', created_by: 1, user_id: 1164, originalFilename: MOUMNI-Aroua_20251006.wav, size: 3366624, length: 420.82, transcription_status: null, questions_status: null, summaries_status: 0, score: null, model_id: 223, language: fr, analysis_language: fr, transcription_language: fr }] } }, light: { summary: display=light, value: { show: 10, total: 550, page: 1, pages: 55, results: [{ id: 2854, created_at: '2025-10-06 15:40:09', created_by: 1, user_id: 1164, score: null, model_id: 223, language: fr }] } }, full: { summary: display=full, value: { show: 10, total: 550, page: 1, pages: 55, results: [{ id: 2854, created_at: '2025-10-06 15:40:09', created_by: { id: 201, fullname: 'John Smith', firstname: John, lastname: Smith }, user_id: 1164, originalFilename: sample-call-20251006.wav, size: 3366624, length: 420.82, transcription_status: null, questions_status: null, summaries_status: 0, score: null, model_id: 223, language: fr, analysis_language: fr, transcription_language: fr, purge_interaction_file: 1, purge_transcription: 0, model_labels: { fr_FR: '0-Enquête CGM' }, user: { id: 1164, fullname: null, firstname: null, lastname: null }, attributes: { 56: { color_scheme: grey, labels: { fr_FR: 'N° de ticket' }, auto_attribute: 0, values: { e8b8d42c4a5f4543a5c12825a5fb3682: e8b8d42c4a5f4543a5c12825a5fb3682 } } }, general_sentiment: null, general_summary: null, client_sentiment: null, advisor_sentiment: null }] } } }
        403:
          $ref: '#/components/responses/Forbidden'
  /upload/create:
    post:
      tags:
        - Upload
      summary: 'Upload a new audio file for processing'
      description: |
        Primary entry point for ingesting a call recording. Submits the audio to
        the async AI pipeline: transcription → attribute auto-fill, Q&A scoring,
        and summary generation. A `callback` URL can be provided to be notified
        when the full pipeline completes.

        Multipart form upload. (SFTP and email imports are handled by separate server-side
        workers that feed into the same pipeline — they are not this endpoint.)
      x-rights:
        - manage_talks
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, model_id, analysis_region, transcription_model, summaries_model, questions_model, language, speakers]
              properties: { file: { type: string, format: binary, description: "Audio file. Accepted MIME types: audio/mpeg (mp3), video/mp4, audio/wav,\naudio/wave, audio/ogg, application/ogg, audio/flac, audio/mp4 (m4a),\naudio/x-m4a, audio/aac, audio/x-aac. Minimum duration: 10 seconds.\nMaximum duration depends on the selected `transcription_model`.\n" }, model_id: { type: integer, description: "Evaluation model id. Must exist in the caller's instance and not be disabled." }, analysis_region: { type: string, enum: [eu, us, global], description: 'AI region for analysis models (case-insensitive; lower-cased server-side).' }, transcription_model: { type: string, description: 'Transcription model technical key (see GET /transcriptionproviders). `light` is accepted as an alias for `nano`.' }, summaries_model: { type: string, description: 'AI model technical key advertising the `summaries` feature for the selected `analysis_region` (see GET /aiproviders).' }, questions_model: { type: string, description: 'AI model technical key advertising the `questions` feature for the selected `analysis_region` (see GET /aiproviders).' }, language: { type: string, description: 'ISO language code of the audio (see GET /languages).' }, speakers: { type: string, enum: [advisor_customer, customer_advisor, salesperson_prospect, prospect_salesperson], description: 'Speaker-role preset (case-insensitive, lower-cased server-side).' }, transcription_language: { type: string, description: "Target language when translating the transcript. Only allowed if the selected\n`transcription_model` advertises `canTranslateTranscript: true`; otherwise must\nequal `language`.\n" }, analysis_language: { type: string, description: 'ISO language code used to run analysis prompts. Defaults to `language` if omitted.' }, anonymization: { type: string, description: "JSON-encoded array of entity types to redact (e.g. `[\"credit_card_number\",\"phone_number\"]`).\nOnly allowed if the selected `transcription_model` advertises `canAnonymizeEntities: true`.\nEach value must be one of the supported anonymization keys.\n" }, callback: { type: string, format: uri, maxLength: 4096, description: 'HTTP(S) URL called with the processed upload. DNS-checked at submit time.' }, purge_interaction_file: { description: 'If `1`, the source audio file is purged after processing. Accepts int or string.', oneOf: [{ type: integer, enum: [0, 1] }, { type: string, enum: ['0', '1'] }] }, purge_transcription: { description: 'If `1`, the transcript is purged after downstream analysis completes. Accepts int or string.', oneOf: [{ type: integer, enum: [0, 1] }, { type: string, enum: ['0', '1'] }] }, user_type: { type: string, enum: [id, email, identifier], description: 'Optional user lookup kind when attaching the upload to a specific user. Must be paired with `user_value`.' }, user_value: { type: string, description: 'User lookup value matching the chosen `user_type` (user id, email or external identifier).' } }
      responses:
        200:
          description: 'Upload accepted and queued.'
          content:
            application/json:
              schema: { type: object, required: [result, upload, setAttributes, uptodateAttributes, anonymization, attachedUser], properties: { result: { type: string, enum: [success] }, upload: { type: object, properties: { id: { type: integer }, analysis_region: { type: string, enum: [eu, us, global] }, model_id: { type: integer }, transcription_model: { type: string }, summaries_model: { type: string }, questions_model: { type: string }, originalFilename: { type: string }, storedFilename: { type: string }, mediaType: { type: string }, size: { type: integer }, length: { type: number }, speakers: { type: string }, language: { type: string }, analysis_language: { type: string }, transcription_language: { type: string }, purge_interaction_file: { type: integer, enum: [0, 1] }, purge_transcription: { type: integer, enum: [0, 1] }, callback: { type: string, nullable: true } } }, setAttributes: { type: object, description: 'Map keyed by attribute_id; values describe per-value set/error messages.' }, uptodateAttributes: { $ref: '#/components/schemas/UploadAttributesMap' }, anonymization: { type: array, nullable: true, items: { type: string } }, attachedUser: { description: 'User id (integer) on success; explanatory string when no user was attached or the lookup failed.', oneOf: [{ type: integer }, { type: string, examples: ['User not set.', 'User not found.', "Missing 'user_type' or 'user_value' to attach user."] }] } } }
              example: { result: success, upload: { id: 13, analysis_region: eu, model_id: 1, transcription_model: voxtral-mini-latest, summaries_model: ministral-14b-latest, questions_model: ministral-14b-latest, originalFilename: demo.wav, storedFilename: abc12345.wav, mediaType: audio/wav, size: 48000000, length: 3600, speakers: advisor_customer, language: fr, analysis_language: fr, transcription_language: fr, purge_interaction_file: 0, purge_transcription: 0, callback: null }, setAttributes: [], uptodateAttributes: [], anonymization: null, attachedUser: 'User not set.' }
        400:
          $ref: '#/components/responses/ValidationError'
        402:
          description: 'Payment required — instance balance is zero or negative.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        403:
          $ref: '#/components/responses/Forbidden'
        413:
          description: 'Payload too large.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/audio':
    get:
      tags:
        - Upload
      summary: "Download or stream the upload's audio file"
      description: |
        Used by the in-app audio player while a reviewer listens along with the
        transcript, and by any client that needs to archive the raw recording.
      x-rights:
        - view_talks
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Audio stream. Response sets `Content-Length` and `Accept-Ranges: bytes`. `Content-Type` is always `audio/mpeg` regardless of the source file type.'
          content:
            audio/mpeg:
              schema: { type: string, format: binary }
        400:
          description: "Caller's team perimeter does not include this upload's user team."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          description: 'Upload not found or audio file missing from storage.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/transcript':
    get:
      tags:
        - Upload
      summary: "Retrieve the upload's transcript"
      description: |
        Return the produced transcript. Two response shapes depending on the source:

        - Imported email (`mediaType = multipart/alternative`) → `text/html` rendering of the
          decoded message body.
        - Audio recording → JSON `{utterances: [...]}` with speaker-segmented turns.
          `speaker` is an integer diarization index produced by the transcription model,
          and `start`/`end` are in milliseconds.
      x-rights:
        - view_talks
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Transcript payload — HTML for email imports, JSON for audio.'
          content:
            application/json:
              schema: { type: object, properties: { utterances: { type: array, items: { type: object, properties: { start: { type: integer, description: Milliseconds. }, end: { type: integer, description: Milliseconds. }, speaker: { type: integer, description: 'Diarization index (0,1,…).' }, text: { type: string } } } } } }
              example: { utterances: [{ start: 100, end: 1900, speaker: 1, text: 'Service livraison, Frédéric à votre écoute.' }, { start: 2000, end: 4800, speaker: 0, text: "Bonjour, j'attendais une livraison aujourd'hui." }] }
            text/html:
              schema: { type: string }
              example: '<p>From: john@acme.com<br>To: support@acme.com<br><br>Hello, I need help with…</p>'
        400:
          description: "Caller's team perimeter does not include this upload's user team."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        500:
          description: 'Upload not found or transcript file not found in storage.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/scores':
    get:
      tags:
        - Upload
      summary: 'Retrieve computed KPI / evaluation scores for an upload'
      description: |
        Return the per-category and per-question scores computed by the
        evaluation model bound to this upload. Shown on the talk-detail page
        and fed into team dashboards and KPIs.

        `upload.billing` is populated only for callers with the `manage_billing`
        right and holds the per-feature totals (transcription, summaries,
        questions, grandtotal); it is `null` otherwise.

        `scores.categories[].questions` is an **object keyed by question_id**
        (not an array), reflecting the server's internal aggregation order.
        `scores.categories[].questions.<qid>.answers` is keyed by
        `answer_format_key` (`provide_score`, `provide_citation`,
        `provide_explanation` — one or more depending on the question type).
      x-rights:
        - view_talks
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Scores grouped by category and question.'
          content:
            application/json:
              schema: { type: object, required: [upload, summaries, scores], properties: { upload: { $ref: '#/components/schemas/UploadBasics' }, summaries: { type: array, items: { $ref: '#/components/schemas/UploadSummary' } }, scores: { $ref: '#/components/schemas/UploadScores' } } }
              example: { upload: { id: 3894, instance_id: 42, originalFilename: sample-call-2024-11-04.mp3, storedFilename: 2da6d1bad101a0d3.mp3, created_at: '2026-04-02 10:10:43', region: eu, path_stored: 42/upload/2026/04/02, size: 2333703, length: 145.789, audio_channels: mono, mediaType: audio/mpeg, speakers: advisor_customer, transcription_status: 3, summaries_status: 3, attributes_status: 1, transcription_id: '3894', transcription_path: 42/transcriptions/2026/04/02/3894.json, transcription_token: null, callback: null, questions_status: 1, language: fr, analysis_language: fr, transcription_language: fr, purge_interaction_file: 0, purge_transcription: 0, score: 100, creator: { id: 201, fullname: 'John Smith', email: john@acme.com, firstname: John, lastname: Smith, locale: fr_FR, default_team_id: 5, teamid: 5, teamname: Global, teampath: '|Global', team_path_id: '|5' }, advisor: { id: 203, fullname: 'Alex Taylor', email: alex@acme.com, firstname: Alex, lastname: Taylor, locale: fr_FR, default_team_id: 5, teamid: 5, teamname: Global, teampath: '|Global', team_path_id: '|5' }, model: { id: 308, labels: { fr_FR: '0-Alertes groupe' } }, ai_models: { analysis_region: eu, transcription: voxtral-mini-latest, summaries: ministral-14b-latest, assertions: ministral-14b-latest }, billing: { transcription: -0.2416666667, summaries: -0.0444733332, questions: -0.0326666667, grandtotal: -0.3419039999 } }, summaries: [{ summary_type: general, summary_score: 80, summary_items: { items: [{ item: "La cliente appelle pour s'inquiéter de l'absence de livraison.", positive: false, timespan: { start: 4300, end: 9100 }, justification: 'La cliente exprime une attente non comblée.' }] } }], scores: { categories: [{ category: { category_id: 5, source: generic, instance_id: null, labels: { en_US: "Advisor's Behavior", fr_FR: 'Attitude du conseiller' }, maxValues: 30, answerValues: 30, categoryScore: 100 }, questions: { 13: { question: { question_id: 13, category_id: 5, type: options, source: generic, instance_id: null, options: '[10, 0, null]', localisation: { fr_FR: { label: Empathie, question: "Le conseiller fait preuve d'empathie." } }, category: 5, maxValue: 10, minValue: 0, scoreRate: 100, score: 10 }, answers: { provide_score: { answer_value: 10, format: '1', format_key: provide_score }, provide_citation: { answer_value: null, format: '', format_key: provide_citation }, provide_explanation: { answer_value: null, format: "Le conseiller utilise des formulations comme 'Je comprends'.", format_key: provide_explanation } }, timestamps: [{ start: 24900, end: 28000 }, { start: 69600, end: 71100 }] } } }] } }
        400:
          description: "Caller's team perimeter does not include this upload's user team, or viewer cannot see another advisor's upload."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          description: 'Upload not found.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/versions':
    get:
      tags:
        - Upload
      summary: 'List the version history of an upload'
      description: |
        Each time an upload is re-scored or its evaluation criteria are edited,
        a new version is snapshotted. Use this endpoint to show "who changed
        what when" in the audit panel.

        `created_by` is `null` when the version is not associated with a user.
      x-rights:
        - view_talks
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Versions list.'
          content:
            application/json:
              schema: { type: object, required: [upload_id, versions], properties: { upload_id: { type: integer }, versions: { type: array, items: { type: object, required: [version_id, created_at, created_by], properties: { version_id: { type: integer }, created_at: { type: string }, created_by: { nullable: true, type: object, properties: { id: { type: integer }, fullname: { type: string }, email: { type: string } } } } } } } }
              example: { upload_id: 12, versions: [{ version_id: 2, created_at: '2026-04-21 14:11:05', created_by: { id: 5, fullname: 'John Doe', email: john@acme.com } }, { version_id: 1, created_at: '2026-04-21 10:00:00', created_by: null }] }
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          description: 'Upload not found.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        500:
          description: 'Database error.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/versions/{version}':
    get:
      tags:
        - Upload
      summary: 'Retrieve a specific version of an upload'
      description: |
        Read a historical snapshot — useful to compare the current scoring
        against a past grading or to restore context for a specific review.

        The response spreads the persisted snapshot (`version_content`) into
        `upload`, `summaries` and `scores`, wrapped with a `version` metadata
        object. The `upload` / `summaries` / `scores` shapes are the same as
        `GET /uploads/{id}/scores` — minus the runtime-injected
        `upload.billing` field, which is never snapshotted.
      x-rights:
        - view_talks
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
        -
          name: version
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Version snapshot.'
          content:
            application/json:
              schema: { type: object, required: [version, upload, summaries, scores], properties: { version: { type: object, properties: { version_id: { type: integer }, created_at: { type: string }, created_by: { nullable: true, type: object, properties: { id: { type: integer }, fullname: { type: string }, email: { type: string } } } } }, upload: { $ref: '#/components/schemas/UploadBasics' }, summaries: { type: array, items: { $ref: '#/components/schemas/UploadSummary' } }, scores: { $ref: '#/components/schemas/UploadScores' } } }
              example: { version: { version_id: 1, created_at: '2026-04-21 10:00:00', created_by: { id: 5, fullname: 'John Doe', email: john@acme.com } }, upload: { id: 12, originalFilename: call.wav, model: { id: 1, labels: { en_US: 'Default grid' } }, advisor: { id: 5, default_team_id: 10, fullname: 'John Doe', email: john@acme.com, firstname: John, lastname: Doe, locale: en_US, teamid: 10, teamname: 'Team A', teampath: '|Team A', team_path_id: '|10' }, creator: { id: 5, default_team_id: 10, fullname: 'John Doe', email: john@acme.com, firstname: John, lastname: Doe, locale: en_US, teamid: 10, teamname: 'Team A', teampath: '|Team A', team_path_id: '|10' }, ai_models: { analysis_region: eu, transcription: voxtral-mini-latest, summaries: ministral-14b-latest, assertions: ministral-14b-latest } }, summaries: [], scores: { categories: [{ category: { category_id: 5, labels: { en_US: "Advisor's Behavior" }, maxValues: 30, answerValues: 30, categoryScore: 100, source: generic, instance_id: null }, questions: [] }] } }
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          description: 'Upload or version not found.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        500:
          description: 'Invalid version content JSON.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/billing':
    get:
      tags:
        - Upload
      summary: 'Billing / cost detail for a single upload'
      description: |
        Returns the cost breakdown attributable to this upload. Used by billing
        admins to inspect a specific record when reconciling invoices.

        `billing.transcription`, `billing.summaries` and `billing.questions`
        are arrays of individual cost entries (one transcription call plus
        one entry per summary type, one per questions chunk). `billing.total`
        holds the per-feature totals and the `grandtotal`.
      x-rights:
        - manage_billing
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Upload-level billing breakdown.'
          content:
            application/json:
              schema: { type: object, required: [upload, billing], properties: { upload: { $ref: '#/components/schemas/UploadBasics' }, billing: { $ref: '#/components/schemas/UploadBillingFull' } } }
              example: { upload: { id: 3894, originalFilename: sample-call-2024-11-04.mp3, score: 100, advisor: { id: 203, default_team_id: 5, fullname: 'Alex Taylor', email: alex@acme.com, firstname: Alex, lastname: Taylor, locale: fr_FR, teamid: 5, teamname: Global, teampath: '|Global', team_path_id: '|5' }, creator: { id: 201, default_team_id: 5, fullname: 'John Smith', email: john@acme.com, firstname: John, lastname: Smith, locale: fr_FR, teamid: 5, teamname: Global, teampath: '|Global', team_path_id: '|5' }, model: { id: 308, labels: { fr_FR: '0-Alertes groupe' } }, ai_models: { analysis_region: eu, transcription: voxtral-mini-latest, summaries: ministral-14b-latest, assertions: ministral-14b-latest } }, billing: { transcription: [-0.2416666667], summaries: [-0.0148244444, -0.0148244444, -0.0148244444], questions: [-0.0326666667], total: { transcription: -0.2416666667, summaries: -0.0444733332, questions: -0.0326666667, grandtotal: -0.3419039999 } } }
        400:
          description: "Caller's team perimeter does not include this upload's user team, or viewer cannot see another advisor's upload."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          description: 'Upload not found.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/pdf':
    get:
      tags:
        - Upload
      summary: "Render the upload's transcript + metadata as PDF"
      description: |
        Produces a shareable PDF (transcript, metadata, scores) for offline
        review, email attachments or compliance archives.
      x-rights:
        - view_talks
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'PDF stream. `Content-Disposition: inline; filename="Raisetalk-Talk-<id>-export.pdf"`.'
          content:
            application/pdf:
              schema: { type: string, format: binary }
        400:
          description: "Caller's team perimeter does not include this upload's user team."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        404:
          description: 'Upload not found.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/attributes':
    get:
      tags:
        - Upload
      summary: 'Read custom attributes attached to an upload'
      description: |
        Return every custom attribute value attached to the upload. Attributes
        can be set manually by a reviewer or auto-populated by the AI pipeline.

        The response is an object **keyed by `attribute_id`** (not an array).
        For each attribute, `values` is keyed by value id and each value
        carries its own `disabled_at` and localized `labels`. `strings` and
        `numbers` carry the free-form string/numeric values attached to the
        upload (numbers are returned as strings — the DB trims trailing zeros
        before serialization).
      x-rights:
        - view_talks
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'Attributes map keyed by attribute id.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UploadAttributesMap' }
              example: { 56: { disabled_at: null, labels: { fr_FR: 'N° de ticket' }, colorScheme: grey, auto_attribute: 0, values: [], strings: [e8b8d42c4a5f4543a5c12825a5fb3682], numbers: [] }, 103: { disabled_at: null, labels: { fr_FR: Canal }, colorScheme: blue, auto_attribute: 0, values: { 203: { disabled_at: null, labels: { fr_FR: 'Téléphone sortant', en_GB: '', de_DE: '' } } }, strings: [], numbers: [] } }
        400:
          description: "Caller's team perimeter does not include this upload's user team, or viewer cannot see another advisor's upload."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          description: 'Upload not found.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
    patch:
      tags:
        - Upload
      summary: 'Update custom attributes on an upload'
      description: |
        Manually set or override attribute values on an upload — used when a
        reviewer wants to correct or enrich what the AI pipeline inferred.

        The body is an **array** of updates; each item targets one attribute and
        either adds or removes a set of values. A pre-modification snapshot is
        saved as a new version before any change is applied.
      x-rights:
        - manage_talks
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items: { type: object, required: [attribute, values, action], properties: { attribute: { type: integer, description: 'Attribute id.' }, values: { type: array, description: 'Value ids (integers) or free-form strings/numbers, depending on the attribute type (`list`, `string`, `number`).', items: { oneOf: [{ type: integer }, { type: string }, { type: number }] } }, action: { type: string, enum: [add, remove] } } }
            example:
              - { attribute: 1, values: [101, 102], action: add }
              - { attribute: 2, values: [keyword], action: remove }
      responses:
        200:
          description: 'Attributes updated. Returns a per-attribute patch log and the refreshed attributes map (same shape as `GET /uploads/{id}/attributes`).'
          content:
            application/json:
              schema: { type: object, required: [patchInfo, uptodateAttributes], properties: { patchInfo: { type: object, description: 'Object keyed by attribute_id, then by value (id or string). Value is a status message: `Value set.`, `Value removed.`, `Value not found.`, `Value should be numeric.`, `Value should be less than 256 characters.`, or a `Failed to add value.` message.' }, uptodateAttributes: { $ref: '#/components/schemas/UploadAttributesMap' } } }
              example: { patchInfo: { 1: { 101: 'Value set.', 102: 'Value set.' }, 2: { keyword: 'Value removed.' } }, uptodateAttributes: { 1: { disabled_at: null, labels: { en_US: Region }, colorScheme: '#FF0000', auto_attribute: 0, values: { 101: { disabled_at: null, labels: { en_US: EMEA } }, 102: { disabled_at: null, labels: { en_US: APAC } } }, strings: [], numbers: [] } } }
        400:
          description: "Caller's team perimeter does not include this upload's user team."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          description: 'Validation error, upload not found, database error, or versioning failure.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/user':
    patch:
      tags:
        - Upload
      summary: 'Reassign (or detach) the owner user of an upload'
      description: |
        Re-attribute an upload to a different user — typically used when the
        original owner leaves the team or when the recording was imported under
        the wrong account.

        Send an empty body `{}` to **detach** the upload from any user. To
        **attach**, send both `user_type` and `user_value`. A pre-modification
        snapshot is saved as a new version before the change is applied. The
        response returns the refreshed upload basics (same shape as
        `scores.upload`, minus the runtime-injected `billing` field).
      x-rights:
        - manage_talks
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties: { user_type: { type: string, enum: [id, email, identifier], description: 'Lookup kind. Required together with `user_value`.' }, user_value: { type: string, description: 'Lookup value matching `user_type`. Required together with `user_type`.' } }
            examples:
              attach: { summary: 'Attach upload to a user by email', value: { user_type: email, user_value: john@acme.com } }
              detach: { summary: 'Detach the current owner', value: [] }
      responses:
        200:
          description: 'Owner updated. Returns the refreshed upload basics.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UploadBasics' }
        400:
          description: 'Validation error, unknown user, or visibility check failure.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        404:
          $ref: '#/components/responses/NotFound'
        500:
          description: 'Upload not found, database error, or versioning failure.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  '/uploads/{id}/criteria':
    patch:
      tags:
        - Upload
      summary: 'Update evaluation criteria results for an upload'
      description: |
        Records a reviewer's scores (and mandatory comments) against specific
        evaluation criteria. Used during manual review and calibration sessions
        where a human overrides or confirms the AI's grading.

        A pre-modification snapshot is saved as a new version before any change
        is applied.
      x-rights:
        - evaluate_talk
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [criteria]
              properties: { criteria: { type: array, minItems: 1, items: { type: object, required: [question_id, answer_value, comment], properties: { question_id: { type: integer, minimum: 1 }, answer_value: { type: integer, minimum: 0 }, comment: { type: string, maxLength: 5000 } } } } }
            example:
              criteria: [{ question_id: 101, answer_value: 8, comment: 'Clear explanation of the billing issue.' }, { question_id: 102, answer_value: 5, comment: 'Could have paraphrased more often.' }]
      responses:
        200:
          description: 'Criteria updated. Returns a patch summary and the refreshed scores (same shape as `scores.scores` in `GET /uploads/{id}/scores`).'
          content:
            application/json:
              schema: { type: object, required: [patchInfo, uptodateScores], properties: { patchInfo: { type: object, required: [success, message, updated_criteria], properties: { success: { type: boolean }, message: { type: string }, updated_criteria: { type: array, items: { type: integer } } } }, uptodateScores: { $ref: '#/components/schemas/UploadScores' } } }
              example: { patchInfo: { success: true, message: '2 criteria updated successfully', updated_criteria: [101, 102] }, uptodateScores: { categories: [{ category: { category_id: 5, labels: { en_US: "Advisor's Behavior" }, maxValues: 30, answerValues: 25, categoryScore: 83.33, source: generic, instance_id: null }, questions: { 101: { question: { question_id: 101, maxValue: 10, minValue: 0, score: 8, scoreRate: 0, category: 5, type: options, source: generic, instance_id: null, options: '[10, 0, null]', localisation: { en_US: { label: Clarity, question: 'Clear explanation?' } } }, answers: { provide_score: { answer_value: 8, format: '1', format_key: provide_score } }, timestamps: [] } } }] } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        404:
          description: 'Upload not found.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        500:
          description: 'Database error, versioning failure, or criteria update failure.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
  /users/list:
    get:
      tags:
        - Users
      summary: 'List users accessible to the caller'
      description: |
        Powers the admin console's user-management table. Regular admins see the
        users of their own instance; a partner-instance admin sees users across
        every instance in its scope.
      x-rights:
        - manage_users
        - view_users
      parameters:
        -
          name: fullname
          in: query
          schema:
            type: string
          description: 'LIKE filter on `firstname lastname`.'
        -
          name: status
          in: query
          schema:
            type: integer
            enum:
              - 0
              - 1
          description: '0 = inactive, 1 = active.'
        -
          name: instance
          in: query
          schema:
            type: integer
          description: "Target instance id. Requires `manage_instances` and the instance to be within the caller's accessible scope; otherwise the caller's own instance is used."
        -
          name: orderby
          in: query
          schema:
            type: string
            enum:
              - firstname
              - lastname
              - fullname
              - id
              - created_at
            default: fullname
        -
          name: orderdirection
          in: query
          schema:
            type: string
            enum:
              - ASC
              - DESC
            default: ASC
        -
          name: page
          in: query
          schema:
            type: integer
            minimum: 1
            default: 1
        -
          name: show
          in: query
          schema:
            type: integer
            enum:
              - 10
              - 50
              - 100
          description: 'Items per page. Server default is `PAGINATION_DEFAULT_LIMIT` (env).'
        -
          name: display
          in: query
          schema:
            type: string
            enum:
              - light
              - default
              - full
            default: default
          description: 'Controls the richness of the `results[]` shape. See `UsersListResultLight` / `UsersListResultDefault` / `UsersListResultFull`.'
      responses:
        200:
          description: |
            Paginated list. The shape of each `results[]` entry depends on the `display`
            query param — lighter displays omit most fields.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UsersListResponse' }
              example: { show: 50, total: 2, page: 1, pages: 1, results: [{ id: 5, email: john@acme.com, firstname: John, lastname: Doe, status: 1, role: 2, locale: fr_FR, default_team_id: 3, role_key: admin, created_at: '2026-04-21 10:00:00' }] }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  /user/self:
    get:
      tags:
        - Users
      summary: "Return the authenticated user's profile"
      description: |
        Called on first page load to bootstrap the authenticated UI — hands back
        identity, rights, default team and locale preference so the client can
        route the user to the right landing page and apply their permissions.
      responses:
        200:
          description: 'Own profile (includes recent connection attempts).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserSelf' }
              example: { id: 5, instance_id: 42, firstname: John, lastname: Doe, fullname: 'John Doe', created_at: '2026-04-21 10:00:00', status: 1, role: 2, locale: fr_FR, email: john@acme.com, instance_name: Acme, instance_status: 1, default_team_id: 3, default_team_name: 'Team A', role_key: admin, identifiers: [{ tierapp: null, identifier: null }], connections_attempts: [{ success: 1, created_at: '2026-04-21 11:00:00', remote_os: 'Mac OS X', remote_ip: 192.168.1.1 }] }
  /user/create:
    post:
      tags:
        - Users
      summary: 'Create a user'
      description: |
        Invite a new teammate into an instance. When `sendWelcomeEmail=1` an
        activation email is sent so the user can set their own password via
        `PUT /user/password`.

        The new user is created in the caller's own instance. `role` must exist
        in that instance and `default_team_id` must be one of the caller's
        visible teams.
      x-rights:
        - manage_users
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserCreate'
      responses:
        201:
          description: 'User created (status 0 until the activation email link is consumed).'
          content:
            application/json:
              schema: { allOf: [{ $ref: '#/components/schemas/Success' }, { type: object, properties: { data: { $ref: '#/components/schemas/UserWithSentFlag' } } }] }
              example: { result: success, data: { id: 6, instance_id: 42, firstname: Jane, lastname: Smith, fullname: 'Jane Smith', created_at: '2026-04-21 10:00:00', status: 0, role: 3, locale: fr_FR, email: jane@acme.com, instance_name: Acme, instance_status: 1, default_team_id: 3, default_team_name: 'Team A', role_key: viewer, identifiers: [{ tierapp: sales, identifier: J001 }], sentWelcomeEmail: 1 } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
  /user/password:
    put:
      tags:
        - Users
      summary: 'Set a password from an activation or reset token (public)'
      description: |
        Final step of the signup / forgotten-password flow — the user lands on the
        "set your password" page from an email link and submits their chosen
        password here.

        Consumes a single-use token sent by email (signup activation or password reset).
        On success the token is deleted and the user is activated (`status=1`).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [password, activationToken]
              additionalProperties: false
              properties: { password: { type: string, format: password, minLength: 8, maxLength: 120, description: "Must contain at least one lowercase letter, one uppercase\nletter, one digit, and one special character from `@$!%*?&#-_=+`.\n" }, activationToken: { type: string, minLength: 64, maxLength: 64, description: 'Exactly 64-char token from the email link (type `setPassword`).' } }
      responses:
        200:
          description: 'Password updated.'
          content:
            application/json:
              schema: { type: object, required: [result, message], properties: { result: { type: string, enum: [success] }, message: { type: string } } }
              example: { result: success, message: 'Password updated.' }
        400:
          $ref: '#/components/responses/ValidationError'
        401:
          description: 'Invalid or expired activation token, or instance inactive.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
              example: { result: error, message: 'Unexpected error #1a4c0e99', error: 'Invalid token provided' }
        500:
          $ref: '#/components/responses/ServerError'
  /user/resetPassword:
    post:
      tags:
        - Users
      summary: 'Request a password-reset email (public)'
      description: |
        Triggered from the "forgot password" link on the login page. Emails a
        one-time reset URL to the supplied address.

        Always returns `202` whether the email is known or not (no-op silently
        on unknown addresses or inactive instances, to avoid leaking account
        existence).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              additionalProperties: false
              properties: { email: { type: string, format: email } }
      responses:
        202:
          description: 'Accepted. Empty body. If the email matches an active account, a reset link is sent.'
        400:
          $ref: '#/components/responses/ValidationError'
        500:
          $ref: '#/components/responses/ServerError'
  '/user/{id}':
    get:
      tags:
        - Users
      summary: 'Read a user'
      description: |
        Admin detail view of a specific user — includes extras not present on
        the list view (upload count, recent connection attempts).

        The target must be in the caller's instance and in one of the caller's
        visible teams, otherwise `403` is returned.
      x-rights:
        - manage_users
        - view_users
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        200:
          description: 'User detail (includes `talks` count and `connections_attempts`).'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserDetail' }
              example: { id: 5, instance_id: 42, firstname: John, lastname: Doe, fullname: 'John Doe', created_at: '2026-04-21 10:00:00', status: 1, role: 2, locale: fr_FR, email: john@acme.com, instance_name: Acme, default_team_id: 3, default_team_name: 'Team A', role_key: admin, talks: 42, identifiers: [{ tierapp: sales, identifier: J001 }], connections_attempts: [{ success: 1, created_at: '2026-04-21 11:00:00', remote_os: 'Mac OS X', remote_ip: 192.168.1.1 }] }
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
    put:
      tags:
        - Users
      summary: 'Update a user'
      description: |
        Used by admins to change profile fields, promote/demote roles, switch
        the default team, deactivate accounts, or (with `manage_instances`) move
        a user to another instance.

        All body fields are optional; any unknown field is rejected. If the
        target user is not found or not in the caller's instance, the server
        currently returns `400` with a validation-style error rather than `404`.
      x-rights:
        - manage_users
      parameters:
        -
          name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserUpdate'
      responses:
        200:
          description: 'Updated. `data` is the re-read user row.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserUpdateResponse' }
              example: { result: success, id: 'User updated', data: { id: 5, instance_id: 42, firstname: John, lastname: Doe, fullname: 'John Doe', created_at: '2026-04-21 10:00:00', status: 1, role: 2, locale: fr_FR, email: john@acme.com, instance_name: Acme, default_team_id: 3, default_team_name: 'Team A', role_key: admin, talks: 42, identifiers: [{ tierapp: sales, identifier: J001 }] } }
        400:
          $ref: '#/components/responses/ValidationError'
        403:
          $ref: '#/components/responses/Forbidden'
        500:
          $ref: '#/components/responses/ServerError'
