openapi: 3.1.0
info:
  title: Prosperian API
  version: "1.1.0"
  description: |
    API HTTP publique de Prosperian.

    Permet de piloter le flux outbound — sourcer des prospects, créer une séquence,
    y attacher une liste, puis l'activer — depuis n'importe quel client HTTP.

    Authentification : clé API par organisation (`psk_live_…`) envoyée dans le header
    `X-API-Key`. Générez une clé dans l'app : **Paramètres → API et développeurs**
    (affichée une seule fois).

    Cette même surface est exposée comme serveur MCP, en Streamable HTTP hébergé sur
    `https://app.prosperian.co/api/mcp`
    (`find_leads → create_campaign → attach_prospects → start_sequence`). Le CLI npm
    `@prosperian/mcp` n'a jamais été publié : l'endpoint hébergé est la seule installation
    valide. Mode d'emploi : https://app.prosperian.co/docs/mcp
  contact:
    name: Prosperian
    url: https://app.prosperian.co

servers:
  - url: https://app.prosperian.co
    description: Production
  - url: "{baseUrl}"
    description: Environnement personnalisé (PROSPERIAN_BASE_URL)
    variables:
      baseUrl:
        default: https://app.prosperian.co

security:
  - ApiKeyAuth: []

tags:
  - name: Sourcing
    description: Recherche et import de prospects
  - name: Campagnes
    description: Création et pilotage des séquences outbound
  - name: Enrichissement
    description: |
      Enrichissement email / téléphone de contacts et de listes. Consomme des
      crédits (email ≈ 1, téléphone ≈ 10 par contact résolu). Pour une clé API,
      `organizationId` est optionnel : il est déduit de la clé.

paths:
  /api/sourcing/prospects:
    post:
      tags: [Sourcing]
      operationId: launchProspectSearch
      summary: Lancer une recherche de prospects
      description: |
        Déclenche une recherche de prospects à partir d'une description en langage
        naturel et de filtres optionnels. La recherche s'exécute en asynchrone côté
        sourcing ; les prospects trouvés alimentent une liste portant le nom `listName`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SourcingRequest"
            examples:
              fintech:
                value:
                  criteria:
                    description: "Fondateurs de fintech à Londres"
                    industry: "Financial Services"
                    location: "London, United Kingdom"
                  listName: "Fintech London"
      responses:
        "200":
          description: Recherche lancée
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SourcingResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/ServerError"

  /api/copilot/create-campaign:
    post:
      tags: [Campagnes]
      operationId: createCampaign
      summary: Créer une séquence (campagne)
      description: |
        Crée une séquence outbound avec une suite d'étapes ordonnées. La séquence est
        créée au statut `draft` ; utilisez `update-status` pour l'activer.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateCampaignRequest"
            examples:
              cold-email-2-steps:
                value:
                  name: "Cold email fintech"
                  sequence:
                    - type: "mail_send"
                      subject: "Quick question"
                      message: "Bonjour {{first_name}}, ..."
                    - type: "logic_wait"
                      delay: 3
                    - type: "mail_send"
                      subject: "Re: Quick question"
                      message: "Petit relance ..."
      responses:
        "200":
          description: Séquence créée
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreateCampaignResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "500":
          $ref: "#/components/responses/ServerError"

  /api/sequences/attach-prospects:
    post:
      tags: [Campagnes]
      operationId: attachProspects
      summary: Attacher une liste de prospects à une séquence
      description: |
        Attache tous les prospects d'une liste à une séquence : déduplique, crée les
        prospects manquants, génère les tâches d'outreach et synchronise vers le système
        d'envoi. Les prospects présents dans la blacklist de l'organisation sont ignorés.

        Opération potentiellement longue (jusqu'à 300 s pour de grandes listes).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AttachProspectsRequest"
            examples:
              attach:
                value:
                  sequence_id: "a1b2c3d4-0000-0000-0000-000000000000"
                  prospect_list_id: "e5f6a7b8-0000-0000-0000-000000000000"
      responses:
        "200":
          description: >
            Prospects attachés. Les CINQ portes de sortie en 200 portent `motif_enrolement`
            (subset vide, liste vide ou introuvable, tout blacklisté, tout déjà en campagne,
            tout sous verrou 90 j, plus le chemin nominal).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AttachProspectsResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "502":
          description: >
            Les leads ont bien été RATTACHÉS, mais l'ordre d'enrôlement n'a pas pu être transmis
            au moteur d'envoi : personne ne recevra rien. `motif_enrolement.kind` vaut
            `envoi_impossible`. ⚠️ `added`, `count` et `tasksCreated` restent renseignés — le
            rattachement, lui, a bien eu lieu. Rejouable tel quel : la création de tâches est
            dédupliquée, relancer ne double aucune ligne.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      success:
                        type: boolean
                        description: Toujours `false` sur cette sortie.
                      added: { type: integer }
                      count: { type: integer }
                      tasksCreated: { type: integer }
                      motif_enrolement:
                        $ref: "#/components/schemas/MotifEnrolement"
        "503":
          description: >
            La liste noire n'a pas pu être lue. AUCUN lead n'a été rattaché et aucune tâche
            créée : continuer sans elle risquerait de recontacter des personnes qui ont demandé
            à ne plus l'être. `motif_enrolement.kind` vaut `liste_noire_illisible`. Réessayez.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      added:
                        type: integer
                        description: Toujours `0` sur cette sortie.
                      count:
                        type: integer
                        description: Toujours `0` sur cette sortie.
                      motif_enrolement:
                        $ref: "#/components/schemas/MotifEnrolement"
        "500":
          $ref: "#/components/responses/ServerError"

  /api/sequences/update-status:
    post:
      tags: [Campagnes]
      operationId: updateSequenceStatus
      summary: Activer ou mettre en pause une séquence
      description: |
        Passe une séquence en `active` (démarre les envois) ou en `paused` (les arrête)
        et synchronise l'état vers le système d'outreach.

        Pré-requis pour l'activation : un abonnement outreach actif et au moins un
        profil expéditeur configuré pour l'organisation.

        Opération potentiellement longue (jusqu'à 300 s) : l'activation lit la liste
        cible en PAGINANT (jusqu'à 20 000 lignes), charge la liste noire paginée, puis
        émet les ordres d'enrôlement par lots.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateStatusRequest"
            examples:
              activate:
                value:
                  sequence_id: "a1b2c3d4-0000-0000-0000-000000000000"
                  status: "active"
      responses:
        "200":
          description: >
            Statut mis à jour. Sur le chemin NATIF, la réponse porte `motif_enrolement` et
            `enroll_items` — POURQUOI ce nombre de leads a été enrôlé, et pas un autre.

            ⚠️ UN 200 PEUT PORTER UNE PERTE NOMMÉE, et un appelant machine qui ne lit que le code
            HTTP rapportera « campagne lancée » à tort. `non_confies > 0` = ces prospects ne
            seront enrôlés par personne sans relance manuelle ; un échec d'émission du reliquat ne
            déclenche AUCUN rollback, délibérément — N enrôlements valides ne se détruisent pas
            pour une panne de file. Lire `motif_enrolement`, jamais le seul code HTTP.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UpdateStatusResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: >
            Conflit. **TROIS émetteurs distincts, et c'est le `code` qui décide de la conduite
            à tenir — jamais le statut HTTP.**

            ⚠️ Réessayer aveuglément sur `no_healthy_sending_account` fait boucler à l'infini sur un
            état que seul un humain répare. Lisez le `code` avant de décider de la conduite à tenir.

            **`activation_deja_en_cours` — REJOUABLE.** Une autre activation de la même séquence
            tourne. Le bail est pris AVANT toute lecture et toute écriture : ni le statut ni un
            enrôlement n'ont bougé. Réessayer est sûr et suffit ; le bail expire de lui-même.
            ⚠️ Ne PAS marquer l'opération « échouée » : une collision de quelques secondes n'est pas
            un échec, et la traiter comme tel coûte une nouvelle approbation humaine.
            N'apparaît que sur `status: "active"` ET sur le chemin NATIF — une PAUSE ne prend pas
            de bail, et refuser un arrêt d'urgence serait le pire moment pour refuser.

            **`organisation_en_migration` — REJOUABLE PLUS TARD.** L'organisation est gelée le
            temps d'une force-migration. Testé AVANT le branchement sur `status`, donc atteignable
            sur une PAUSE aussi.

            **`bail_illisible` — NON REJOUABLE.** Le verrou d'activation n'a pas pu être LU.
            Rien n'a été activé. Réessayer ne répare rien : c'est un défaut d'infrastructure côté
            serveur, pas un état que l'appelant peut débloquer — signalez-le. ⚠️ Ne PAS le confondre avec
            `activation_deja_en_cours` — les deux sortent du même refus de bail, mais l'un dure
            quelques secondes et l'autre est permanent.

            **`no_healthy_sending_account` — NON REJOUABLE.** Aucune boîte d'envoi connectée :
            réessayer ne changera rien tant qu'un humain n'en aura pas connecté une. Le corps
            nomme les canaux attendus par la séquence.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                deja-en-cours:
                  summary: Conflit de bail — réessayer
                  value:
                    error: "Une activation de cette séquence est déjà en cours"
                    code: "activation_deja_en_cours"
                organisation-gelee:
                  summary: Force-migration en cours — réessayer plus tard
                  value:
                    error: "Organisation en cours de migration : action temporairement indisponible"
                    code: "organisation_en_migration"
                aucun-expediteur:
                  summary: Aucune boîte connectée — NE PAS réessayer
                  value:
                    error: "Aucune boîte d'envoi connectée. Connectez une boîte dans Paramètres > Comptes de prospection…"
                    code: "no_healthy_sending_account"
        "500":
          description: >
            Erreur interne. ⚠️ Recouvre DEUX cas distincts. (1) Panne d'enrôlement — trois
            motifs : `envoi_impossible`, `lecture_impossible`, `liste_noire_illisible`. La
            séquence a alors été RENDUE à son statut d'avant et les enrôlements réveillés par
            l'appel ont été rendormis : rien n'a été activé, rien n'a été envoyé, l'appel est
            atomique du point de vue du client et se REJOUE tel quel. Le corps porte
            `ok: false`, `status` (le statut restauré), `enroll_items: 0` et
            `motif_enrolement`. (2) Erreur inattendue, sans ces champs.

            ⚠️ Une activation qui n'enrôle personne pour une raison NORMALE (brouillon sans
            canal, liste vide, tout filtré) reste un 200 : y rendre un 5xx déclencherait des
            tempêtes de reprise sur le geste le plus courant du produit. Lisez
            `motif_enrolement`, pas seulement le code HTTP.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Error"
                  - type: object
                    properties:
                      ok:
                        type: boolean
                        description: Toujours `false` sur cette sortie.
                      status:
                        type: string
                        description: Le statut RESTAURÉ (celui d'avant l'appel), pas `active`.
                      provider: { type: string }
                      native: { type: boolean }
                      enroll_items:
                        type: integer
                        description: Toujours `0` sur cette sortie.
                      motif_enrolement:
                        $ref: "#/components/schemas/MotifEnrolement"

  /api/enrichment/manual:
    post:
      tags: [Enrichissement]
      operationId: enrichContact
      summary: Créer et enrichir un contact
      description: |
        Crée un contact (dans la liste « Enrichissements Manuels ») à partir de son
        nom et de sa société, puis l'enrichit (email et/ou téléphone). Renvoie les
        données résolues immédiatement ou `pending: true` si la résolution est asynchrone.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrichContactRequest"
            examples:
              email:
                value:
                  first_name: "Jane"
                  last_name: "Doe"
                  company_domain: "stripe.com"
                  enrichmentTypes: { email: true, phone: false }
      responses:
        "200":
          description: Contact enrichi (ou enrichissement lancé)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrichContactResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/enrichment/single:
    post:
      tags: [Enrichissement]
      operationId: enrichProspect
      summary: Enrichir un prospect existant
      description: |
        Enrichit un prospect existant (`prospectId`) sur un seul canal (`email` ou
        `phone`). Le prospect doit être rattaché à au moins une ligne de liste.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrichProspectRequest"
      responses:
        "200":
          description: Donnée enrichie (ou enrichissement lancé)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrichItemResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/enrichment/prospect-list-item:
    post:
      tags: [Enrichissement]
      operationId: enrichListItem
      summary: Enrichir un item de liste
      description: Enrichit une ligne de liste précise (`itemId`) sur un canal (`email` ou `phone`).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrichListItemRequest"
      responses:
        "200":
          description: Donnée enrichie (ou enrichissement lancé)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrichItemResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/enrichment/pronto:
    post:
      tags: [Enrichissement]
      operationId: enrichList
      summary: Enrichir une liste de prospects
      description: |
        Enrichit l'ensemble des prospects d'une liste existante (`prospectListId`).
        Stratégie inline avec repli asynchrone ; les lignes restées en attente se
        réconcilient via `sync-pending-list`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrichByListIdRequest"
      responses:
        "200":
          description: Enrichissement terminé / lancé
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrichListResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/LockBusy" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/enrichment/csv:
    post:
      tags: [Enrichissement]
      operationId: enrichCsv
      summary: Enrichir une liste fournie (CSV / bulk)
      description: |
        Crée une liste à partir des prospects fournis (≤ 1000) puis lance leur
        enrichissement de façon asynchrone. Suivre l'avancement via `sync-pending-list`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/EnrichCsvRequest"
      responses:
        "200":
          description: Enrichissement lancé
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/EnrichListResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/InsufficientCredits" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/LockBusy" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/enrichment/list-coverage:
    get:
      tags: [Enrichissement]
      operationId: listCoverage
      summary: Couverture LinkedIn / domaine d'une liste
      description: |
        Renvoie le nombre de prospects disposant d'un signal exploitable (URL LinkedIn
        ou domaine) — indicateur de la qualité d'enrichissement email atteignable.
      parameters:
        - name: prospectListId
          in: query
          required: true
          schema: { type: string }
        - name: organizationId
          in: query
          required: false
          description: Optionnel pour une clé API (déduit de la clé).
          schema: { type: string }
      responses:
        "200":
          description: Statistiques de couverture
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListCoverageResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/enrichment/sync-pending-item:
    post:
      tags: [Enrichissement]
      operationId: syncPendingItem
      summary: Réconcilier un item en attente
      description: |
        Tente de récupérer le résultat d'enrichissement d'une ligne encore en attente
        (filet de sécurité si le webhook fournisseur est manqué).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PendingItemRequest"
      responses:
        "200":
          description: État courant de la ligne
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PendingItemResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/enrichment/pull-list-item:
    post:
      tags: [Enrichissement]
      operationId: pullListItem
      summary: Forcer le polling Pronto d'un item
      description: |
        Interroge Pronto pour une ligne précise afin de débloquer un enrichissement
        resté en attente. Idempotent.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PendingItemRequest"
      responses:
        "200":
          description: Résultat du polling
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PullListItemResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/ServerError" }

  /api/enrichment/sync-pending-list:
    post:
      tags: [Enrichissement]
      operationId: syncPendingList
      summary: Réconcilier les items en attente d'une liste
      description: |
        Parcourt les lignes encore en attente d'une liste et récupère les résultats
        d'enrichissement disponibles. Renvoie le nombre de lignes encore en attente.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PendingListRequest"
      responses:
        "200":
          description: Nombre de lignes encore en attente
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PendingListResponse"
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "500": { $ref: "#/components/responses/ServerError" }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Clé API par organisation, préfixée `psk_live_`.

  responses:
    BadRequest:
      description: Requête invalide (champ requis manquant ou statut non supporté)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "Critères de recherche requis"
    Unauthorized:
      description: Clé API absente ou invalide
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "Clé API invalide"
    Forbidden:
      description: La clé n'est pas autorisée pour l'organisation visée
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "Clé API non autorisée pour cette organisation"
    ServerError:
      description: Erreur interne
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "Erreur lors du lancement de la recherche de prospects"
            details: "Erreur inconnue"
    NotFound:
      description: Ressource introuvable (prospect, item ou liste)
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "Liste introuvable"
    InsufficientCredits:
      description: Crédits d'enrichissement insuffisants
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/InsufficientCreditsError"
          example:
            error: "Crédits insuffisants"
            required: 30
            available: 12
    LockBusy:
      description: Un enrichissement est déjà en cours pour l'organisation
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: "Un enrichissement est déjà en cours. Veuillez attendre sa fin."

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
          description: Message d'erreur lisible.
        details:
          type: string
          description: Détail technique optionnel (présent sur certaines erreurs 500).
        code:
          type: string
          description: >
            Discriminant MACHINE de l'erreur, quand le code HTTP seul ne suffit pas à décider
            quoi faire. Le message d'`error` est destiné à un humain et peut changer ; `code`
            est stable et c'est LUI qu'on teste.

            ⚠️ ENSEMBLE OUVERT, DÉLIBÉRÉMENT — ne le fermez pas dans un client généré.
            `Error` est le schéma PARTAGÉ de toutes les routes : l'énumérer sur un seul code
            rendrait INVALIDES des réponses déjà documentées et déjà
            servies (`approval_required` en 403, `subscription_expired` en 402,
            `pause_partially_applied` en 500…), et cassait tout client généré. Un `code` inconnu
            doit être traité comme un échec de sa FAMILLE HTTP, jamais rejeté.

            Émetteurs connus au moment de l'écriture — la liste informe, elle ne contraint pas :

            | `code` | HTTP | Rejouable tel quel ? |
            |---|---|---|
            | `activation_deja_en_cours` | 409 | **oui** — conflit de bail, rien n'a été écrit |
            | `organisation_en_migration` | 409 | **oui** — gel temporaire de l'organisation |
            | `bail_illisible` | 409 | **non** — le verrou est illisible, défaut d'infra |
            | `no_healthy_sending_account` | 409 | **non** — aucune boîte connectée, geste humain |
            | `approval_required` | 403 | **non** — une validation humaine est attendue |
            | `subscription_expired` | 402 | **non** |
            | `unsupported_branch_filter` | 422 | **non** |
            | `unsupported_linkedin_blocks` | 422 | **non** |
            | `sender_selection_missing_channel` | 422 | **non** |
            | `pause_partially_applied` | 500 | **oui** |
      required: [error]

    SourcingCriteria:
      type: object
      properties:
        description:
          type: string
          description: Description en langage naturel des prospects recherchés.
        industry:
          type: [string, "null"]
        companySize:
          type: [string, "null"]
        location:
          type: [string, "null"]
        revenue:
          type: [string, "null"]
        technologies:
          type: [string, "null"]
        keywords:
          type: [string, "null"]
      required: [description]

    SourcingRequest:
      type: object
      properties:
        criteria:
          $ref: "#/components/schemas/SourcingCriteria"
        listName:
          type: string
          description: Nom de la liste de prospects créée. Auto-généré si absent.
        userContext:
          type: object
          description: Contexte optionnel. Si `organizationId` est fourni, il doit correspondre à l'organisation de la clé.
          properties:
            organizationId:
              type: string
            userId:
              type: string
            currentGoals:
              type: string
      required: [criteria]

    SourcingResponse:
      type: object
      properties:
        success:
          type: boolean
        message:
          type: string
        webhookResponse:
          description: Réponse brute du moteur de sourcing (forme variable).
        criteria:
          type: string
          description: La description recherchée.
        listName:
          type: string
          description: Nom final de la liste.

    CampaignStep:
      type: object
      properties:
        type:
          type: string
          description: Type d'étape, ex. `mail_send`, `linkedin_message`, `logic_wait`.
        message:
          type: string
        subject:
          type: string
        delay:
          type: number
          description: Délai avant l'étape (en jours) pour les étapes d'attente.
      required: [type]

    CreateCampaignRequest:
      type: object
      properties:
        name:
          type: string
          description: Nom de la campagne.
        sequence:
          type: array
          description: Étapes ordonnées de la séquence.
          items:
            $ref: "#/components/schemas/CampaignStep"
        description:
          type: string
        organization_id:
          type: string
          description: Doit correspondre à l'organisation de la clé si fourni.
      required: [name]

    CreateCampaignResponse:
      type: object
      properties:
        success:
          type: boolean
        campaignId:
          type: string
          description: UUID de la séquence créée.
        stepsCount:
          type: integer
        outreachSynced:
          type: boolean
        outreachSkipped:
          type: [string, "null"]
        outreachError:
          type: [string, "null"]
        message:
          type: string

    AttachProspectsRequest:
      type: object
      properties:
        sequence_id:
          type: string
        prospect_list_id:
          type: string
      required: [sequence_id, prospect_list_id]

    AttachProspectsResponse:
      type: object
      properties:
        success:
          type: boolean
        added:
          type: integer
          description: Prospects ajoutés à la séquence.
        tasksCreated:
          type: integer
        attachedInThisCall:
          type: integer
          description: >
            Volume RATTACHÉ par CET appel. Ce n'est PAS le total de la séquence :
            ce total est recalculé séparément, depuis les enrôlements réels.
        totalInSequence:
          type: integer
          deprecated: true
          description: >
            ⚠️ DÉPRÉCIÉ — utilisez `attachedInThisCall`. Ce champ ne vaut plus le total de la
            séquence ; il est conservé comme alias parce que des appelants externes peuvent
            encore le lire.
        blacklisted:
          type: integer
          description: Prospects ignorés car présents dans la blacklist.
        locked_skipped:
          type: integer
          description: >
            Leads écartés parce que sortis d'une campagne il y a moins de 90 jours (verrou
            d'anti-relance). Comme `blacklisted` et `enrolled_skipped`, c'est une exclusion
            AMONT du filtre : elle n'entre pas dans `motif_enrolement.resolus`.
        enrolled_skipped:
          type: integer
          description: >
            Prospects déjà enrôlés dans une AUTRE séquence de l'organisation, non réassociés
            (filtre opt-in `exclude_enrolled`). Distinct de `motif_enrolement.ventilation.deja_demarche`,
            qui compte l'anti-relance sur des campagnes passées.
        motif_enrolement:
          $ref: '#/components/schemas/MotifEnrolement'
        blacklist_skips:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              label: { type: string }
              reason: { type: string }
        prospect_errors:
          type: array
          items:
            type: string
        outreach:
          type: object
          description: >
            Présent seulement sur le chemin NATIF. ⚠️ Ce bloc documentait `pushed`, `skipped`,
            `errors` et `customFieldErrors` — quatre champs que le code n'émet jamais — et
            aucun des trois qu'il émet réellement.
          properties:
            native: { type: boolean }
            provider: { type: string }
            enrolled:
              type: integer
              description: >
                Identique à `motif_enrolement.enroles` : lignes RÉELLEMENT INSÉRÉES — un fait
                accompli, pas un volume simplement soumis.
        ai_import:
          type: object
          properties:
            ran: { type: boolean }
            keys:
              type: array
              items: { type: string }
            generated: { type: integer }
            skipped_existing: { type: integer }
            errors:
              type: array
              items: { type: string }
            openai_not_configured: { type: boolean }

    UpdateStatusRequest:
      type: object
      properties:
        sequence_id:
          type: string
        status:
          type: string
          enum: [active, paused]
      required: [sequence_id, status]

    UpdateStatusResponse:
      type: object
      properties:
        ok:
          type: boolean
        status:
          type: string
          enum: [active, paused]
        native:
          type: boolean
          description: Présent sur le chemin NATIF. Toujours `true` quand il est émis.
        provider:
          type: string
          enum: [unipile, smartlead]
          description: Provider natif résolu pour la séquence. Chemin NATIF seulement.
        getsales_synced:
          type: boolean
          deprecated: true
          description: >
            ⚠️ N'EST PLUS ÉMIS. Les colonnes GetSales correspondantes ont été supprimées ;
            aucun chemin du code ne pose ce champ. Conservé ici le temps que les clients
            générés cessent de le lire.
        resynced_flow_uuid:
          type: string
          deprecated: true
          description: ⚠️ N'EST PLUS ÉMIS — même raison que `getsales_synced`.
        post_engager_ingestion:
          type: object
          description: Résultat de l'ingestion des signaux post-engagers si applicable.
        enroll_items:
          type: integer
          description: >
            Lignes RÉELLEMENT INSÉRÉES en base par cet appel (0 sur toute sortie non-`filtre`) —
            identique à `motif_enrolement.enroles`. Un fait accompli, pas un volume soumis.
        motif_enrolement:
          $ref: '#/components/schemas/MotifEnrolement'

    MotifEnrolement:
      type: object
      # Seul `kind` est exigible : les autres champs ne sont servis que sur `kind: filtre`,
      # ce que porte déjà la description de chacun. Un motif hors `filtre` est un singleton
      # `{ kind }`, et doit rester valide contre ce schéma.
      required: [kind]
      description: >
        POURQUOI ce nombre de leads a été enrôlé dans le moteur d'envoi — et pas un autre.
        Union discriminée par `kind`. ABSENT ≠ zéro : un client qui ne trouve pas ce champ
        doit dire qu'il ne sait pas, jamais afficher une ventilation à zéro (la réponse peut
        venir d'un déploiement antérieur).
      properties:
        kind:
          type: string
          enum:
            - pas_de_provider
            - sequence_inactive
            - condition_illisible
            - bloc_linkedin_non_supporte
            - aucune_liste_cible
            - liste_introuvable
            - lecture_impossible
            - envoi_impossible
            - liste_noire_illisible
            - ecarte_en_amont
            - filtre
          description: >
            `pas_de_provider` = brouillon non relié à un canal (cas NORMAL, l'enrôlement se
            fera à l'activation). `liste_noire_illisible` = la liste noire n'a pas pu être lue,
            donc rien n'est enrôlé (le seul compartiment à conséquence légale : on refuse
            plutôt que d'affirmer `liste_noire: 0` sans l'avoir mesuré). `ecarte_en_amont` =
            toutes les lignes ont été écartées AVANT le filtre par une garde de la route
            (blacklist, déjà en campagne, verrou 90 j) ; les décomptes voisins disent
            lesquelles. `lecture_impossible` = la liste n'a pas pu être lue (≠ liste
            vide, ≠ liste tronquée). `envoi_impossible` = l'ordre d'enrôlement n'a pas pu être
            transmis au moteur, la séquence est pourtant active. `filtre` = des candidats ont
            été examinés, cf. les champs ci-dessous.
        resolus:
          type: integer
          description: >
            `kind=filtre` seulement. Lignes portant un prospect résolu, telles que le FILTRE
            les a vues. ⚠️ N'inclut AUCUNE des TROIS exclusions amont de la route
            d'attachement — `blacklisted`, `enrolled_skipped`, `locked_skipped` — qui sont
            retirées avant que le filtre ne voie quoi que ce soit. Ces quatre nombres ne sont
            PAS additionnables entre eux.
        enroles:
          type: integer
          description: >
            `kind=filtre` seulement. Lignes RÉELLEMENT INSÉRÉES en base — un fait accompli, pas
            une intention : ne pas traiter ce nombre comme un volume simplement soumis.
        repris:
          type: integer
          description: >
            `kind=filtre` seulement. Enrôlements RÉVEILLÉS par cette activation
            (`paused` → `active`). Vaut toujours 0 sur le chemin d'attachement, qui ne
            réveille rien. Hors ventilation : ce ne sont pas des lignes écartées.
        deja_enroles_illisible:
          type: boolean
          description: >
            `kind=filtre` seulement. On n'a PAS pu établir qui était déjà enrôlé (lecture en
            erreur ou tronquée) : `enroles` peut alors être surestimé, et l'écran le dit au
            lieu de laisser croire à une mesure.
        exclus_pool:
          type: integer
          description: >
            `kind=filtre` seulement. Lignes écartées faute d'expéditeur SAIN sur un canal que
            la séquence envoie (garde anti-skip). Hors ventilation, comme `liste_tronquee` :
            ces prospects ne sont pas « inéligibles », c'est l'organisation qui n'a pas
            d'expéditeur pour les servir. Absent d'une réponse plus ancienne ⇒ lire 0.
        non_tentes:
          type: integer
          description: >
            `kind=filtre` seulement. Lignes JAMAIS TENTÉES — DEUX causes, et le diagnostic
            diffère : (a) l'écriture s'est arrêtée avant la limite de durée de l'appel pour ne
            pas être interrompue — comportement VOULU sur une grosse liste ; (b) la
            séquence a été mise en PAUSE pendant l'écriture — un opérateur a arrêté sa campagne.
            Ne pas conclure « on a tapé les 300 s » sur ce compteur seul.

            ⚠️ Ce n'est PAS une alarme en soi : `non_tentes - non_confies` partiront tout seuls.
            C'est `non_confies` qui appelle un geste. Absent d'une réponse plus ancienne ⇒ lire 0.
        non_confies:
          type: integer
          description: >
            `kind=filtre` seulement. SOUS-ENSEMBLE de `non_tentes` que PERSONNE ne reprendra —
            ces prospects resteront hors campagne jusqu'à une relance manuelle, contrairement aux
            `non_tentes - non_confies` autres, qui seront enrôlés automatiquement.

            DEUX causes là encore : la reprise du reliquat a été REFUSÉE, OU elle n'a
            délibérément jamais été tentée parce que la séquence avait été mise en pause. Dans le
            second cas `non_confies` vaut tout `non_tentes`, et rien n'a été refusé.

            ⚠️ NE PAS l'ajouter à l'invariant : il compterait deux fois les mêmes lignes.
            Absent d'une réponse plus ancienne ⇒ lire 0.
        liste_tronquee:
          type: boolean
          description: >
            `kind=filtre` seulement. La source n'a pas été lue en entier (plafond atteint) :
            les compteurs portent alors sur un sous-ensemble.
        arret_pause:
          type: boolean
          description: >
            `kind=filtre` seulement. L'écriture s'est arrêtée parce que la séquence a été MISE
            EN PAUSE en cours d'activation.

            ⚠️ IL NE CHANGE AUCUN COMPTEUR. `non_confies` continue de valoir tout `non_tentes`
            sur ce chemin, exactement comme la description de ce champ l'annonce déjà : aucun
            refus n'a eu lieu, rien n'a été proposé. Ce drapeau change la PHRASE et le TON de la
            ligne — « la campagne a été mise en pause » au lieu de « relance manuelle », qui
            imputerait à un refus un arrêt que l'opérateur a voulu.

            Absent d'une réponse plus ancienne ⇒ lire `false`. Présent mais non booléen ⇒ le
            motif entier est refusé côté client (aucun repli silencieux).

            ⚠️ CE DRAPEAU N'EST PAS CE QUI EMPÊCHE D'ENVOYER. La garantie est posée côté
            serveur, en amont de tout envoi : un enrôlement `active` rattaché à une séquence qui
            ne l'est pas n'est plus éligible. `arret_pause` sert à le DIRE, pas à le faire.
        ventilation:
          type: object
          description: >
            `kind=filtre` seulement. Une LIGNE tombe dans un SEUL compartiment.
            ⚠️ L'INVARIANT PORTE TROIS TERMES, PAS UN. La somme des compartiments SEULE ne
            vaut plus `resolus - enroles` : deux causes vivent hors ventilation parce qu'elles
            ne sont pas des raisons métier. L'égalité exacte est
            `somme(ventilation) + exclus_pool + non_tentes = resolus - enroles`.
            ⚠️ Ne PAS se contenter de la somme des compartiments : elle ne suffit pas dès
            qu'un pool d'expéditeurs est vide.

            ⚠️ UNE EXCEPTION, MESURÉE : quand `deja_enroles_illisible` vaut `true`, l'égalité
            devient un MINORANT. La lecture des déjà-enrôlés ayant échoué, l'ensemble rendu est
            VIDE : ces lignes ne peuvent pas être rangées dans `ventilation.deja_enrole`, elles
            restent dans les candidats, puis la contrainte d'unicité les écarte en silence à
            l'insertion — donc elles ne comptent ni dans `enroles`, ni dans aucun terme. Le manque
            vaut exactement leur nombre. Ne pas alerter sur l'écart quand ce drapeau est levé.
            ⚠️ `liste_noire` vaut toujours 0 sur le chemin d'attachement, qui filtre en amont
            et le rapporte via `blacklisted`.
          properties:
            liste_noire: { type: integer }
            deja_enrole: { type: integer }
            deja_demarche: { type: integer }
            canal_absent: { type: integer }
            doublon: { type: integer }

    InsufficientCreditsError:
      type: object
      properties:
        error: { type: string }
        required:
          type: integer
          description: Crédits nécessaires pour l'opération.
        available:
          type: integer
          description: Crédits disponibles sur l'organisation.
      required: [error]

    EnrichmentTypes:
      type: object
      description: Canaux à enrichir. Au moins un doit être `true`.
      properties:
        email: { type: boolean }
        phone: { type: boolean }

    OrganizationId:
      type: string
      description: Optionnel pour une clé API (déduit de la clé) ; requis en session.

    EnrichedData:
      type: object
      description: Données résolues (présentes si l'enrichissement a abouti en synchrone).
      properties:
        email: { type: [string, "null"] }
        phone: { type: [string, "null"] }

    EnrichContactRequest:
      type: object
      properties:
        first_name: { type: string }
        last_name: { type: string }
        company_name: { type: string }
        company_domain:
          type: string
          description: Domaine de la société, ex. `stripe.com` — améliore le taux de match.
        linkedin_url: { type: string }
        enrichmentTypes: { $ref: "#/components/schemas/EnrichmentTypes" }
        organizationId: { $ref: "#/components/schemas/OrganizationId" }
      required: [first_name, last_name, enrichmentTypes]

    EnrichContactResponse:
      type: object
      properties:
        success: { type: boolean }
        source:
          type: string
          description: Origine de la donnée (`database`, `pronto`, `airscale`, …).
        data: { $ref: "#/components/schemas/EnrichedData" }
        itemId: { type: string }
        listId: { type: string }
        pending:
          type: boolean
          description: true si la résolution est asynchrone (poller `sync-pending-item`).
        creditsDeducted: { type: integer }
        message: { type: string }
        enrichmentId: { type: [string, "null"] }

    EnrichProspectRequest:
      type: object
      properties:
        prospectId: { type: string }
        enrichmentType:
          type: string
          enum: [email, phone]
        first_name: { type: string }
        last_name: { type: string }
        company_name: { type: string }
        company_domain: { type: string }
        linkedin_url: { type: string }
        organizationId: { $ref: "#/components/schemas/OrganizationId" }
      required: [prospectId, enrichmentType]

    EnrichListItemRequest:
      type: object
      properties:
        itemId: { type: string }
        enrichmentType:
          type: string
          enum: [email, phone]
        first_name: { type: string }
        last_name: { type: string }
        company_name: { type: string }
        company_domain: { type: string }
        linkedin_url: { type: string }
        organizationId: { $ref: "#/components/schemas/OrganizationId" }
      required: [itemId, enrichmentType]

    EnrichItemResponse:
      type: object
      properties:
        success: { type: boolean }
        message: { type: string }
        source: { type: string }
        creditsDeducted: { type: integer }
        data: { $ref: "#/components/schemas/EnrichedData" }
        enrichmentId: { type: [string, "null"] }

    EnrichByListIdRequest:
      type: object
      properties:
        prospectListId: { type: string }
        enrichmentTypes: { $ref: "#/components/schemas/EnrichmentTypes" }
        organizationId: { $ref: "#/components/schemas/OrganizationId" }
      required: [prospectListId, enrichmentTypes]

    EnrichCsvRequest:
      type: object
      properties:
        listName: { type: string }
        prospects:
          type: array
          maxItems: 1000
          items:
            type: object
            properties:
              first_name: { type: string }
              last_name: { type: string }
              company_name: { type: string }
              company_domain: { type: string }
              linkedin_url: { type: string }
            required: [first_name, last_name]
        enrichmentTypes: { $ref: "#/components/schemas/EnrichmentTypes" }
        organizationId: { $ref: "#/components/schemas/OrganizationId" }
      required: [listName, prospects, enrichmentTypes]

    EnrichListResultCounts:
      type: object
      properties:
        total: { type: integer }
        success: { type: integer }
        errors: { type: integer }
        fromDatabase: { type: integer }
        fromAirscaleAPI: { type: integer }
        fromAsyncAPI: { type: integer }
        fromProntoAPI: { type: integer }

    EnrichListResponse:
      type: object
      properties:
        success: { type: boolean }
        async:
          type: boolean
          description: Présent pour l'import CSV — l'enrichissement se poursuit en arrière-plan.
        listId: { type: string }
        message: { type: string }
        creditsDeducted: { type: integer }
        results: { $ref: "#/components/schemas/EnrichListResultCounts" }

    ListCoverageResponse:
      type: object
      properties:
        total: { type: integer }
        withSignal:
          type: integer
          description: Prospects disposant d'une URL LinkedIn ou d'un domaine.
        missingSignal: { type: integer }

    PendingItemRequest:
      type: object
      properties:
        itemId: { type: string }
        organizationId: { $ref: "#/components/schemas/OrganizationId" }
      required: [itemId]

    PendingItemResponse:
      type: object
      properties:
        pending: { type: boolean }
        email: { type: [string, "null"] }
        phone: { type: [string, "null"] }

    PullListItemResponse:
      type: object
      description: |
        Résultat du polling. `synced`/`noop` en cas de succès ; sinon un objet
        `{ skipped: true, reason }` ou `{ stillPending: true }`.
      properties:
        synced: { type: boolean }
        noop: { type: boolean }
        skipped: { type: boolean }
        reason: { type: string }
        stillPending: { type: boolean }

    PendingListRequest:
      type: object
      properties:
        prospectListId: { type: string }
        organizationId: { $ref: "#/components/schemas/OrganizationId" }
      required: [prospectListId]

    PendingListResponse:
      type: object
      properties:
        pendingRemaining:
          type: integer
          description: Nombre de lignes encore en attente après réconciliation.
