openapi: 3.1.0
info:
  title: API Bercail v1
  version: "1.0.0"
  summary: API externe en lecture seule des modules de Bercail, pour la domotique, les scripts et les tableaux de bord.
  description: |
    L'API v1 expose **en lecture seule** les données d'**un seul foyer**, celui que désigne la clé d'API.
    Elle ne concerne pas l'API interne du client web (`/api/spaces/…`), qui peut changer sans préavis.

    - **Une clé par foyer et par application**, créée dans l'application : *Paramètres du foyer → Clés d'API*
      (propriétaire ou administrateur). Chaque clé est limitée aux modules choisis à sa création
      (`bernard`, `lulu`, `leon`, `annie`) ; on ne modifie pas les modules d'une clé, on en crée une autre
      et on révoque l'ancienne.
    - La clé s'envoie dans `Authorization: Bearer …` ou `X-API-Key: …`, **jamais dans l'URL**.
      Elle n'est affichée qu'une fois à sa création : seule son empreinte SHA-256 est conservée.
    - L'URL ne contient pas le foyer : c'est la clé qui le détermine. La session navigateur ne suffit pas.
    - Aucune écriture n'est possible : uniquement des `GET`. Les réponses portent `Cache-Control: no-store`.

    **Versionnement.** La version est dans le chemin (`/api/v1`) et rappelée dans le champ `version`.
    Sans changer de version, des routes, des champs ou des codes d'erreur peuvent être **ajoutés** :
    un client doit ignorer ce qu'il ne connaît pas. Supprimer ou renommer un champ, changer son type
    ou son sens, ou changer une adresse demande une **v2**, servie en parallèle pendant une transition
    annoncée à l'avance.

    *Cette page est la référence générée depuis `openapi.yaml`, le même fichier qui sert à la
    documentation publique. Les exemples ne contiennent pas de clé : renseignez la vôtre avec
    « Authorize » pour essayer une requête depuis le navigateur.*
  contact:
    name: Bercail
    url: https://docs.bercail.rilaun.fr
  license:
    name: Licence à définir
    identifier: LicenseRef-proprietary
servers:
  - url: https://app.bercail.rilaun.fr
    description: Bercail en ligne (SaaS)
tags:
  - name: Bernard
    description: Stock alimentaire du foyer — placards, frigo, congélateur, péremptions.
  - name: Lulu
    description: Carnet de recettes du foyer.
  - name: Léon
    description: Consommables non alimentaires et articles à racheter.
  - name: Annie
    description: Achats, factures et fins de garantie.
security:
  - bearerApiKey: []
  - headerApiKey: []
paths:
  /api/v1/stock:
    get:
      tags: [Bernard]
      operationId: bernardStockLegacy
      summary: Stock alimentaire (adresse d'origine)
      description: |
        Adresse historique, identique à `GET /api/v1/bernard/stock` : un client écrit pour le Bernard
        autonome lit Bercail sans changement. Préférez `/api/v1/bernard/stock` dans un nouveau client.
      responses:
        "200":
          description: Stock du foyer, regroupé par aliment (`foods`) et en entrées individuelles (`items`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BernardStock"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/Internal"
  /api/v1/bernard/stock:
    get:
      tags: [Bernard]
      operationId: bernardStock
      summary: Stock alimentaire
      description: |
        Regroupements par aliment (`foods`) et entrées individuelles (`items`). Les entrées sont groupées
        par nom (casse ignorée) et unité identique, sans conversion d'unité. Tri : prochaine péremption
        croissante, puis nom ; les groupes sans date viennent après. **Ni l'ordre des tableaux ni les
        identifiants ne doivent servir de clé stable.**
      responses:
        "200":
          description: Stock du foyer.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BernardStock"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/Internal"
  /api/v1/lulu/recipes:
    get:
      tags: [Lulu]
      operationId: luluRecipes
      summary: Recettes du carnet
      description: |
        Recettes du foyer, les favorites d'abord puis par titre. Les ingrédients sont **analysés**
        (quantité, unité, nom) ; les photos ne sont pas exposées (les fichiers du foyer ne sortent pas).
      responses:
        "200":
          description: Recettes du foyer.
          content:
            application/json:
              schema:
                type: object
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    required: [recipes]
                    properties:
                      recipes:
                        type: array
                        items:
                          $ref: "#/components/schemas/LuluRecipe"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/Internal"
  /api/v1/leon/items:
    get:
      tags: [Léon]
      operationId: leonItems
      summary: Consommables non alimentaires
      description: |
        Articles suivis par Léon, triés par nom. `restock` vaut `true` quand l'article est à racheter :
        quantité au seuil ou en dessous, ou article ajouté à la liste de courses.
      responses:
        "200":
          description: Articles du foyer.
          content:
            application/json:
              schema:
                type: object
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/LeonItem"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/Internal"
  /api/v1/annie/items:
    get:
      tags: [Annie]
      operationId: annieItems
      summary: Achats et garanties
      description: |
        Achats du foyer, triés par fin de garantie puis par nom. **Ni numéro de série, ni notes, ni
        fichiers** : seulement ce qu'il faut pour suivre les achats et les garanties.
      responses:
        "200":
          description: Achats du foyer.
          content:
            application/json:
              schema:
                type: object
                allOf:
                  - $ref: "#/components/schemas/Envelope"
                  - type: object
                    required: [items]
                    properties:
                      items:
                        type: array
                        items:
                          $ref: "#/components/schemas/AnnieItem"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/TooManyRequests"
        "500":
          $ref: "#/components/responses/Internal"
components:
  securitySchemes:
    bearerApiKey:
      type: http
      scheme: bearer
      description: "`Authorization: Bearer bcl_…` — clé d'API du foyer."
    headerApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: "`X-API-Key: bcl_…` — même clé, envoyée dans un en-tête dédié."
  responses:
    Unauthorized:
      description: |
        Clé absente, mal formée, inconnue ou révoquée (`401 api_key_required`).
        Chaque échec compte pour la limite par adresse IP.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: api_key_required
            message: Clé API invalide
    Forbidden:
      description: |
        `403 scope_missing` : la clé ne donne pas accès à ce module.
        `403 module_disabled` : le module est désactivé dans le foyer (la clé refonctionne s'il est réactivé).
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            scopeMissing:
              value:
                error: scope_missing
                message: Cette clé ne donne pas accès au module leon
                params:
                  module: leon
            moduleDisabled:
              value:
                error: module_disabled
                message: Le module bernard n'est pas activé pour ce foyer
                params:
                  module: bernard
    TooManyRequests:
      description: |
        `429 too_many_attempts` : trop d'échecs d'authentification depuis cette adresse.
        `params.seconds` donne l'attente, qui croît à chaque nouvel échec.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error: too_many_attempts
            message: Trop de tentatives
            params:
              seconds: 60
    NotFound:
      description: "`404 not_found` : route inconnue."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Internal:
      description: "`500 internal` : erreur du serveur."
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
  schemas:
    Envelope:
      type: object
      description: |
        Champs communs à toute réponse `200`. Les données suivent sous une clé propre à la route
        (`foods`/`items`, `recipes`, `items`).
      required: [version, generated_at, today, timezone]
      properties:
        version:
          type: integer
          const: 1
          description: Version du contrat.
        generated_at:
          type: string
          format: date-time
          description: Instant de la réponse, ISO 8601 en UTC.
          examples: ["2026-11-06T08:00:00.000Z"]
        today:
          type: string
          format: date
          description: Date du jour dans le fuseau du foyer ; sert aux calculs de péremption et de garantie.
          examples: ["2026-11-06"]
        timezone:
          type: string
          description: Fuseau du foyer.
          examples: ["Europe/Paris"]
    BernardStock:
      type: object
      description: Réponse de `GET /api/v1/bernard/stock`. Tous les champs listés sont toujours présents ; `null` signale une donnée absente.
      required: [version, generated_at, today, timezone, expiry_days, foods, items]
      properties:
        version:
          type: integer
          const: 1
        generated_at:
          type: string
          format: date-time
        today:
          type: string
          format: date
        timezone:
          type: string
        expiry_days:
          type: integer
          description: Seuil « bientôt périmé » du foyer, en jours (réglage de Bernard, 3 par défaut).
          examples: [3]
        foods:
          type: array
          description: Regroupements par aliment ; vide si le stock est vide.
          items:
            $ref: "#/components/schemas/BernardFood"
        items:
          type: array
          description: Entrées individuelles ; vide si le stock est vide.
          items:
            $ref: "#/components/schemas/BernardItem"
    BernardItem:
      type: object
      required:
        [id, name, category, category_label, space, quantity, unit, unit_label, estimated_quantity,
         state, remaining, opened_on, expires_on, days_left, expiry_status]
      properties:
        id:
          type: integer
          description: Identifiant de l'entrée, propre à ce foyer de cette instance.
        name:
          type: string
          description: Nom de l'aliment.
        category:
          type: string
          description: Identifiant de catégorie (`feculents`, `laitier`…). Rapprochez sur cet identifiant, pas sur le libellé.
          examples: ["feculents"]
        category_label:
          type: string
          description: Libellé français de la catégorie, pour l'affichage.
        space:
          type: [string, "null"]
          description: Nom du rangement Bernard (pas le foyer Bercail).
        quantity:
          type: number
          description: Quantité enregistrée, toujours supérieure à 0, au plus trois décimales.
        unit:
          type: string
          description: Identifiant de l'unité (`paquet`, `kg`…).
        unit_label:
          type: string
          description: Libellé français accordé à la quantité (`paquets`).
        estimated_quantity:
          type: number
          description: Quantité après application du reste, arrondie à trois décimales.
        state:
          type: string
          enum: [open, closed]
          description: "`open` : entamé ; `closed` : fermé."
        remaining:
          type: [integer, "null"]
          minimum: 1
          maximum: 99
          description: Pourcentage restant d'une entrée entamée ; `null` si fermée ou non renseigné.
        opened_on:
          type: [string, "null"]
          format: date
        expires_on:
          type: [string, "null"]
          format: date
        days_left:
          type: [integer, "null"]
          description: Jours de `today` à `expires_on`, négatif si périmé ; `null` sans date.
        expiry_status:
          type: string
          enum: [expired, expiring, ok, unknown]
          description: |
            `expired` si `days_left < 0`, `expiring` si `0 ≤ days_left ≤ expiry_days`, `ok` au-delà,
            `unknown` sans date. Les produits périmés sont inclus : au client de les écarter ou non.
    BernardFood:
      type: object
      description: Regroupement d'entrées de même nom (casse ignorée) et de même unité, sans conversion d'unité.
      required:
        [name, category, category_label, unit, unit_label, quantity, estimated_quantity, open_quantity,
         closed_quantity, spaces, next_expiry, days_left, expiry_status, item_ids]
      properties:
        name:
          type: string
        category:
          type: string
        category_label:
          type: string
        unit:
          type: string
        unit_label:
          type: string
          description: Libellé accordé à la somme.
        quantity:
          type: number
          description: Somme des quantités.
        estimated_quantity:
          type: number
          description: Somme des quantités estimées.
        open_quantity:
          type: number
          description: Somme des quantités entamées (sans tenir compte du reste).
        closed_quantity:
          type: number
          description: Somme des quantités fermées.
        spaces:
          type: array
          description: Noms distincts des rangements du groupe.
          items:
            type: string
        next_expiry:
          type: [string, "null"]
          format: date
          description: Première date de péremption du groupe.
        days_left:
          type: [integer, "null"]
        expiry_status:
          type: string
          enum: [expired, expiring, ok, unknown]
        item_ids:
          type: array
          description: Identifiants des entrées du groupe.
          items:
            type: integer
    LuluRecipe:
      type: object
      required:
        [id, title, description, servings, prep_minutes, cook_minutes, total_minutes, ingredients,
         steps, tags, source_url, favorite]
      properties:
        id:
          type: integer
        title:
          type: string
        description:
          type: [string, "null"]
        servings:
          type: [number, "null"]
          description: Nombre de parts, au plus deux décimales.
        prep_minutes:
          type: [integer, "null"]
        cook_minutes:
          type: [integer, "null"]
        total_minutes:
          type: [integer, "null"]
        ingredients:
          type: array
          description: |
            Ingrédients analysés, dans l'ordre de la recette. `unit` vaut `null` pour un nombre d'objets
            (« 2 œufs ») ou sans quantité (« sel »).
          items:
            $ref: "#/components/schemas/LuluIngredient"
        steps:
          type: array
          description: Étapes de préparation, dans l'ordre.
          items:
            type: string
        tags:
          type: array
          items:
            type: string
        source_url:
          type: [string, "null"]
          description: Adresse d'origine quand la recette a été importée depuis un site.
        favorite:
          type: boolean
    LuluIngredient:
      type: object
      required: [raw, quantity, unit, name, note, optional]
      properties:
        raw:
          type: string
          description: Ligne d'origine, telle que saisie ou importée.
          examples: ["200 g de farine"]
        quantity:
          type: [number, "null"]
          description: Quantité lue en tête de ligne ; `null` s'il n'y en a pas (« sel »).
        unit:
          type: [string, "null"]
          description: Unité reconnue ; `null` sans unité.
          examples: ["g"]
        name:
          type: string
          description: Nom de l'ingrédient, sans la quantité ni l'unité.
        note:
          type: [string, "null"]
          description: Précision entre parenthèses ou après virgule.
        optional:
          type: boolean
          description: Vrai si la ligne est marquée facultative (« facultatif », « au goût »…).
    LeonItem:
      type: object
      required:
        [id, name, category, category_label, quantity, unit, min_quantity, to_buy, note, restock]
      properties:
        id:
          type: integer
        name:
          type: string
        category:
          type: string
          description: Identifiant de catégorie, à utiliser pour le rapprochement.
        category_label:
          type: string
        quantity:
          type: number
        unit:
          type: string
        min_quantity:
          type: number
          description: Seuil de rachat choisi pour l'article.
        to_buy:
          type: boolean
          description: Article ajouté à la liste de courses.
        note:
          type: [string, "null"]
        restock:
          type: boolean
          description: Vrai si l'article est à racheter (quantité sous le seuil ou ajouté à la liste).
    AnnieItem:
      type: object
      required:
        [id, name, category, category_label, brand, model, store, price_cents, purchased_on,
         warranty_months, warranty_until, warranty_status, days_left]
      properties:
        id:
          type: integer
        name:
          type: string
        category:
          type: string
        category_label:
          type: string
        brand:
          type: [string, "null"]
        model:
          type: [string, "null"]
        store:
          type: [string, "null"]
          description: Enseigne où l'achat a été fait.
        price_cents:
          type: [integer, "null"]
          description: Montant en centimes.
        purchased_on:
          type: [string, "null"]
          format: date
        warranty_months:
          type: [integer, "null"]
        warranty_until:
          type: [string, "null"]
          format: date
        warranty_status:
          type: string
          enum: [active, expiring, expired, none]
          description: "`expiring` : fin de garantie dans 60 jours ou moins."
        days_left:
          type: [integer, "null"]
    Error:
      type: object
      description: |
        Même forme pour toutes les erreurs. `error` est un code stable, à utiliser dans le code client ;
        `message` est une aide en français, susceptible de changer ; `params` est facultatif.
      required: [error, message]
      properties:
        error:
          type: string
          description: Code stable de l'erreur.
          examples: ["scope_missing"]
        message:
          type: string
          description: Message d'aide en français, susceptible de changer.
        params:
          type: object
          description: Précisions propres au code (`module`, `seconds`…).
          additionalProperties: true
