openapi: 3.1.0
info:
  title: Basile API
  version: "1.0.0"
  description: |
    **Basile** est une base de données B2B française : ~26 M sociétés et ~4,4 M
    contacts, issus du croisement de trois sources :
    - **Legal** — registre légal français (INSEE / INPI-RNE) : dirigeants, SIREN, NAF, forme juridique…
    - **LKI** — profils LinkedIn : intitulé de poste, séniorité, compétences, écoles…
    - **GMB** — Google My Business : note, avis, horaires, catégorie…

    L'API expose deux recherches symétriques — **personnes** (`/people`) et
    **entreprises** (`/companies`) — avec un système de filtres `include`/`exclude`,
    un comptage gratuit avant extraction, et un export CSV enrichi.

    ### Authentification
    Toutes les requêtes portent la clé API dans le header `Authorization`, **en clair,
    sans préfixe `Bearer`** : `Authorization: sk_live_xxxxxxxx`.

    ### Règle d'or
    L'export consomme du quota. **Toujours compter d'abord** (`/find` avec `limit: 1`
    → champ `total`) avant d'extraire.
  contact:
    name: Support Basile
    url: https://basile.cc
    email: contact@basile.cc

servers:
  - url: https://api.basile.cc
    description: Production

security:
  - ApiKeyAuth: []

tags:
  - name: Personnes
    description: Recherche et export de contacts (dirigeants légaux + profils LinkedIn).
  - name: Entreprises
    description: Recherche et export de sociétés (registre + LinkedIn + Google My Business).
  - name: Suggestions
    description: Autocomplétion pour trouver les valeurs valides des filtres.

paths:
  /people/find:
    post:
      tags: [Personnes]
      summary: Rechercher des personnes
      description: |
        Recherche de contacts (dirigeants légaux et/ou profils LinkedIn). Renvoie
        un total et une page de résultats. **Pour compter sans extraire**, envoyer
        `limit: 1` et lire `total`. La page 1 est accessible sans abonnement ; la
        pagination (page > 1) requiert un abonnement actif.
      operationId: findPeople
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FindPeopleRequest"
            examples:
              compterCEO:
                summary: Compter les CEO en France (limit 1)
                value:
                  limit: 1
                  filters:
                    result_role:
                      include: ["CEO", "Chief Executive Officer", "PDG", "Président Directeur Général", "Directeur Général"]
                    result_country_code:
                      include: ["FR"]
                    hide_legal_entities: true
      responses:
        "200":
          description: Résultats paginés.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedPeople"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
      x-codeSamples:
        - lang: cURL
          source: |
            curl -s https://api.basile.cc/people/find \
              -H "Authorization: $BASILE_KEY" \
              -H "Content-Type: application/json" \
              -d '{"limit":1,"filters":{"result_role":{"include":["CEO","PDG"]},"result_country_code":{"include":["FR"]},"hide_legal_entities":true}}'
        - lang: Python
          source: |
            import requests
            r = requests.post("https://api.basile.cc/people/find",
                headers={"Authorization": BASILE_KEY},
                json={"limit": 1, "filters": {
                    "result_role": {"include": ["CEO", "PDG"]},
                    "result_country_code": {"include": ["FR"]},
                    "hide_legal_entities": True}})
            print(r.json()["total"])
        - lang: JavaScript
          source: |
            const res = await fetch("https://api.basile.cc/people/find", {
              method: "POST",
              headers: { Authorization: BASILE_KEY, "Content-Type": "application/json" },
              body: JSON.stringify({ limit: 1, filters: {
                result_role: { include: ["CEO", "PDG"] },
                result_country_code: { include: ["FR"] },
                hide_legal_entities: true } }),
            });
            console.log((await res.json()).total);
        - lang: PHP
          source: |
            <?php
            $ch = curl_init("https://api.basile.cc/people/find");
            curl_setopt_array($ch, [
              CURLOPT_RETURNTRANSFER => true,
              CURLOPT_POST => true,
              CURLOPT_HTTPHEADER => ["Authorization: {$BASILE_KEY}", "Content-Type: application/json"],
              CURLOPT_POSTFIELDS => json_encode(["limit" => 1, "filters" => [
                "result_role" => ["include" => ["CEO", "PDG"]],
                "result_country_code" => ["include" => ["FR"]],
                "hide_legal_entities" => true]]),
            ]);
            $data = json_decode(curl_exec($ch), true);
            echo $data["total"];

  /people/export:
    post:
      tags: [Personnes]
      summary: Exporter des personnes (CSV)
      description: |
        Exporte un CSV enrichi (~70 colonnes : identité, poste, profil LinkedIn,
        société, légal, GMB, contacts). Accepte les **mêmes `filters`** que `/find`,
        ou une liste d'`ids`. Plafonné par le plan (header `X-Export-Max-Rows`).
        L'export est **streamé** (parcours du résultat par curseur, écriture au fil
        de l'eau) : un seul appel par `filters` suffit jusqu'à la limite par export
        du plan — pas besoin de pré-collecter les IDs. Au-delà de cette limite,
        découper la recherche en segments (département / NAF / année…) et faire un
        export par segment. L'export par `ids` reste utile quand on a déjà collecté
        une liste (via `/find` `idsOnly`) pour un ré-enrichissement par lots ou une
        sélection.
      operationId: exportPeople
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/ExportByFilters"
                - $ref: "#/components/schemas/ExportByIds"
      responses:
        "200":
          description: Fichier CSV.
          headers:
            X-Export-Max-Rows:
              schema: { type: integer }
              description: Nombre max de lignes pour cet export (selon le plan).
            X-Export-Capped:
              schema: { type: string }
              description: "`true` si le résultat a été tronqué (plus de résultats au-delà)."
          content:
            text/csv:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/ExportLimit" }
      x-codeSamples:
        - lang: cURL
          source: |
            curl -s https://api.basile.cc/people/export \
              -H "Authorization: $BASILE_KEY" -H "Content-Type: application/json" \
              -d '{"filters":{"mandate_role":{"include":["president"]},"result_is_current":true}}' \
              -o people.csv

  /people/{id}:
    get:
      tags: [Personnes]
      summary: Fiche brute d'une personne
      operationId: getPerson
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: La personne. }
        "404": { $ref: "#/components/responses/NotFound" }

  /people/{id}/full:
    get:
      tags: [Personnes]
      summary: Fiche complète enrichie (toutes colonnes)
      description: Même schéma enrichi que l'export CSV, pour une seule personne.
      operationId: getPersonFull
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Ligne enrichie + liste des colonnes. }
        "404": { $ref: "#/components/responses/NotFound" }

  /people/roles/suggest:
    get:
      tags: [Suggestions]
      summary: Suggérer des intitulés de poste
      operationId: suggestRoles
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 10 } }
      responses:
        "200": { description: Liste de suggestions. }

  /people/cities/suggest:
    get:
      tags: [Suggestions]
      summary: Suggérer des villes (personnes)
      operationId: suggestPeopleCities
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: Liste de villes. }

  /companies/find:
    post:
      tags: [Entreprises]
      summary: Rechercher des entreprises
      description: |
        Recherche de sociétés sur les trois sources (registre, LinkedIn, GMB).
        **Pour compter sans extraire**, envoyer `limit: 1` et lire `total`.
      operationId: findCompanies
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FindCompaniesRequest"
            examples:
              btp:
                summary: Artisans du bâtiment actifs à Paris (concept activity + codes postaux)
                value:
                  limit: 1
                  filters:
                    activity: { include: ["btp_global"] }
                    company_ceased: false
                    headquarters_postal_code: { include: ["75001", "75002", "75003"] }
      responses:
        "200":
          description: Résultats paginés.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedCompanies"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402": { $ref: "#/components/responses/SubscriptionRequired" }
        "429": { $ref: "#/components/responses/RateLimited" }
      x-codeSamples:
        - lang: cURL
          source: |
            curl -s https://api.basile.cc/companies/find \
              -H "Authorization: $BASILE_KEY" -H "Content-Type: application/json" \
              -d '{"limit":1,"filters":{"activity":{"include":["btp_global"]},"company_ceased":false}}'
        - lang: Python
          source: |
            import requests
            r = requests.post("https://api.basile.cc/companies/find",
                headers={"Authorization": BASILE_KEY},
                json={"limit": 1, "filters": {
                    "activity": {"include": ["btp_global"]},
                    "company_ceased": False}})
            print(r.json()["total"])

  /companies/export:
    post:
      tags: [Entreprises]
      summary: Exporter des entreprises (CSV)
      description: |
        Mêmes règles que l'export personnes : accepte `filters` ou une liste d'`ids`,
        export **streamé** (un seul appel par `filters` jusqu'à la limite par export
        du plan), plafonné par le plan. Au-delà de la limite, découper en segments.
      operationId: exportCompanies
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/ExportByFilters"
                - $ref: "#/components/schemas/ExportByIds"
      responses:
        "200":
          description: Fichier CSV.
          content:
            text/csv:
              schema: { type: string }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/ExportLimit" }

  /companies/{id}:
    get:
      tags: [Entreprises]
      summary: Fiche brute d'une entreprise
      operationId: getCompany
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: L'entreprise. }
        "404": { $ref: "#/components/responses/NotFound" }

  /companies/{id}/full:
    get:
      tags: [Entreprises]
      summary: Fiche complète enrichie (toutes colonnes)
      operationId: getCompanyFull
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: Ligne enrichie + colonnes. }
        "404": { $ref: "#/components/responses/NotFound" }

  /companies/activity-suggest:
    get:
      tags: [Suggestions]
      summary: Suggérer des activités (concepts pour le filtre `activity`)
      description: Renvoie des IDs de concept (taxonomie unifiée NAF · Maps · LinkedIn) à passer dans `filters.activity`.
      operationId: suggestActivity
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: Concepts correspondants. }

  /companies/legal-form-suggest:
    get:
      tags: [Suggestions]
      summary: Suggérer des formes juridiques
      operationId: suggestLegalForms
      parameters:
        - { name: q, in: query, required: true, schema: { type: string } }
      responses:
        "200": { description: Formes juridiques. }

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: Authorization
      description: "Clé API en clair (sans `Bearer`). Ex : `Authorization: sk_live_xxxx`."

  schemas:
    IncludeExcludeFilter:
      type: object
      description: |
        Filtre texte. `include` = OR entre les valeurs (au moins une matche).
        `exclude` = exclut toute valeur qui matche. Les deux peuvent être combinés.
        Plusieurs filtres distincts se combinent en **ET**.
      properties:
        include:
          type: array
          items: { type: string }
        exclude:
          type: array
          items: { type: string }
      example:
        include: ["Directeur Commercial", "Sales Director"]
        exclude: ["assistant"]

    RangeFilter:
      type: object
      description: Filtre numérique (bornes). Tous les opérateurs sont optionnels.
      properties:
        ">":  { type: number }
        ">=": { type: number }
        "<":  { type: number }
        "<=": { type: number }
      example: { ">=": 40, "<=": 55 }

    PaginationParams:
      type: object
      properties:
        limit:
          type: integer
          maximum: 5000
          default: 100
          description: >
            Taille de page (défaut 100). Cap = **1000** en mode normal ;
            **5000** en mode `idsOnly` côté `/people/find` (companies reste à 1000).
            Mettre `1` pour seulement compter.
        paginationToken:
          type: string
          description: Curseur opaque renvoyé dans `pagination.nextToken` pour la page suivante.
        idsOnly:
          type: boolean
          default: false
          description: >
            Si `true`, `/find` ne renvoie que `{ _id, source }` (champ `data` vide), sans
            enrichissement par ligne — beaucoup plus léger et rapide pour collecter les IDs
            d'un grand résultat. Permet `limit` jusqu'à 5000 côté `/people/find`. À utiliser
            quand on a besoin de la liste d'IDs avant un export par `{ ids }` ou un
            ré-enrichissement par lots. Pour un simple CSV, préférer `/export` par `filters`
            (export streamé, jusqu'à la limite par export du plan).

    FindPeopleRequest:
      allOf:
        - $ref: "#/components/schemas/PaginationParams"
        - type: object
          properties:
            filters:
              $ref: "#/components/schemas/FindPeopleFilters"

    FindPeopleFilters:
      type: object
      description: |
        Filtres de recherche de personnes. Deux sources : **Legal** (registre) et
        **LKI** (LinkedIn). Activer un filtre Legal-only exclut LinkedIn, et
        inversement (voir descriptions).
      properties:
        result_full_name: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Nom complet." }
        result_first_name: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Prénom." }
        result_last_name: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Nom de famille." }
        result_role: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Intitulé de poste (texte). Mettre toutes les variantes (CEO, PDG, DG…)." }
        activity: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Secteur/métier de l'entreprise de la personne (concept unifié NAF + LinkedIn). IDs via /companies/activity-suggest, ou préfixes naf:/lki:/gmb:." }
        employer: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Employeur actuel. Valeur entre guillemets (\"Nom\") = match EXACT sur legal_name + current_company_name (dirigeants registre + salariés LinkedIn) ; valeur nue = contains. Multi-source." }
        result_city: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Ville de la personne." }
        result_country_code: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Code pays (ex. FR)." }
        mandate_role: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Codes : gerant, president, dg, dgd, administrateur, commissaire_comptes, associe, directeur_non_dg, autre." }
        siren: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. SIREN exact de la société liée." }
        legal_name: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Raison sociale." }
        nationality: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Nationalité (libellé)." }
        nationality_code: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Code nationalité (ex. FR)." }
        result_postal_code: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Code postal." }
        result_is_legal_entity: { type: boolean, description: "Legal-only. La 'personne' est une personne morale." }
        result_is_current: { type: boolean, description: "Legal-only. Mandat actuel uniquement." }
        result_age: { $ref: "#/components/schemas/RangeFilter", description: "Legal-only. Âge." }
        result_total_companies_count: { $ref: "#/components/schemas/RangeFilter", description: "Legal-only. Nb de sociétés dirigées." }
        current_seniority: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. Séniorité (C-Level, Director, VP, Head, Manager…)." }
        current_job_functions: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. Fonction (Sales, Marketing…)." }
        skills: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. Compétences." }
        languages: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. Langues." }
        education: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. École ou diplôme." }
        past_title: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. Intitulé d'un poste passé." }
        past_employer: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. Employeur passé (alumni)." }
        connection_count: { $ref: "#/components/schemas/RangeFilter", description: "LKI-only. Nb de connexions LinkedIn." }
        source: { type: string, enum: ["Legal", "LKI"], description: "Forcer une seule source." }
        with_legal_data: { type: boolean, description: "Registre uniquement." }
        with_linkedin_profile: { type: boolean, description: "LinkedIn uniquement." }
        hide_legal_entities: { type: boolean, description: "Masque les personnes morales (recommandé : true) sans exclure LinkedIn." }

    FindCompaniesRequest:
      allOf:
        - $ref: "#/components/schemas/PaginationParams"
        - type: object
          properties:
            filters:
              $ref: "#/components/schemas/FindCompaniesFilters"

    FindCompaniesFilters:
      type: object
      description: |
        Filtres de recherche d'entreprises. Trois sources : **Legal** (registre),
        **LKI** (LinkedIn), **GMB** (Google My Business).
      properties:
        name: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Nom d'entreprise (contains)." }
        activity: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Métier unifié : IDs de concept (NAF + Maps + LinkedIn). Le meilleur filtre secteur. Multi-source." }
        naf_code: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Codes NAF. Préfixe .x supporté (41.x = tout le 41) ou code exact (41.20A)." }
        headquarters_department_code: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Département du siège (ex. 92, 75). Dérivé du code postal." }
        headquarters_region_code: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "⛔ CASSÉ actuellement (0 résultat). Passer par les codes postaux des départements de la région." }
        headquarters_postal_code: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Code postal du siège (exact). LE filtre géo fiable — accepte de grandes listes (énumération département)." }
        headquarters_city: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Ville du siège (Legal + LKI). ⚠️ Matching exact SENSIBLE À LA CASSE : inclure 'Marseille' ET 'MARSEILLE' (Legal stocke en MAJUSCULES SANS ACCENT)." }
        headquarters_country_code: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Code pays du siège (Legal + LKI)." }
        legal_form: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Forme juridique." }
        legal_category: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. Code catégorie juridique INSEE." }
        siren: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "Legal-only. SIREN exact." }
        headcount_min: { type: integer, description: "Effectif minimum (Legal + LKI)." }
        headcount_max: { type: integer, description: "Effectif maximum (Legal + LKI)." }
        capital_min: { type: number, description: "Legal-only. Capital social min (€)." }
        capital_max: { type: number, description: "Legal-only. Capital social max (€)." }
        company_ceased: { type: boolean, description: "false = actives uniquement (recommandé). true = cessées." }
        publishable: { type: boolean, description: "Legal-only. Diffusable." }
        creation_date_min: { type: string, description: "Legal-only. ANNÉE de création min (2015 ou '2015'). ⚠️ JAMAIS de date complète ('2015-03-01') : elle serait SILENCIEUSEMENT ignorée (résultats non filtrés)." }
        creation_date_max: { type: string, description: "Legal-only. ANNÉE de création max (année seule, pas de date complète)." }
        industry_main: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. Secteur LinkedIn (lacunaire — préférer activity/naf_code)." }
        company_type: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. Type (Privately Held…)." }
        domain: { $ref: "#/components/schemas/IncludeExcludeFilter", description: "LKI-only. Domaine web (contains)." }
        followers_min: { type: integer, description: "LKI-only. Followers LinkedIn min." }
        followers_max: { type: integer, description: "LKI-only. Followers LinkedIn max." }
        rating_min: { type: number, description: "GMB-only. Note Google min (0-5)." }
        rating_max: { type: number, description: "GMB-only. Note Google max." }
        reviews_min: { type: integer, description: "GMB-only. Nb d'avis min." }
        reviews_max: { type: integer, description: "GMB-only. Nb d'avis max." }
        is_opening: { type: boolean, description: "GMB-only. Ouvert le dimanche." }
        source: { type: string, enum: ["Legal", "LKI", "GMB"], description: "Forcer une seule source." }
        with_legal_data: { type: boolean, description: "Registre uniquement." }
        with_linkedin_page: { type: boolean, description: "LinkedIn uniquement." }

    ExportByFilters:
      type: object
      required: [filters]
      properties:
        filters:
          oneOf:
            - $ref: "#/components/schemas/FindPeopleFilters"
            - $ref: "#/components/schemas/FindCompaniesFilters"

    ExportByIds:
      type: object
      required: [ids]
      properties:
        ids:
          type: array
          items: { type: string }
          description: IDs renvoyés par `/find` (champ `_id` de chaque lead).

    MergedItem:
      type: object
      properties:
        _id: { type: string }
        source: { type: string, enum: ["Legal", "LKI", "GMB"] }
        data:
          type: object
          additionalProperties: true
          description: Champs bruts de la source (varie selon `source`).

    PaginatedPeople:
      type: object
      properties:
        success: { type: boolean }
        total: { type: integer, description: "Total de résultats (somme des sources, sans dédup cross-source)." }
        leads:
          type: array
          items: { $ref: "#/components/schemas/MergedItem" }
        pagination:
          type: object
          properties:
            nextToken: { type: string }
          description: Présent uniquement s'il reste une page.

    PaginatedCompanies:
      $ref: "#/components/schemas/PaginatedPeople"

    Error:
      type: object
      properties:
        success: { type: boolean, example: false }
        error: { type: string }
        message: { type: string }

  responses:
    Unauthorized:
      description: Clé API absente ou invalide.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: "Unauthorized" }
    SubscriptionRequired:
      description: La pagination (page > 1) requiert un abonnement actif.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "subscription_required" }
    RateLimited:
      description: "Trop de requêtes. Respecter le header `Retry-After` (secondes)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "rate_limit_exceeded" }
    ExportLimit:
      description: Quota d'export mensuel atteint, ou rate limit.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "export_limit_reached" }
    NotFound:
      description: Ressource introuvable.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { success: false, error: "Not found" }
