openapi: 3.1.0
info:
  title: SeveUp API
  version: 1.3.0
  summary: Projets, maquettes IFC, révisions, contrôles IDS et exports de SeveUp App.
  description: |
    API publique de SeveUp App, réservée aux plans PRO, BUSINESS, ENTREPRISE et CUSTOM (TRIAL : lecture seule).

    **Authentification.** Une clé d'organisation `sk_…`, créée dans la console d'organisation (section « Clés d'API »),
    envoyée en `Authorization: Bearer sk_…`. Chaque clé porte des scopes ; une route sans le scope requis répond `403`
    avec `code: SCOPE_MISSING`. Aucun secret n'est accepté dans une URL.

    **Erreurs.** Toujours `application/problem+json` (RFC 9457). Le champ `code` est stable : le programme s'appuie
    dessus, jamais sur `title` ou `detail`. Le `type` s'en déduit (`SCOPE_MISSING` donne
    `https://seve-up.com/problems/scope-missing`). Une requête invalide (`VALIDATION`) détaille chaque champ fautif dans
    `errors`. Un identifiant inconnu, mal formé ou d'une autre organisation répond `404` `NOT_FOUND`.

    **Pagination.** Par curseur opaque : `?limit=` (1 à 200, 50 par défaut) et `?cursor=` (valeur de `next_cursor`
    de la page précédente). Fin de liste : `has_more: false` et `next_cursor: null`.

    **Plans.** L'API est ouverte aux plans PRO, BUSINESS, ENTREPRISE et CUSTOM. Le plan d'essai (TRIAL) l'ouvre en lecture
    seule : toute écriture répond `403` `PLAN_READ_ONLY`. Les autres plans répondent `403` `PLAN_REQUIRED`. Un plan
    payant arrivé à échéance reste lisible ; ses écritures répondent `PLAN_READ_ONLY`.

    **Débit.** Chaque réponse porte `RateLimit-Policy` et `RateLimit` (draft-ietf-httpapi-ratelimit-headers), par clé et
    par fenêtre d'une minute : 60 requêtes en essai, 600 en PRO, 1 200 en BUSINESS, 3 000 en ENTREPRISE et CUSTOM.
    Au-delà : `429` `RATE_LIMITED` avec `Retry-After` en secondes.

    **Quotas.** Par organisation et par mois calendaire (UTC) : appels, versions déposées, volume déposé, contrôles
    IDS. Un quota atteint répond `429` `QUOTA_EXCEEDED`, avec `Retry-After` jusqu'au 1er du mois suivant. Un quota se
    consomme avant le lancement du traitement : une requête refusée ne compte pas. Si la vérification est
    momentanément impossible, l'appel répond `503` `BUSY` plutôt que de passer sans être compté.

    **Capacité.** Les opérations marquées `x-heavy` (dépôt, conversion, contrôle IDS, export, synthèses) partagent un
    nombre limité de places ; une organisation a au plus un nombre fixé de traitements actifs (3 en PRO, 5 en
    BUSINESS, 10 en ENTREPRISE et CUSTOM). Place indisponible : `503` `BUSY` avec `Retry-After`. Trop de traitements en
    cours : `429` `QUOTA_EXCEEDED` avec `Retry-After`.

    **Idempotence.** Tout `POST` accepte `Idempotency-Key` (1 à 200 caractères, gardée 24 h par organisation). Rejouer
    la même clé avec le même corps renvoie la réponse d'origine et `Idempotent-Replayed: true` ; avec un autre corps,
    `409` `IDEMPOTENCY_KEY_REUSED`.

    **Traitements longs.** Conversion d'une maquette, contrôle IDS et export répondent `202` avec une ressource
    `Job` ; suivre `GET /api/v1/jobs/{job_id}` ou s'abonner aux webhooks.

    **Types.** Les nombres et booléens sont de vrais types JSON. Les dates sont en RFC 3339 (UTC).
  contact:
    name: SeveUp
    url: https://www.seve-up.app
    email: contact@seve-up.com
  license:
    name: Propriétaire
    identifier: LicenseRef-SeveUp-Proprietary
servers:
  - url: https://api.seve-up.app
    description: Production
    x-internal: false
security:
  - apiKey: []
tags:
  - name: Projets
    description: Projets de l'organisation visibles par la clé.
  - name: Maquettes
    description: Maquettes IFC et leurs révisions.
  - name: Fichiers
    description: Dépôt de fichiers par URL signée, avant création d'une révision ou d'un jeu d'exigences.
  - name: IDS
    description: Jeux d'exigences IDS et contrôles de conformité.
  - name: Exports
    description: Export des données d'une révision.
  - name: Jobs
    description: Suivi des traitements longs.
  - name: Webhooks
    description: Abonnements aux événements de l'organisation.

paths:
  /api/v1/projects:
    get:
      operationId: listProjects
      summary: Lister les projets
      tags: [Projets]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: q
          in: query
          description: Filtre sur le nom (contient, sans casse).
          schema: { type: string, maxLength: 200 }
      responses:
        "200":
          description: Une page de projets.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Project" }
              example:
                data:
                  - id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                    name: LNPCA — Section 1
                    description: Ligne nouvelle Provence Côte d'Azur
                    space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
                    model_count: 197
                    requirement_set_count: 4
                    start_date: "2026-01-05T00:00:00Z"
                    end_date: null
                    created_at: "2026-01-05T09:12:44Z"
                    updated_at: "2026-10-20T16:02:10Z"
                next_cursor: eyJpZCI6IjViMGYzYzNlIn0
                has_more: true
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: getProject
      summary: Lire un projet
      tags: [Projets]
      security:
        - apiKey: []
      x-required-scopes: [projects:read]
      responses:
        "200":
          description: Le projet.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Project" }
              example:
                id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                name: LNPCA — Section 1
                description: Ligne nouvelle Provence Côte d'Azur
                space_id: 0b8a2c47-5a0e-4a7e-8f0d-0c6d2b7e9c31
                model_count: 197
                requirement_set_count: 4
                start_date: "2026-01-05T00:00:00Z"
                end_date: null
                created_at: "2026-01-05T09:12:44Z"
                updated_at: "2026-10-20T16:02:10Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}/models:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: listProjectModels
      summary: Lister les maquettes d'un projet
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de maquettes.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Model" }
              example:
                data:
                  - id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                    name: LNPCA-S1-OA-PONT-0042
                    format: IFC
                    revision_count: 3
                    latest_revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                    created_at: "2026-03-02T10:00:00Z"
                    updated_at: "2026-10-18T08:41:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createModel
      summary: Créer une maquette depuis un fichier déposé
      description: |
        Crée la maquette et sa première révision à partir d'un fichier déposé (`POST /api/v1/uploads` puis
        `complete`). La conversion démarre aussitôt : la réponse est le job de conversion.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:write]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ModelCreate" }
            example:
              name: LNPCA-S1-OA-PONT-0042
              upload_id: 7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/models/{model_id}:
    parameters:
      - $ref: "#/components/parameters/ModelId"
    get:
      operationId: getModel
      summary: Lire une maquette
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      responses:
        "200":
          description: La maquette.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Model" }
              example:
                id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                name: LNPCA-S1-OA-PONT-0042
                format: IFC
                revision_count: 3
                latest_revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                created_at: "2026-03-02T10:00:00Z"
                updated_at: "2026-10-18T08:41:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/models/{model_id}/revisions:
    parameters:
      - $ref: "#/components/parameters/ModelId"
    get:
      operationId: listModelRevisions
      summary: Lister les révisions d'une maquette
      description: Révisions de la plus récente à la plus ancienne.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de révisions.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Revision" }
              example:
                data:
                  - id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                    model_id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                    version: 3
                    file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
                    size_bytes: 184467200
                    ifc_schema: IFC4X3_ADD2
                    authoring_software: Revit 2025
                    status: COMPLETE
                    processing:
                      conversion: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                      dataset: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                      scene: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                    object_count: 44213
                    created_at: "2026-10-18T08:41:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createRevision
      summary: Ajouter une révision depuis un fichier déposé
      description: Nouvelle version de la maquette. La conversion démarre aussitôt ; la réponse est le job.
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:write]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RevisionCreate" }
            example:
              upload_id: 7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b
              milestone: APD
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/revisions/{revision_id}:
    parameters:
      - $ref: "#/components/parameters/RevisionId"
    get:
      operationId: getRevision
      summary: Lire une révision et son état de traitement
      tags: [Maquettes]
      security:
        - apiKey: []
      x-required-scopes: [models:read]
      responses:
        "200":
          description: La révision.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Revision" }
              example:
                id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                model_id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                version: 3
                file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
                size_bytes: 184467200
                ifc_schema: IFC4X3_ADD2
                authoring_software: Revit 2025
                status: PROCESSING
                processing:
                  conversion: { status: COMPLETE, percent_complete: 100, eta_seconds: null, error_message: null }
                  dataset: { status: PROCESSING, percent_complete: 42, eta_seconds: 180, error_message: null }
                  scene: { status: QUEUED, percent_complete: 0, eta_seconds: null, error_message: null }
                object_count: null
                created_at: "2026-10-18T08:41:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/revisions/{revision_id}/ids-results:
    parameters:
      - $ref: "#/components/parameters/RevisionId"
    get:
      operationId: listRevisionIdsResults
      summary: Résultats IDS d'une révision
      description: |
        Un résultat par jeu d'exigences lié à la révision, avec le détail par spécification.
        Une spécification est comptée dans le score si `applicable` est vrai. Une exigence de présence non
        satisfaite a `applicable: true` et `entity_count_applicable: 0` : elle échoue, elle n'est pas « sans objet ».
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      x-heavy: true
      responses:
        "200":
          description: Les résultats.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items: { $ref: "#/components/schemas/IdsResult" }
              example:
                data:
                  - requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                    requirement_set_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                    status: COMPLETE
                    score_percent: 87.5
                    specification_count: 8
                    specification_count_applicable: 8
                    specification_count_failed: 1
                    completed_at: "2026-10-18T09:02:11Z"
                    specifications:
                      - index: 0
                        name: Classification des ouvrages d'art
                        status: PARTIAL
                        applicable: true
                        score_percent: 91.2
                        entity_count_applicable: 1250
                        entity_count_valid: 1140
                        entity_count_invalid: 110
                      - index: 1
                        name: Présence d'un IfcSite géoréférencé
                        status: FAIL
                        applicable: true
                        score_percent: 0
                        entity_count_applicable: 0
                        entity_count_valid: 0
                        entity_count_invalid: 0
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/projects/{project_id}/ids-summary:
    parameters:
      - $ref: "#/components/parameters/ProjectId"
    get:
      operationId: getProjectIdsSummary
      summary: Synthèse IDS d'un projet (dernière révision de chaque maquette)
      description: Agrégé côté serveur ; une seule requête, quel que soit le nombre de maquettes.
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      x-heavy: true
      responses:
        "200":
          description: La synthèse.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/IdsSummary" }
              example:
                project_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                model_count: 197
                model_count_checked: 190
                model_count_in_progress: 5
                model_count_error: 2
                score_percent: 82.4
                by_requirement_set:
                  - requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                    name: Exigences BIM LNPCA v3
                    model_count_checked: 190
                    score_percent: 82.4
                computed_at: "2026-10-21T07:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/revisions/{revision_id}/exports:
    parameters:
      - $ref: "#/components/parameters/RevisionId"
    post:
      operationId: createRevisionExport
      summary: Exporter les données d'une révision
      description: |
        Prépare une archive zip, un fichier par table (CSV par défaut, ou JSON avec `format: json`). Le jeu de
        données de la révision doit être terminé (`409` sinon). Quand le job est `COMPLETE`, son `result` donne une
        URL de téléchargement signée valable une heure, renouvelée à chaque lecture du job ; l'archive est gardée
        48 h. Une table en échec est absente de l'archive et nommée dans `error` (code `EXPORT_PARTIAL`).
      tags: [Exports]
      security:
        - apiKey: []
      x-required-scopes: [exports:read]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ExportCreate" }
            example:
              tables: [assets, properties, qto]
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/uploads:
    post:
      operationId: createUpload
      summary: Ouvrir un dépôt de fichier
      description: |
        Renvoie un point d'entrée TUS (reprise possible) et les en-têtes à joindre à chaque requête TUS
        (`Authorization`, `Tus-Resumable`, `Upload-Metadata`). Le jeton de dépôt est valable 2 h (`expires_at`) :
        au-delà, ouvrir un nouveau dépôt. Le fichier ne transite pas par l'API. Une fois le fichier envoyé, appeler
        `complete`. Taille maximale selon le plan : `413` au-delà. Avec `purpose: model`, la clé doit porter
        `models:write` ; avec `purpose: ids`, `ids:write`. Extensions : `.ifc` pour une maquette, `.ids` ou `.xlsx`
        pour un jeu d'exigences.
      tags: [Fichiers]
      security:
        - apiKey: []
      x-required-scopes: [models:write, ids:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UploadCreate" }
            example:
              file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
              size_bytes: 184467200
              purpose: model
      responses:
        "201":
          description: Dépôt ouvert.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Upload" }
              example:
                id: 7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b
                status: PENDING
                file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
                size_bytes: 184467200
                purpose: model
                upload_url: https://ooddxuvlammhnltcfqpm.supabase.co/storage/v1/upload/resumable
                upload_headers:
                  Authorization: Bearer eyJhbGciOi…
                  Tus-Resumable: 1.0.0
                  Upload-Metadata: bucketName YXR0YWNobWVudHM=,objectName …
                expires_at: "2026-10-21T10:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "413": { $ref: "#/components/responses/PayloadTooLarge" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/uploads/{upload_id}/complete:
    parameters:
      - $ref: "#/components/parameters/UploadId"
    post:
      operationId: completeUpload
      summary: Clore un dépôt de fichier
      description: |
        Vérifie que le fichier est arrivé en entier (taille annoncée). L'empreinte MD5 fournie est conservée avec le
        fichier ; elle n'est pas recalculée. `409` si le fichier n'est pas encore complet ou si le dépôt a expiré.
        Rappeler `complete` sur un dépôt déjà clos renvoie le même résultat.
      tags: [Fichiers]
      security:
        - apiKey: []
      x-required-scopes: [models:write, ids:write]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                md5_hex:
                  type: string
                  pattern: "^[a-f0-9]{32}$"
                  description: Empreinte MD5 du fichier envoyé, conservée avec lui (non recalculée).
            example:
              md5_hex: 9e107d9d372bb6826bd81d3542a419d6
      responses:
        "200":
          description: Dépôt clos ; le fichier peut servir à une maquette ou un jeu d'exigences.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Upload" }
              example:
                id: 7a1c2e3f-4b5d-4e6f-8a9b-0c1d2e3f4a5b
                status: COMPLETE
                file_name: LNPCA-S1-OA-PONT-0042_C03.ifc
                size_bytes: 184467200
                purpose: model
                upload_url: null
                upload_headers: null
                expires_at: "2026-10-21T10:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/requirement-sets:
    get:
      operationId: listRequirementSets
      summary: Lister les jeux d'exigences IDS
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: project_id
          in: query
          description: Jeux liés à ce projet seulement ; projet inconnu ou d'une autre organisation, 404.
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Une page de jeux d'exigences.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/RequirementSet" }
              example:
                data:
                  - id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                    name: Exigences BIM LNPCA v3
                    format: IDS
                    scope: PROJECT
                    scope_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                    latest_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                    specification_count: 8
                    created_at: "2026-02-10T14:00:00Z"
                    updated_at: "2026-09-30T11:20:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createRequirementSet
      summary: Créer un jeu d'exigences depuis un fichier IDS déposé
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:write]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RequirementSetCreate" }
            example:
              name: Exigences BIM LNPCA v3
              upload_id: 8b2d3f4a-5c6e-4f7a-9b0c-1d2e3f4a5b6c
              project_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
      responses:
        "201":
          description: Jeu créé.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RequirementSet" }
              example:
                id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                name: Exigences BIM LNPCA v3
                format: IDS
                scope: PROJECT
                scope_id: 5b0f3c3e-1d7a-4d0c-9a51-3f2b8f1e7a10
                latest_revision_id: 3d4e5f6a-7b8c-4d9e-8f1a-2b3c4d5e6f70
                specification_count: 8
                created_at: "2026-10-21T08:00:00Z"
                updated_at: "2026-10-21T08:00:00Z"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/ids-checks:
    post:
      operationId: createIdsCheck
      summary: Contrôler une révision avec un jeu d'exigences
      description: |
        Lance le contrôle (ou le relance si la paire a déjà un résultat). Si le jeu n'est rattaché ni à la maquette
        ni à un projet qui la contient, il est rattaché à la maquette, comme depuis l'application : ses autres
        révisions seront contrôlées aussi. Le jeu contrôlé est la version épinglée par le rattachement, sinon la
        dernière. Le résultat se lit ensuite dans `GET /api/v1/revisions/{revision_id}/ids-results`. Compte dans le
        quota mensuel de contrôles du plan. `400` si la révision ou le jeu est inconnu dans l'organisation, `409`
        si le jeu n'a aucune révision ou si la conversion de la révision a échoué.
      tags: [IDS]
      security:
        - apiKey: []
      x-required-scopes: [ids:write]
      x-heavy: true
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/IdsCheckCreate" }
            example:
              revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
              requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
      responses:
        "202": { $ref: "#/components/responses/JobAccepted" }
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/jobs/{job_id}:
    parameters:
      - $ref: "#/components/parameters/JobId"
    get:
      operationId: getJob
      summary: Suivre un traitement long
      description: |
        Un job de conversion a pour identifiant celui de la révision ; son statut couvre conversion, jeu de données
        et scène. Scope exigé selon le type : `models:read` (conversion), `ids:read` (contrôle IDS), `exports:read`
        (export). Codes d'`error` : `PROCESSING_FAILED`, `IDS_CHECK_FAILED`, `EXPORT_FAILED`, `EXPORT_EXPIRED`,
        `EXPORT_UNAVAILABLE`, et `EXPORT_PARTIAL` sur un export `COMPLETE` dont des tables manquent.
      tags: [Jobs]
      security:
        - apiKey: []
      x-required-scopes: [models:read, ids:read, exports:read]
      responses:
        "200":
          description: Le job.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
              example:
                id: 1f2e3d4c-5b6a-4978-8a1b-2c3d4e5f6a7b
                type: export
                status: COMPLETE
                percent_complete: 100
                eta_seconds: null
                target: { type: revision, id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b }
                result:
                  download_url: https://ooddxuvlammhnltcfqpm.supabase.co/storage/v1/object/sign/exports/dataset-4f6a1b2c.zip?token=eyJhbGciOi
                  download_expires_at: "2026-10-21T09:00:00Z"
                  size_bytes: 52428800
                error: null
                created_at: "2026-10-21T07:58:00Z"
                started_at: "2026-10-21T07:58:02Z"
                completed_at: "2026-10-21T07:59:40Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/webhooks:
    get:
      operationId: listWebhooks
      summary: Lister les abonnements webhook
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page d'abonnements.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/Webhook" }
              example:
                data:
                  - id: 6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b
                    name: ERP chantier
                    url: https://erp.example.com/hooks/seveup
                    events: [revision.processing.completed, ids_check.completed]
                    active: true
                    last_delivered_at: "2026-10-20T18:00:00Z"
                    last_status: 200
                    created_at: "2026-09-01T10:00:00Z"
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }
    post:
      operationId: createWebhook
      summary: Créer un abonnement webhook
      description: Le secret de signature n'est rendu qu'ici, une seule fois.
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookCreate" }
            example:
              name: ERP chantier
              url: https://erp.example.com/hooks/seveup
              events: [revision.processing.completed, ids_check.completed]
      responses:
        "201":
          description: Abonnement créé.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Webhook"
                  - type: object
                    required: [secret]
                    properties:
                      secret:
                        type: string
                        description: Secret Standard Webhooks (`whsec_…`), montré une seule fois.
              example:
                id: 6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b
                name: ERP chantier
                url: https://erp.example.com/hooks/seveup
                events: [revision.processing.completed, ids_check.completed]
                active: true
                last_delivered_at: null
                last_status: null
                created_at: "2026-10-21T08:00:00Z"
                secret: "whsec_<montré une seule fois>"
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/webhooks/{webhook_id}:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
    delete:
      operationId: deleteWebhook
      summary: Supprimer un abonnement webhook
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      responses:
        "204":
          description: Abonnement supprimé, avec son journal ; les livraisons en attente ne partent plus.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/webhooks/{webhook_id}/deliveries:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
    get:
      operationId: listWebhookDeliveries
      summary: Journal de livraison d'un abonnement
      description: Les livraisons des 30 derniers jours, les plus récentes d'abord.
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Une page de livraisons.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Page"
                  - type: object
                    properties:
                      data:
                        type: array
                        items: { $ref: "#/components/schemas/WebhookDelivery" }
              example:
                data:
                  - id: 0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d
                    event: ids_check.completed
                    status: delivered
                    attempts: 1
                    response_code: 200
                    error: null
                    created_at: "2026-10-21T09:02:11Z"
                    delivered_at: "2026-10-21T09:02:12Z"
                    next_attempt_at: null
                next_cursor: null
                has_more: false
        "400": { $ref: "#/components/responses/ValidationFailed" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

  /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retry:
    parameters:
      - $ref: "#/components/parameters/WebhookId"
      - $ref: "#/components/parameters/DeliveryId"
    post:
      operationId: retryWebhookDelivery
      summary: Rejouer une livraison
      description: |
        Remet la livraison en file, avec le même `webhook-id` : un destinataire qui l'avait déjà traitée la reconnaît.
        Une livraison encore en attente est rendue telle quelle. `409` si l'abonnement est désactivé ou si le corps de
        l'événement n'est plus conservé.
      tags: [Webhooks]
      security:
        - apiKey: []
      x-required-scopes: [webhooks:manage]
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      responses:
        "202":
          description: Livraison remise en file.
          headers:
            RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
            RateLimit: { $ref: "#/components/headers/RateLimit" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookDelivery" }
              example:
                id: 0b1c2d3e-4f50-4a6b-8c7d-9e0f1a2b3c4d
                event: ids_check.completed
                status: pending
                attempts: 0
                response_code: 401
                error: "HTTP 401"
                created_at: "2026-10-21T09:02:11Z"
                delivered_at: null
                next_attempt_at: "2026-10-22T07:20:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { $ref: "#/components/responses/Conflict" }
        "429": { $ref: "#/components/responses/TooManyRequests" }
        "500": { $ref: "#/components/responses/InternalError" }
        "503": { $ref: "#/components/responses/ServiceUnavailable" }

webhooks:
  revision.processing.completed:
    post:
      operationId: onRevisionProcessingCompleted
      summary: Une révision est entièrement traitée
      description: |
        Livraison au format Standard Webhooks : en-têtes `webhook-id` (stable entre les essais), `webhook-timestamp`
        (secondes Unix) et `webhook-signature` (`v1,<base64 HMAC-SHA256(clé, id.timestamp.corps)>`, la clé étant le
        secret `whsec_` décodé du base64). Les bibliothèques Standard Webhooks vérifient tout cela. Refuser un
        horodatage de plus de 5 minutes. Répondre `2xx` en moins de 10 s ; sinon nouvel essai à délai croissant (1 min,
        5 min, 30 min, 2 h), 5 essais en tout. Un `4xx` autre que `408` et `429` arrête les essais ; une redirection
        n'est jamais suivie. `job_id`, quand il est présent, est celui que `GET /api/v1/jobs/{job_id}` suit.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: revision.processing.completed
              timestamp: "2026-10-18T08:55:00Z"
              data:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                model_id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                job_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                status: COMPLETE
      responses:
        "200":
          description: Reçu.
  revision.processing.failed:
    post:
      operationId: onRevisionProcessingFailed
      summary: Le traitement d'une révision a échoué
      description: |
        Envoyé quand une étape passe en `ERROR`, comme le montre `GET /api/v1/revisions/{revision_id}`. Une relance
        automatique qui réussit ensuite donne `revision.processing.completed`.
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: revision.processing.failed
              timestamp: "2026-10-18T08:55:00Z"
              data:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                model_id: 9d2e7f10-6c4b-4f2a-8e11-2a7c5b3d9e01
                job_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                status: ERROR
                error_message: Fichier IFC illisible
      responses:
        "200":
          description: Reçu.
  ids_check.completed:
    post:
      operationId: onIdsCheckCompleted
      summary: Un contrôle IDS est terminé
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: ids_check.completed
              timestamp: "2026-10-18T09:02:11Z"
              data:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                requirement_revision_id: 3d4e5f6a-7b8c-4d9e-8f0a-1b2c3d4e5f6a
                job_id: 5e6f7a8b-9c0d-4e1f-8a2b-3c4d5e6f7a8b
                status: COMPLETE
                score_percent: 87.5
      responses:
        "200":
          description: Reçu.
  ids_check.failed:
    post:
      operationId: onIdsCheckFailed
      summary: Un contrôle IDS a échoué
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: ids_check.failed
              timestamp: "2026-10-18T09:02:11Z"
              data:
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
                requirement_set_id: 2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f
                requirement_revision_id: 3d4e5f6a-7b8c-4d9e-8f0a-1b2c3d4e5f6a
                status: ERROR
                error_message: Le contrôle n'a pas produit de résultat dans le délai
      responses:
        "200":
          description: Reçu.
  export.ready:
    post:
      operationId: onExportReady
      summary: Un export est prêt à télécharger
      tags: [Webhooks]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WebhookEvent" }
            example:
              type: export.ready
              timestamp: "2026-10-21T07:59:40Z"
              data:
                job_id: 1f2e3d4c-5b6a-4978-8a1b-2c3d4e5f6a7b
                revision_id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b
      responses:
        "200":
          description: Reçu.

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: sk_…
      description: |
        Clé d'organisation. Scopes : `projects:read`, `models:read`, `models:write`, `ids:read`, `ids:write`,
        `exports:read`, `webhooks:manage`. Une clé `powerbi:read` n'ouvre pas cette API.

  parameters:
    Limit:
      name: limit
      in: query
      description: Taille de page.
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    Cursor:
      name: cursor
      in: query
      description: Curseur opaque rendu par la page précédente (`next_cursor`).
      schema: { type: string, maxLength: 512 }
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: Clé d'idempotence, gardée 24 h par organisation.
      schema: { type: string, minLength: 1, maxLength: 200 }
    ProjectId:
      name: project_id
      in: path
      required: true
      description: "Identifiant du projet (`id` rendu par `GET /api/v1/projects`)."
      schema: { type: string, format: uuid }
    ModelId:
      name: model_id
      in: path
      required: true
      description: "Identifiant de la maquette (`id` rendu par la liste des maquettes du projet)."
      schema: { type: string, format: uuid }
    RevisionId:
      name: revision_id
      in: path
      required: true
      description: "Identifiant de la révision (`latest_revision_id` d'une maquette, ou `id` d'une révision)."
      schema: { type: string, format: uuid }
    UploadId:
      name: upload_id
      in: path
      required: true
      description: "Identifiant du dépôt rendu par `POST /api/v1/uploads`."
      schema: { type: string, format: uuid }
    JobId:
      name: job_id
      in: path
      required: true
      description: "Identifiant du job, rendu par toute opération qui répond `202` (aussi dans l'en-tête `Location`)."
      schema: { type: string, format: uuid }
    WebhookId:
      name: webhook_id
      in: path
      required: true
      description: "Identifiant de l'abonnement webhook."
      schema: { type: string, format: uuid }
    DeliveryId:
      name: delivery_id
      in: path
      required: true
      description: "Identifiant de la livraison, lu dans le journal de livraison de l'abonnement."
      schema: { type: string, format: uuid }

  headers:
    RateLimitPolicy:
      description: Politique de débit de la clé (draft-ietf-httpapi-ratelimit-headers), ex. `"default";q=600;w=60`.
      schema: { type: string }
    RateLimit:
      description: Débit restant dans la fenêtre courante, ex. `"default";r=598;t=42`.
      schema: { type: string }
    RetryAfter:
      description: Secondes à attendre avant de réessayer.
      schema: { type: integer, format: int32, minimum: 1 }
    Location:
      description: URL de la ressource créée ou du job.
      schema: { type: string, format: uri }
    IdempotentReplayed:
      description: "`true` si la réponse rejoue une requête déjà reçue avec la même Idempotency-Key."
      schema: { type: boolean }

  responses:
    JobAccepted:
      description: Traitement accepté ; suivre le job.
      headers:
        Location: { $ref: "#/components/headers/Location" }
        Idempotent-Replayed: { $ref: "#/components/headers/IdempotentReplayed" }
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Job" }
          example:
            id: 1f2e3d4c-5b6a-4978-8a1b-2c3d4e5f6a7b
            type: conversion
            status: QUEUED
            percent_complete: 0
            eta_seconds: null
            target: { type: revision, id: 4f6a1b2c-3d4e-4f50-8a6b-7c8d9e0f1a2b }
            result: null
            error: null
            created_at: "2026-10-21T07:58:00Z"
            started_at: null
            completed_at: null
    ValidationFailed:
      description: Requête invalide.
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/validation
            title: Bad Request
            status: 400
            code: VALIDATION
            detail: "limit : doit être compris entre 1 et 200."
            instance: /api/v1/projects
            errors:
              - field: limit
                message: doit être compris entre 1 et 200
    Unauthorized:
      description: Clé absente, inconnue, expirée ou révoquée.
      headers:
        WWW-Authenticate:
          description: "`Bearer`, ou `Bearer error=\"invalid_token\"` pour une clé refusée."
          schema: { type: string }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/unauthorized
            title: Unauthorized
            status: 401
            code: UNAUTHORIZED
            detail: "Clé d'API requise : Authorization: Bearer sk_…"
    Forbidden:
      description: |
        Scope manquant (`SCOPE_MISSING`), plan sans accès à l'API (`PLAN_REQUIRED`), écriture refusée en plan TRIAL
        (`PLAN_READ_ONLY`) ou route non ouverte aux clés (`API_KEY_ROUTE_FORBIDDEN`).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/plan-required
            title: Forbidden
            status: 403
            code: PLAN_REQUIRED
            detail: L'API est incluse dans les plans PRO, BUSINESS, ENTREPRISE et CUSTOM.
    NotFound:
      description: Ressource inexistante ou appartenant à une autre organisation (jamais distingué).
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/not-found
            title: Not Found
            status: 404
            code: NOT_FOUND
            detail: Ressource introuvable.
    Conflict:
      description: Idempotency-Key déjà utilisée avec un autre corps, ou état incompatible.
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/conflict-idempotency
            title: Conflict
            status: 409
            code: IDEMPOTENCY_KEY_REUSED
            detail: Cette Idempotency-Key a déjà servi pour une requête différente.
    PayloadTooLarge:
      description: Fichier plus gros que la limite du plan.
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/payload-too-large
            title: Payload Too Large
            status: 413
            code: FILE_TOO_LARGE
            detail: "Taille maximale pour ce plan : 3 Gio."
    TooManyRequests:
      description: Débit de la clé dépassé (`RATE_LIMITED`) ou quota mensuel du plan atteint (`QUOTA_EXCEEDED`).
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/rate-limited
            title: Too Many Requests
            status: 429
            code: RATE_LIMITED
            detail: 600 requêtes par minute au plus pour cette clé ; réessayer dans 12 s.
    Gone:
      description: Opération retirée à sa date `x-sunset`, annoncée au moins 6 mois avant (en-têtes `Deprecation` et `Sunset`).
      headers:
        RateLimit-Policy: { $ref: "#/components/headers/RateLimitPolicy" }
        RateLimit: { $ref: "#/components/headers/RateLimit" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/gone
            title: Gone
            status: 410
            code: GONE
            detail: Opération retirée le 2027-05-01, comme annoncé dans le journal des changements.
    InternalError:
      description: Erreur inattendue du serveur, déjà journalisée de notre côté ; réessayer plus tard.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/internal-error
            title: Internal Server Error
            status: 500
            code: INTERNAL_ERROR
            detail: Erreur interne ; réessayer plus tard.
    ServiceUnavailable:
      description: Capacité de traitement saturée pour l'instant ; réessayer après `Retry-After`.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
          example:
            type: https://seve-up.com/problems/busy
            title: Service Unavailable
            status: 503
            code: BUSY
            detail: Trop de traitements en cours pour cette organisation ; réessayer dans 30 s.

  schemas:
    Problem:
      type: object
      description: Erreur RFC 9457.
      required: [type, title, status, code]
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer, format: int32, minimum: 400, maximum: 599 }
        code:
          type: string
          description: |
            Code stable, en majuscules. Liste ouverte : un nouveau code peut apparaître dans la v1 ; traiter un code
            inconnu d'après le statut HTTP.
          x-extensible-enum:
            - VALIDATION
            - UNAUTHORIZED
            - INVALID_API_KEY
            - SCOPE_MISSING
            - API_KEY_ROUTE_FORBIDDEN
            - PLAN_REQUIRED
            - PLAN_READ_ONLY
            - NOT_FOUND
            - IDEMPOTENCY_KEY_REUSED
            - CONFLICT
            - FILE_TOO_LARGE
            - RATE_LIMITED
            - QUOTA_EXCEEDED
            - BUSY
            - INTERNAL_ERROR
            - GONE
        detail: { type: string }
        instance: { type: string }
        errors:
          type: array
          description: Détail par champ, pour `VALIDATION`.
          items:
            type: object
            required: [field, message]
            properties:
              field: { type: string }
              message: { type: string }

    Page:
      type: object
      required: [data, next_cursor, has_more]
      properties:
        data:
          type: array
          items: {}
        next_cursor:
          type: [string, "null"]
          description: À passer en `cursor` pour la page suivante ; nul en fin de liste.
        has_more: { type: boolean }

    ProcessingStatus:
      type: string
      description: |
        `STANDBY` en attente d'un déclencheur, `READY_FOR_PROCESSING` prêt, `QUEUED` en file, `PROCESSING` en cours,
        `COMPLETE` terminé, `ERROR` en échec.
      enum: [STANDBY, READY_FOR_PROCESSING, QUEUED, PROCESSING, COMPLETE, ERROR]

    Project:
      type: object
      required: [id, name, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        description: { type: [string, "null"] }
        space_id:
          type: [string, "null"]
          format: uuid
          description: Espace (regroupement de projets) qui contient le projet.
        model_count: { type: integer, format: int32, minimum: 0 }
        requirement_set_count: { type: integer, format: int32, minimum: 0 }
        start_date: { type: [string, "null"], format: date-time }
        end_date: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    Model:
      type: object
      required: [id, name, format, revision_count, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        format: { type: string, enum: [IFC, REVIT] }
        revision_count: { type: integer, format: int32, minimum: 0 }
        latest_revision_id: { type: [string, "null"], format: uuid }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    ModelCreate:
      type: object
      required: [name, upload_id]
      properties:
        name: { type: string, minLength: 1, maxLength: 256 }
        upload_id:
          type: string
          format: uuid
          description: "Dépôt clos (`purpose: model`)."

    StepStatus:
      type: object
      required: [status]
      properties:
        status: { $ref: "#/components/schemas/ProcessingStatus" }
        percent_complete: { type: [number, "null"], minimum: 0, maximum: 100 }
        eta_seconds: { type: [number, "null"], minimum: 0 }
        error_message: { type: [string, "null"] }

    Revision:
      type: object
      required: [id, model_id, version, status, processing, created_at]
      properties:
        id: { type: string, format: uuid }
        model_id: { type: string, format: uuid }
        version: { type: integer, format: int32, minimum: 1 }
        milestone: { type: [string, "null"], description: "Jalon (APS, APD, PRO, EXE…)." }
        file_name: { type: [string, "null"] }
        size_bytes: { type: [integer, "null"], format: int64, minimum: 0 }
        ifc_schema: { type: [string, "null"], description: "Ex. IFC2X3, IFC4, IFC4X3_ADD2." }
        authoring_software: { type: [string, "null"] }
        status:
          $ref: "#/components/schemas/ProcessingStatus"
          description: État global, le moins avancé des étapes.
        processing:
          type: object
          required: [conversion, dataset, scene]
          properties:
            conversion: { $ref: "#/components/schemas/StepStatus" }
            dataset: { $ref: "#/components/schemas/StepStatus" }
            scene: { $ref: "#/components/schemas/StepStatus" }
        object_count:
          type: [integer, "null"]
          format: int32
          minimum: 0
          description: Nombre d'objets du jeu de données ; nul tant que `dataset` n'est pas `COMPLETE`.
        created_at: { type: string, format: date-time }

    RevisionCreate:
      type: object
      required: [upload_id]
      properties:
        upload_id: { type: string, format: uuid }
        milestone: { type: string, maxLength: 100 }

    IdsSpecificationResult:
      type: object
      required: [index, name, status, applicable]
      properties:
        index: { type: integer, format: int32, minimum: 0 }
        name: { type: string }
        status:
          type: string
          description: "`NA` : sans objet pour cette maquette, non compté dans le score."
          enum: [PASS, PARTIAL, FAIL, NA, MISMATCH]
        applicable:
          type: boolean
          description: Compté dans le score. Seul critère d'applicabilité, jamais `entity_count_applicable > 0`.
        score_percent: { type: [number, "null"], minimum: 0, maximum: 100 }
        entity_count_applicable: { type: integer, format: int32, minimum: 0 }
        entity_count_valid: { type: integer, format: int32, minimum: 0 }
        entity_count_invalid: { type: integer, format: int32, minimum: 0 }

    IdsResult:
      type: object
      required: [requirement_set_id, requirement_set_revision_id, status]
      properties:
        requirement_set_id: { type: string, format: uuid }
        requirement_set_revision_id: { type: string, format: uuid }
        status: { $ref: "#/components/schemas/ProcessingStatus" }
        score_percent: { type: [number, "null"], minimum: 0, maximum: 100 }
        specification_count: { type: [integer, "null"], format: int32, minimum: 0 }
        specification_count_applicable: { type: [integer, "null"], format: int32, minimum: 0 }
        specification_count_failed: { type: [integer, "null"], format: int32, minimum: 0 }
        completed_at: { type: [string, "null"], format: date-time }
        specifications:
          type: array
          description: Vide tant que `status` n'est pas `COMPLETE`.
          items: { $ref: "#/components/schemas/IdsSpecificationResult" }

    IdsSummary:
      type: object
      required: [project_id, model_count, computed_at]
      properties:
        project_id: { type: string, format: uuid }
        model_count: { type: integer, format: int32, minimum: 0 }
        model_count_checked: { type: integer, format: int32, minimum: 0 }
        model_count_in_progress: { type: integer, format: int32, minimum: 0 }
        model_count_error: { type: integer, format: int32, minimum: 0 }
        score_percent:
          type: [number, "null"]
          minimum: 0
          maximum: 100
          description: Moyenne des maquettes contrôlées ; nul si aucune.
        by_requirement_set:
          type: array
          items:
            type: object
            required: [requirement_set_id, name]
            properties:
              requirement_set_id: { type: string, format: uuid }
              name: { type: string }
              model_count_checked: { type: integer, format: int32, minimum: 0 }
              score_percent: { type: [number, "null"], minimum: 0, maximum: 100 }
        computed_at: { type: string, format: date-time }

    RequirementSet:
      type: object
      required: [id, name, format, scope, created_at, updated_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        format: { type: string, enum: [IDS, SEVEUP] }
        scope:
          type: string
          description: Portée du jeu (organisation, espace ou projet).
          enum: [ORGANIZATION, SPACE, PROJECT]
        scope_id: { type: [string, "null"], format: uuid }
        latest_revision_id: { type: [string, "null"], format: uuid }
        specification_count: { type: [integer, "null"], format: int32, minimum: 0 }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }

    RequirementSetCreate:
      type: object
      required: [name, upload_id]
      properties:
        name: { type: string, minLength: 1, maxLength: 256 }
        upload_id: { type: string, format: uuid, description: "Dépôt clos (`purpose: ids`), fichier .ids ou .xlsx." }
        project_id: { type: string, format: uuid, description: Lie le jeu à ce projet ; sinon portée organisation. }

    IdsCheckCreate:
      type: object
      required: [revision_id, requirement_set_id]
      properties:
        revision_id: { type: string, format: uuid }
        requirement_set_id: { type: string, format: uuid }

    ExportCreate:
      type: object
      properties:
        tables:
          type: array
          description: Tables à inclure ; toutes par défaut.
          uniqueItems: true
          items:
            type: string
            enum:
              - assets
              - asset_types
              - classification
              - revisions
              - relations
              - asset_relations
              - spatial_relation
              - asset_tree
              - asset_logical
              - space_boundary_relation
              - properties
              - qto
              - materials
              - asset_placement
        format:
          type: string
          description: Format des fichiers de l'archive.
          enum: [csv, json]
          default: csv

    UploadCreate:
      type: object
      required: [file_name, size_bytes, purpose]
      properties:
        file_name: { type: string, minLength: 1, maxLength: 256 }
        size_bytes:
          type: integer
          minimum: 1
          description: Taille du fichier en octets. Au-delà de la limite du plan (3 Gio au plus), réponse `413`.
        purpose:
          type: string
          description: "`model` pour une maquette IFC, `ids` pour un jeu d'exigences."
          enum: [model, ids]

    Upload:
      type: object
      required: [id, status, file_name, size_bytes, purpose, expires_at]
      properties:
        id: { type: string, format: uuid }
        status: { type: string, enum: [PENDING, COMPLETE] }
        file_name: { type: string }
        size_bytes: { type: integer, format: int64, minimum: 1 }
        purpose: { type: string, enum: [model, ids] }
        upload_url:
          type: [string, "null"]
          format: uri
          description: Point d'entrée TUS ; nul une fois le dépôt clos.
        upload_headers:
          type: [object, "null"]
          description: En-têtes à joindre aux requêtes TUS.
          additionalProperties: { type: string }
        expires_at: { type: string, format: date-time }

    Job:
      type: object
      required: [id, type, status, target, created_at]
      properties:
        id: { type: string, format: uuid }
        type: { type: string, enum: [conversion, ids_check, export] }
        status: { $ref: "#/components/schemas/ProcessingStatus" }
        percent_complete: { type: [number, "null"], minimum: 0, maximum: 100 }
        eta_seconds: { type: [number, "null"], minimum: 0 }
        target:
          type: object
          required: [type, id]
          properties:
            type: { type: string, enum: [revision, ids_check] }
            id: { type: string, format: uuid }
        result:
          type: [object, "null"]
          description: "Export : URL signée et taille. Conversion et contrôle IDS : nul, lire la révision ou ses résultats IDS."
          properties:
            download_url: { type: string, format: uri }
            download_expires_at: { type: string, format: date-time }
            size_bytes: { type: integer, format: int64, minimum: 0 }
        error:
          type: [object, "null"]
          properties:
            code: { type: string }
            message: { type: string }
        created_at: { type: string, format: date-time }
        started_at: { type: [string, "null"], format: date-time }
        completed_at: { type: [string, "null"], format: date-time }

    WebhookEventType:
      type: string
      enum:
        - revision.processing.completed
        - revision.processing.failed
        - ids_check.completed
        - ids_check.failed
        - export.ready

    Webhook:
      type: object
      required: [id, name, url, events, active, created_at]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
        active: { type: boolean }
        last_delivered_at: { type: [string, "null"], format: date-time }
        last_status: { type: [integer, "null"], format: int32 }
        created_at: { type: string, format: date-time }

    WebhookCreate:
      type: object
      required: [name, url, events]
      properties:
        name: { type: string, minLength: 1, maxLength: 120 }
        url: { type: string, format: uri, description: "HTTPS uniquement, 2000 caractères au plus." }
        events:
          type: array
          minItems: 1
          uniqueItems: true
          items: { $ref: "#/components/schemas/WebhookEventType" }

    WebhookDelivery:
      type: object
      required: [id, event, status, attempts, created_at]
      properties:
        id: { type: string, format: uuid, description: "Valeur de l'en-tête `webhook-id`." }
        event: { type: string }
        status:
          type: string
          enum: [pending, delivered, failed, abandoned, refused]
          description: |
            `pending` en file ou en attente d'un nouvel essai ; `delivered` reçu en `2xx` ; `failed` refusé par le
            destinataire (`4xx`) ; `abandoned` essais épuisés ou abonnement supprimé ; `refused` URL non publique.
        attempts: { type: integer, format: int32, minimum: 0 }
        response_code: { type: [integer, "null"], format: int32 }
        error: { type: [string, "null"] }
        created_at: { type: string, format: date-time }
        delivered_at: { type: [string, "null"], format: date-time }
        next_attempt_at: { type: [string, "null"], format: date-time }

    WebhookEvent:
      type: object
      required: [type, timestamp, data]
      properties:
        type: { $ref: "#/components/schemas/WebhookEventType" }
        timestamp: { type: string, format: date-time }
        data:
          type: object
          additionalProperties: true
