{
  "openapi": "3.0.0",
  "paths": {
    "/health": {
      "get": {
        "description": "Returns a fixed { status: 'ok' } payload when the process is up. Used by monitoring and orchestration health checks. Public (no auth, no rate limiting — health probes must NEVER 429).",
        "operationId": "HealthController_check",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Process is up.",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "status": "ok"
                  }
                }
              }
            }
          }
        },
        "summary": "Liveness probe",
        "tags": ["System"]
      }
    },
    "/health/warmup": {
      "post": {
        "description": "Forces a Mistral SDK round-trip (lightweight `models.list`) and a JWT sign/verify so the apps/api container reaches steady-state before the deploy gate measures INV-C07. Idempotent. Best-effort : a Mistral failure during warmup does not error the endpoint (the gate stays the smoke voice test downstream). Public + rate-limited (60 calls/min/IP).",
        "operationId": "HealthController_warmup",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Warmup completed.",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "elapsed_ms": 230,
                    "status": "ok",
                    "stages": {
                      "jwt": {
                        "elapsed_ms": 1,
                        "ok": true
                      },
                      "mistral": {
                        "elapsed_ms": 220,
                        "ok": true,
                        "skipped": false
                      }
                    }
                  }
                }
              }
            }
          }
        },
        "summary": "Pre-smoke cold-start drain",
        "tags": ["System"]
      }
    },
    "/conversations/me/mood-entries": {
      "post": {
        "description": "Le senior tape une face 1-5 sur la tablette ; on persiste une row mood_scores avec source='manual_entry' + confidence=1. La row co-existe avec le scoring passif Mistral per-turn ; la moyenne journalière fusionne les deux sources (pas d'override).",
        "operationId": "MoodEntriesController_createMoodEntry",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "`{ mood_level: 1..5, note?: <=1024 chars, entered_at?: ISO-8601, source?: voice|tablet_ui }`",
          "content": {
            "application/json": {
              "schema": {
                "type": "string"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Row mood_entry persistée"
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT senior absent"
          },
          "429": {
            "description": "Rate limit dépassé"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Saisie senior du journal d'humeur (1-5 → score 0-100)",
        "tags": ["Conversations"]
      }
    },
    "/conversations/me/mood-history": {
      "get": {
        "description": "Retourne les mood scores capturés à chaque tour conversationnel (table `mood_scores`). Trois granularités disponibles : `raw` (défaut, un point par tour, paginé via cursor keyset INV-A03), `hourly` ou `daily` (agrégat AVG score + MODE tier sur la range demandée). Range par défaut : [now - 30 jours, now]. Le senior ne peut voir QUE ses propres rows (INV-P05) — toute tentative de passer un `?user_id=` query param est rejetée 400 (schema strict).",
        "operationId": "MoodHistoryController_getMoodHistory",
        "parameters": [
          {
            "name": "cursor",
            "required": false,
            "in": "query",
            "description": "Opaque base64 cursor (raw mode only)",
            "schema": {}
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "description": "Entier 1..200 (default 50)",
            "schema": {}
          },
          {
            "name": "granularity",
            "required": false,
            "in": "query",
            "schema": {
              "enum": ["raw", "hourly", "daily"],
              "type": "string"
            }
          },
          {
            "name": "to",
            "required": false,
            "in": "query",
            "description": "ISO 8601 datetime (default : now)",
            "schema": {}
          },
          {
            "name": "from",
            "required": false,
            "in": "query",
            "description": "ISO 8601 datetime (default : now - 30 jours)",
            "schema": {}
          }
        ],
        "responses": {
          "200": {
            "description": "Mood history page"
          },
          "400": {
            "description": "Query params invalides"
          },
          "401": {
            "description": "JWT senior manquant"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Journal d'humeur (mood history) du senior connecté",
        "tags": ["Conversations"]
      }
    },
    "/conversation/mood-score/timeline": {
      "get": {
        "description": "Délègue à MoodScoreRepositoryPort.findHistory({ granularity: 'daily' }) avec un range décodé depuis `period` : 7d=7j, 30d=30j, 90d=90j. INV-P05 : senior_id extrait du JWT.",
        "operationId": "MoodScoreTimelineAliasController_getTimeline",
        "parameters": [
          {
            "name": "period",
            "required": false,
            "in": "query",
            "description": "Fenêtre temporelle (défaut 30d)",
            "schema": {
              "enum": ["7d", "30d", "90d"],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Timeline daily"
          },
          "400": {
            "description": "Period invalide (Zod)"
          },
          "401": {
            "description": "JWT senior absent"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Timeline daily score humeur — alias CDC §4.3.5 (period=7d|30d|90d)",
        "tags": ["Conversations"]
      }
    },
    "/voice/internal/morning-sequence/scan": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Bypass le cron 0,30 8-9 Europe/Paris pour exercer le pipeline complet (context provider → handler.fireForSenior → composer → voice-prompt event) sur tous les seniors éligibles. Body optionnel `{ now: ISO-8601 }` pour injecter un instant arbitraire (utile en E2E hors fenêtre matin).",
        "operationId": "MorningSequenceInternalController_triggerScan",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Scan terminé, agrégat fired/skipped/total retourné."
          }
        },
        "summary": "Déclenche manuellement la scan séquence matinale (debug / smoke test)",
        "tags": ["Voice (interne)"]
      }
    },
    "/realtime/token": {
      "post": {
        "operationId": "LivekitTokenController_createToken",
        "parameters": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "room": {
                    "type": "string",
                    "description": "Ignoré : la room est dérivée serveur-side en senior_id (room === senior_id). Champ conservé pour compatibilité client.",
                    "example": "11111111-1111-4111-8111-111111111111"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Token signé + URL WebSocket du serveur LiveKit",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
                    "wsUrl": "wss://kohami.livekit.cloud"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT Kohami absent ou invalide"
          },
          "403": {
            "description": "JWT sans senior_id — endpoint réservé au senior connecté"
          },
          "429": {
            "description": "Rate limit dépassé (20/min/IP)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Générer un token LiveKit signé",
        "tags": ["Realtime"]
      }
    },
    "/realtime/webhook": {
      "post": {
        "description": "Endpoint **public côté authentification Kohami** (pas de JWT exigé) car LiveKit signe lui-même le body via HMAC-SHA256 dans le header `Authorization`. La signature est vérifiée via `WebhookReceiver#receive` du SDK `livekit-server-sdk`. Tout body non signé ou avec une signature invalide est rejeté. Événements traités : `room_started`, `room_finished`, `participant_joined`, `participant_left`, `track_published`, `track_unpublished`. `participant_left` et `room_finished` invalident la row `active_participants` correspondante.",
        "operationId": "LivekitWebhookController_receiveWebhook",
        "parameters": [
          {
            "name": "Authorization",
            "in": "header",
            "description": "Signature HMAC-SHA256 LiveKit (préfixée par `Bearer ` ou utilisée brute). Calculée par le serveur LiveKit avec `LIVEKIT_API_SECRET` partagé.",
            "required": true,
            "schema": {
              "type": "string",
              "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.signature"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Payload JSON brut signé par LiveKit (raw bytes — `rawBody: true` activé au bootstrap). Schéma documenté dans la doc LiveKit Webhooks. Exemple non exhaustif :",
          "content": {
            "application/json": {
              "schema": {
                "example": {
                  "event": "participant_joined",
                  "room": {
                    "name": "salon-jeanne",
                    "sid": "RM_xxx"
                  },
                  "participant": {
                    "identity": "jeanne-moreau",
                    "sid": "PA_xxx"
                  },
                  "createdAt": 1729849200
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Événement accepté et journalisé"
          },
          "401": {
            "description": "Signature absente, mal formée, ou non vérifiable avec `LIVEKIT_API_SECRET`"
          }
        },
        "summary": "Webhook LiveKit — ingestion des événements signés",
        "tags": ["Realtime"]
      }
    },
    "/auth/register": {
      "post": {
        "description": "Endpoint public, rate-limité à 10 req/min/IP. Le rôle `senior` est interdit ici — un senior est créé par un admin via `POST /v1/admin/seniors`. Le mot de passe est hashé argon2id (memoryCost ≥ 19456, timeCost ≥ 2).",
        "operationId": "AuthController_register",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Email + mot de passe (≥ 10 caractères) + rôle (aidant|admin)",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "password", "role"],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "claire.martin@kohami.fr"
                  },
                  "password": {
                    "type": "string",
                    "minLength": 10,
                    "example": "Motdepasse-456!"
                  },
                  "role": {
                    "type": "string",
                    "enum": ["aidant", "admin"],
                    "example": "aidant"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Compte créé + session immédiatement utilisable",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "user": {
                      "id": "8a1b2c3d-4e5f-6789-abcd-ef0123456789",
                      "email": "claire.martin@kohami.fr",
                      "role": "aidant",
                      "senior_id": null
                    },
                    "tokens": {
                      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
                      "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh.signature",
                      "expires_at": "2026-04-27T14:30:00.000Z"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod) — email mal formé, password < 10 char, role hors {aidant,admin}"
          },
          "409": {
            "description": "Email déjà pris"
          },
          "429": {
            "description": "Rate limit dépassé (10/min/IP)"
          }
        },
        "summary": "Créer un compte utilisateur (aidant ou admin)",
        "tags": ["Auth"]
      }
    },
    "/auth/login": {
      "post": {
        "description": "Endpoint public, rate-limité à 10 req/min/IP. Pour la connexion senior depuis la tablette, utiliser `POST /v1/auth/device/login` (auto-enrollment Device ID) ou `POST /v1/auth/device/session` (re-login via device_token).",
        "operationId": "AuthController_login",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Identifiants email + mot de passe",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email", "password"],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "claire.martin@kohami.fr"
                  },
                  "password": {
                    "type": "string",
                    "example": "Motdepasse-456!"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session créée — access_token + refresh_token retournés",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "user": {
                      "id": "8a1b2c3d-4e5f-6789-abcd-ef0123456789",
                      "email": "claire.martin@kohami.fr",
                      "role": "aidant",
                      "senior_id": null
                    },
                    "tokens": {
                      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
                      "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh.signature",
                      "expires_at": "2026-04-27T14:30:00.000Z"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "Email ou mot de passe invalide"
          },
          "429": {
            "description": "Rate limit dépassé (10/min/IP)"
          }
        },
        "summary": "Se connecter (aidant/admin uniquement)",
        "tags": ["Auth"]
      }
    },
    "/auth/refresh": {
      "post": {
        "description": "Endpoint public, rate-limité à 10 req/min/IP. Le refresh_token a une durée de 7 jours, l'access_token retourné est valable 15 minutes. Un access_token soumis ici (mauvais `type` claim) est rejeté en 401.",
        "operationId": "AuthController_refresh",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Refresh token JWT obtenu lors d'un login précédent",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["refresh_token"],
                "properties": {
                  "refresh_token": {
                    "type": "string",
                    "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh.signature"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Nouvel access_token (15 min) + expires_at",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
                    "expires_at": "2026-04-27T14:30:00.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "Refresh token invalide, expiré, ou de type `access` (mauvais claim type)"
          },
          "429": {
            "description": "Rate limit dépassé (10/min/IP)"
          }
        },
        "summary": "Rafraîchir un access_token via un refresh_token",
        "tags": ["Auth"]
      }
    },
    "/auth/change-password": {
      "post": {
        "description": "Vérifie `current_password` via argon2.verify(), rejette si `new_password === current_password`, puis transaction `FOR UPDATE` + CAS sur le hash existant pour éliminer la TOCTOU race. Throttle 5 tentatives / minute / user (bucket `default`) protège du brute-force lent.",
        "operationId": "AuthChangePasswordController_changePassword",
        "parameters": [],
        "responses": {
          "204": {
            "description": "Mot de passe changé"
          },
          "400": {
            "description": "Body invalide (Zod) ou new_password === current_password"
          },
          "401": {
            "description": "JWT absent / invalide OU current_password incorrect"
          },
          "429": {
            "description": "Rate limit dépassé (5/min/user)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Changer son mot de passe (auth required, 5 req/min/user)",
        "tags": ["Auth"]
      }
    },
    "/auth/device/enroll": {
      "post": {
        "description": "Associe une paire `(application_id, device_id)` à un senior **pré-créé** en base via `POST /v1/admin/seniors`. Provisionne en transaction un user shadow `role=senior` (ou réutilise si 2e device sur le même senior). Retourne un `device_token` JWT long-lived (TTL 1 an) à persister en secure-store côté kiosque + une session senior immédiatement utilisable. Si la paire est déjà enrôlée mais révoquée (`revoked_at IS NOT NULL`), une nouvelle ligne est créée avec un nouveau UUID. Rate-limité à 10 req/min/IP.",
        "operationId": "DeviceAuthController_enroll",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Identifiants de la tablette + UUID du senior pré-créé. `application_id` = bundle de l'app Expo, `device_id` = `Constants.installationId` côté React Native.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["application_id", "device_id", "senior_id"],
                "properties": {
                  "application_id": {
                    "type": "string",
                    "example": "com.kohami.app"
                  },
                  "device_id": {
                    "type": "string",
                    "example": "android-tablet-jeanne-001"
                  },
                  "senior_id": {
                    "type": "string",
                    "format": "uuid",
                    "example": "555270b3-fae2-42d8-8023-9e3f7c72dc6c"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Device enrôlé + device_token + session senior prête. La tablette stocke `device_token` en secure-store puis entre directement dans l'app.",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "consent_state": {
                      "current_cgu_version": "v1",
                      "granted_categories": [],
                      "reason": "never_validated",
                      "requires_review": true
                    },
                    "device": {
                      "id": "9a899e25-fc0b-4d17-bc94-7def8bebc605",
                      "application_id": "com.kohami.app",
                      "device_id": "android-tablet-jeanne-001"
                    },
                    "device_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.device.signature",
                    "senior": {
                      "id": "555270b3-fae2-42d8-8023-9e3f7c72dc6c",
                      "display_name": "Jeanne Dupont"
                    },
                    "session": {
                      "user": {
                        "id": "8a1b2c3d-4e5f-6789-abcd-ef0123456789",
                        "email": "kohami-device-555270b3@kohami.local",
                        "role": "senior",
                        "senior_id": "555270b3-fae2-42d8-8023-9e3f7c72dc6c"
                      },
                      "tokens": {
                        "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access.signature",
                        "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh.signature",
                        "expires_at": "2026-04-27T14:30:00.000Z"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT aidant absent ou invalide"
          },
          "403": {
            "description": "Le rôle n'a pas le droit d'enrôler (seniors interdits)"
          },
          "404": {
            "description": "Senior introuvable (pré-créer via POST /v1/admin/seniors)"
          },
          "409": {
            "description": "Cette paire (application_id, device_id) est déjà enrôlée et active. Pour ré-enrôler après révocation, l'API crée automatiquement une nouvelle ligne (pas de 409 dans ce cas)."
          },
          "429": {
            "description": "Rate limit dépassé (10/min/IP)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Enrôler une tablette kiosque (aidant/admin)",
        "tags": ["Auth"]
      }
    },
    "/auth/device/login": {
      "post": {
        "description": "Endpoint **public** — c'est le seul écran de connexion présenté par la tablette au premier démarrage. L'aidant/technicien saisit les credentials senior remis lors du provisioning ; le back vérifie, associe définitivement la paire `(application_id, device_id)` au senior, retourne un `device_token` (TTL 1 an) à persister en secure-store + une `AuthSession` senior immédiatement utilisable. Aux démarrages suivants la tablette utilise `POST /v1/auth/device/session`.",
        "operationId": "DeviceAuthController_login",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Credentials senior + identifiants Expo de la tablette",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "application_id",
                  "device_id",
                  "email",
                  "password"
                ],
                "properties": {
                  "application_id": {
                    "type": "string",
                    "example": "com.kohami.app"
                  },
                  "device_id": {
                    "type": "string",
                    "example": "android-tablet-jeanne-001"
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "jeanne.dupont@kohami.fr"
                  },
                  "password": {
                    "type": "string",
                    "example": "Senior-pass-123!"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Senior connecté + device associé + session émise",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "consent_state": {
                      "current_cgu_version": "v1",
                      "granted_categories": [],
                      "reason": "never_validated",
                      "requires_review": true
                    },
                    "device": {
                      "id": "9a899e25-fc0b-4d17-bc94-7def8bebc605",
                      "application_id": "com.kohami.app",
                      "device_id": "android-tablet-jeanne-001"
                    },
                    "device_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.device.signature",
                    "senior": {
                      "id": "555270b3-fae2-42d8-8023-9e3f7c72dc6c",
                      "display_name": "Jeanne Dupont"
                    },
                    "session": {
                      "user": {
                        "id": "8a1b2c3d-4e5f-6789-abcd-ef0123456789",
                        "email": "jeanne.dupont@kohami.fr",
                        "role": "senior",
                        "senior_id": "555270b3-fae2-42d8-8023-9e3f7c72dc6c"
                      },
                      "tokens": {
                        "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access.signature",
                        "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh.signature",
                        "expires_at": "2026-04-27T14:30:00.000Z"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "Email ou mot de passe invalide"
          },
          "403": {
            "description": "Le compte n'est pas un compte senior — login aidant/admin via /v1/auth/login"
          },
          "409": {
            "description": "Cette paire (application_id, device_id) est déjà enrôlée et active sur un autre senior"
          },
          "429": {
            "description": "Rate limit dépassé (10/min/IP)"
          }
        },
        "summary": "Login senior + association Device ID (1er démarrage tablette)",
        "tags": ["Auth"]
      }
    },
    "/auth/device/session": {
      "post": {
        "description": "Endpoint **public** appelé par la tablette à chaque ouverture, sans intervention du senior. Échange le `device_token` (stocké en secure-store) contre une `AuthSession` senior (access_token 15min + refresh_token 7 jours) **enrichie de `consent_state`** — la tablette détecte une publication d'une nouvelle version de CGU (`reason: 'cgu_updated'`) sans round-trip supplémentaire. Met à jour `devices.last_seen_at`. Retourne 401 avec `error: \"DEVICE_REVOKED\"` si la tablette a été révoquée par un admin (cf. `POST /v1/admin/devices/:id/revoke`) — la mobile route alors vers l'écran 'contact support JUWA'.",
        "operationId": "DeviceAuthController_session",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "device_token JWT obtenu lors du dernier `POST /v1/auth/device/enroll` ou `POST /v1/auth/device/login`",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["device_token"],
                "properties": {
                  "device_token": {
                    "type": "string",
                    "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.device.signature"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session senior créée + consent_state à l'instant T",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "user": {
                      "id": "8a1b2c3d-4e5f-6789-abcd-ef0123456789",
                      "email": "jeanne.dupont@kohami.fr",
                      "role": "senior",
                      "senior_id": "555270b3-fae2-42d8-8023-9e3f7c72dc6c"
                    },
                    "tokens": {
                      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.access.signature",
                      "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.refresh.signature",
                      "expires_at": "2026-04-27T14:30:00.000Z"
                    },
                    "consent_state": {
                      "current_cgu_version": "v1",
                      "granted_categories": ["contacts", "profil"],
                      "reason": null,
                      "requires_review": false
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "device_token invalide, expiré, révoqué (`DEVICE_REVOKED`), ou de mauvais type JWT (`access_token` au lieu de `device_token`)"
          },
          "429": {
            "description": "Rate limit dépassé (10/min/IP)"
          }
        },
        "summary": "Ouvrir une session senior via device_token",
        "tags": ["Auth"]
      }
    },
    "/admin/seniors": {
      "post": {
        "description": "Crée en une transaction la ligne `seniors` (display_name) + le user senior associé (email + password hashé argon2id). Les credentials sont à transmettre manuellement à l'aidant/technicien d'installation, qui les saisit ensuite sur la tablette via `POST /v1/auth/device/login` (auto-enrollment Device ID au 1er démarrage). Rate-limité à 10 req/min/IP.",
        "operationId": "AdminSeniorsController_createSenior",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Display name du senior + credentials d'installation",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["display_name", "email", "password"],
                "properties": {
                  "display_name": {
                    "type": "string",
                    "example": "Jeanne Dupont"
                  },
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "jeanne.dupont@kohami.fr"
                  },
                  "password": {
                    "type": "string",
                    "minLength": 10,
                    "example": "Senior-pass-123!"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Senior + compte créés. Le `id` retourné sert de `senior_id` pour l'enrollment device.",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "id": "555270b3-fae2-42d8-8023-9e3f7c72dc6c",
                    "display_name": "Jeanne Dupont",
                    "email": "jeanne.dupont@kohami.fr",
                    "created_at": "2026-04-27T10:15:51.227Z"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Seuls les admins peuvent provisionner un senior"
          },
          "409": {
            "description": "Email déjà pris"
          },
          "429": {
            "description": "Rate limit dépassé (10/min/IP)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Provisionner un senior (admin uniquement)",
        "tags": ["Admin"]
      }
    },
    "/admin/devices/{id}/revoke": {
      "post": {
        "description": "Marque le device comme révoqué (`revoked_at = now()`). La prochaine tentative d'ouverture de session côté mobile (`POST /v1/auth/device/session`) remontera un 401 avec `error: \"DEVICE_REVOKED\"` — le client affiche alors l'écran 'contact support JUWA' et interdit toute navigation. Idempotent : re-révoquer un device déjà révoqué renvoie 200 avec la `revoked_at` d'origine. Le param `:id` est l'UUID interne `device.id` (retourné à l'enrollment), **pas** le `device_token` JWT. La paire `(application_id, device_id)` est libérée et peut être ré-enrôlée par un aidant via `POST /v1/auth/device/enroll`. Rate-limité à 10 req/min/IP.",
        "operationId": "AdminDevicesController_revoke",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "UUID interne du device (champ `device.id` retourné à l'enrollment)",
            "schema": {
              "example": "6a4c7e1e-9f2c-4f0a-8d1f-9b7a5c3e2d10",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Device révoqué (idempotent)",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "device_id": "6a4c7e1e-9f2c-4f0a-8d1f-9b7a5c3e2d10",
                    "revoked_at": "2026-04-24T09:15:33.221Z"
                  }
                }
              }
            }
          },
          "400": {
            "description": "`:id` n'est pas un UUID valide (ParseUUIDPipe). Erreur fréquente : utiliser le `device_token` JWT au lieu du `device.id`."
          },
          "401": {
            "description": "JWT admin absent ou invalide"
          },
          "403": {
            "description": "Seuls les admins peuvent révoquer une tablette"
          },
          "404": {
            "description": "Device introuvable"
          },
          "429": {
            "description": "Rate limit dépassé (10/min/IP)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Révoquer une tablette enrôlée (admin uniquement)",
        "tags": ["Admin"]
      }
    },
    "/admin/seniors/{senior_id}/relatives-birthdays": {
      "post": {
        "description": "Insère une ligne dans `relatives_birthdays` consommée par le workflow `birthday-reminder` (SOP V3.2 §2.7).",
        "operationId": "AdminRelativesBirthdaysController_create",
        "parameters": [
          {
            "name": "senior_id",
            "required": true,
            "in": "path",
            "description": "UUID du senior propriétaire de l'anniversaire",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "personName (1-128) + birthday (YYYY-MM-DD) + relation? enum + deceased_at? ISO timestamp (grief mode)",
          "content": {
            "application/json": {
              "schema": {
                "type": "string"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Anniversaire créé"
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins (aidant + senior rejetés)"
          },
          "404": {
            "description": "Senior introuvable"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Créer un anniversaire de proche (admin uniquement)",
        "tags": ["Admin · Relatives Birthdays"]
      },
      "get": {
        "description": "Cursor-based pagination (?cursor + ?limit, default 50, max 100). Les rows soft-deleted sont exclues sauf `?include_deleted=true`.",
        "operationId": "AdminRelativesBirthdaysController_list",
        "parameters": [
          {
            "name": "senior_id",
            "required": true,
            "in": "path",
            "description": "UUID du senior",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior introuvable"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Liste paginée des anniversaires d'un senior (admin uniquement)",
        "tags": ["Admin · Relatives Birthdays"]
      }
    },
    "/admin/seniors/{senior_id}/relatives-birthdays/{id}": {
      "put": {
        "description": "Update partiel : personName, birthday, relation, ou deceased_at (set/null pour reactivation grief).",
        "operationId": "AdminRelativesBirthdaysController_update",
        "parameters": [
          {
            "name": "senior_id",
            "required": true,
            "in": "path",
            "description": "UUID du senior",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "UUID de l'anniversaire",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Anniversaire mis à jour"
          },
          "400": {
            "description": "Body invalide"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior ou anniversaire absent"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Mettre à jour un anniversaire (admin uniquement)",
        "tags": ["Admin · Relatives Birthdays"]
      },
      "delete": {
        "description": "Sets `deleted_at = now()` afin que le workflow `birthday-reminder` cesse d'émettre des rappels pour cette entrée. La ligne reste visible avec `?include_deleted=true`.",
        "operationId": "AdminRelativesBirthdaysController_remove",
        "parameters": [
          {
            "name": "senior_id",
            "required": true,
            "in": "path",
            "description": "UUID du senior",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "UUID de l'anniversaire",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Anniversaire soft-deleted"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior ou anniversaire absent"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Soft-delete d'un anniversaire (admin uniquement)",
        "tags": ["Admin · Relatives Birthdays"]
      }
    },
    "/admin/seniors/{senior_id}/relatives-birthdays/{id}/mark-deceased": {
      "post": {
        "description": "Bascule le proche en mode 'deuil' — le workflow `birthday-reminder-scan` cessera d'émettre les anticipations J-7 / J-1 et déclenchera à la place le message empathique J0 pendant 1 an (SOP V3.2 §2.7 — `Cas limites : senior qui exprime un deuil récent`). Au-delà du délai 1 an, plus aucun rappel n'est émis (anti retraumatisation). Reset via `PUT` avec `{deceased_at: null}`. Rate limit dédié : 5/min/actor pour borner les bursts admin (mass-email payload + spam audit log).",
        "operationId": "AdminRelativesBirthdaysController_markDeceased",
        "parameters": [
          {
            "name": "senior_id",
            "required": true,
            "in": "path",
            "description": "UUID du senior",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "UUID de l'anniversaire",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "description": "`{deceased_at?: ISO timestamp}` — quand omis, prend `now()` côté serveur.",
          "content": {
            "application/json": {
              "schema": {
                "type": "string"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Proche marqué comme décédé"
          },
          "400": {
            "description": "Body invalide"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior ou anniversaire absent"
          },
          "429": {
            "description": "Rate limit dépassé (5/min/actor)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Marquer un proche comme décédé (admin uniquement)",
        "tags": ["Admin · Relatives Birthdays"]
      }
    },
    "/me/consents": {
      "get": {
        "description": "Retourne le `ConsentState` calculé à l'instant T pour le senior identifié par le JWT (`role: senior`). Pilote l'UI tablette via `requires_review`. Aidants/admins doivent passer par un endpoint d'admin dédié pour consulter d'autres seniors. Rate-limité à 20 req/min/IP. Par défaut, `granted_categories` sort en nommage **CDC v1.0** (`voice_print`, `memory_persistent`). Le query param `?naming=legacy` réactive l'ancien nommage (`biometrie`, `conversation`) pour les front-ends React Native + dashboard tant qu'ils n'ont pas migré (cf. ADR `consents-alerts-naming-migration`).",
        "operationId": "ConsentsController_read",
        "parameters": [
          {
            "name": "naming",
            "required": false,
            "in": "query",
            "description": "`cdc` (défaut) renvoie les catégories en nommage CDC v1.0 (`voice_print`, `memory_persistent`). `legacy` renvoie les noms historiques (`biometrie`, `conversation`). À retirer post-Jalon 3 — front-ends doivent migrer vers les noms CDC.",
            "deprecated": false,
            "schema": {
              "enum": ["cdc", "legacy"],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "État courant des consentements",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "consent_state": {
                      "current_cgu_version": "v1",
                      "granted_categories": ["contacts", "profil"],
                      "reason": null,
                      "requires_review": false
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Le compte appelant n'est pas un senior"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lire l'état courant des consentements du senior connecté",
        "tags": ["Me"]
      },
      "put": {
        "description": "Idempotent : la liste `granted_categories` reflète l'état final voulu — toutes les catégories absentes sont implicitement révoquées. La `cgu_version` doit correspondre à la version courante (sinon 409). Une ligne `audit_logs` (`action='consent'`) est écrite dans la même transaction. Rate-limité à 20 req/min/IP. Le body accepte indifféremment les noms **CDC v1.0** (`voice_print`, `memory_persistent`) **et** les noms legacy (`biometrie`, `conversation`) — le mapping bidirectionnel est appliqué au point d'admission. La réponse sort en nommage CDC par défaut ; passer `?naming=legacy` pour basculer (rétrocompat front Nathan).",
        "operationId": "ConsentsController_update",
        "parameters": [
          {
            "name": "naming",
            "required": false,
            "in": "query",
            "description": "`cdc` (défaut) renvoie les catégories de la réponse en nommage CDC v1.0. `legacy` renvoie les noms historiques (rétrocompat). À retirer post-Jalon 3.",
            "deprecated": false,
            "schema": {
              "enum": ["cdc", "legacy"],
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Version de CGU acceptée + catégories accordées. Accepte les noms CDC v1.0 (`voice_print`, `memory_persistent`, `contacts`, `localisation`, `profil`, `sante`, `vision`) ET les noms legacy (`biometrie`, `conversation`, ...) — le mapping bidirectionnel est documenté dans l'ADR `consents-alerts-naming-migration`.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["cgu_version", "granted_categories"],
                "properties": {
                  "cgu_version": {
                    "type": "string",
                    "example": "v1"
                  },
                  "granted_categories": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "biometrie",
                        "contacts",
                        "conversation",
                        "localisation",
                        "memory_persistent",
                        "profil",
                        "sante",
                        "vision",
                        "voice_print"
                      ]
                    },
                    "example": [
                      "contacts",
                      "profil",
                      "voice_print",
                      "memory_persistent"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Consentements appliqués + nouvel état courant",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "consent_state": {
                      "current_cgu_version": "v1",
                      "granted_categories": ["contacts", "profil"],
                      "reason": null,
                      "requires_review": false
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Le compte appelant n'est pas un senior"
          },
          "409": {
            "description": "`cgu_version` envoyée n'est plus la version courante — recharger via GET /v1/me/consents"
          },
          "429": {
            "description": "Rate limit dépassé (20/min/IP)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Mettre à jour les consentements du senior connecté",
        "tags": ["Me"]
      }
    },
    "/me/birthdays": {
      "get": {
        "operationId": "MeDataController_listBirthdays",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Liste des anniversaires à venir (triés par date croissante)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeBirthdaysListingSchema"
                }
              }
            }
          },
          "400": {
            "description": "`within_days` hors bornes [1..365]"
          },
          "401": {
            "description": "JWT absent/invalide ou `senior_id` absent des claims"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lister les anniversaires de proches à venir pour le senior connecté",
        "tags": ["Me"]
      }
    },
    "/me/reminders": {
      "get": {
        "operationId": "MeDataController_listReminders",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Liste des rappels à venir (triés par `scheduledAt` croissant)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeRemindersListingSchema"
                }
              }
            }
          },
          "400": {
            "description": "`within_days` hors bornes [1..365] ou `kind` invalide"
          },
          "401": {
            "description": "JWT absent/invalide ou `senior_id` absent des claims"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lister les rappels à venir pour le senior connecté (medication, appointment, ou tous)",
        "tags": ["Me"]
      }
    },
    "/auth/password/reset-request": {
      "post": {
        "description": "Endpoint public, rate-limité à 10 req/min/IP et 3 req/15 min/email. Toujours répond 204 quel que soit le statut de l'email (anti-enumeration). Si l'email correspond à un aidant ou admin actif, un email contenant un lien de reset (JWT short-lived) est envoyé via le provider sélectionné par `EMAIL_PROVIDER` (`stub` log-only en dev, `smtp` réel en prod, `noop` silencieux en CI). Pour le rôle senior, le back ne fait rien (les seniors n'ont pas de mot de passe — ils utilisent leur tablette dédiée).",
        "operationId": "PasswordResetController_requestReset",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Email du compte à reset",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["email"],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email",
                    "example": "claire.martin@kohami.fr"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Toujours 204 — succès ou email inconnu (anti-enumeration). Aucun corps."
          },
          "400": {
            "description": "Body invalide (Zod) — email mal formé"
          },
          "429": {
            "description": "Rate limit dépassé (3 req/15 min/email ou 10 req/min/IP). L'attaquant tentant l'énumération de comptes hit le bucket."
          }
        },
        "summary": "Demander un email de reset password (aidant/admin)",
        "tags": ["Auth · Password"]
      }
    },
    "/auth/password/reset": {
      "post": {
        "description": "Endpoint public, rate-limité à 10 req/min/IP. Le token JWT est vérifié pour signature, expiration et claim `scope=password_reset` avant tout accès BDD. Le nouveau mot de passe est hashé argon2id (memoryCost ≥ 19456, timeCost ≥ 2). Si le user a une enrollment 2FA TOTP active, elle reste active (le reset password ne désactive pas la 2FA).",
        "operationId": "PasswordResetController_resetPassword",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Token JWT de reset (15 min de validité) + nouveau password",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["token", "new_password"],
                "properties": {
                  "token": {
                    "type": "string",
                    "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature"
                  },
                  "new_password": {
                    "type": "string",
                    "minLength": 12,
                    "example": "Nouveaupassword-456!"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Password mis à jour, aucun corps retourné."
          },
          "400": {
            "description": "Body invalide (Zod), token invalide/expiré, ou nouveau password ne respecte pas la politique (longueur, chiffre, spécial)"
          },
          "403": {
            "description": "Token JWT a un scope ou type incorrect (e.g. token d'access classique présenté à la place d'un token de reset)"
          },
          "429": {
            "description": "Rate limit dépassé (10/min/IP)"
          }
        },
        "summary": "Appliquer un reset password via un token JWT short-lived",
        "tags": ["Auth · Password"]
      }
    },
    "/reminders/{reminder_id}/acknowledge": {
      "post": {
        "description": "Endpoint réservé au JWT senior. Acquitte un rappel `appointment` / `birthday` / `custom` en mettant à jour `reminders.acknowledged_at`. **Refuse** les rappels `medication` avec 422 + code `MEDICATION_REQUIRES_TAKEN_EVENT` : pour les médicaments, le tablet DOIT poster un event `kohami.user.medication_taken { reminder_id }` sur `POST /v1/events/user-action` (SOP V3.2 §3.1 + INV-S02). Idempotent : second ack sur un rappel déjà acquitté retourne 202 sans nouvelle écriture.",
        "operationId": "RemindersAckController_acknowledge",
        "parameters": [
          {
            "name": "reminder_id",
            "required": true,
            "in": "path",
            "description": "UUID du rappel à acquitter (cf. `reminders.id`)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Ack accepté"
          },
          "400": {
            "description": "reminder_id non-UUID"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "JWT sans rôle senior ou senior_id absent"
          },
          "404": {
            "description": "Rappel introuvable ou n'appartient pas au senior connecté"
          },
          "422": {
            "description": "Rappel de type `medication` — utilisez `kohami.user.medication_taken` à la place"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Senior acquitte un rappel (sauf médicaments — canal dédié medication_taken)",
        "tags": ["Reminders"]
      }
    },
    "/auth/2fa/enroll": {
      "post": {
        "description": "Génère un nouveau secret TOTP + N recovery codes. Le secret et les codes ne sont retournés qu'une seule fois ; le serveur stocke le secret chiffré AES-256-GCM et les codes hashés argon2. Réservé aux rôles aidant/admin.",
        "operationId": "TwoFactorController_enroll",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Secret + URI otpauth + recovery codes générés",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "secret": "JBSWY3DPEHPK3PXP",
                    "otpauth_uri": "otpauth://totp/KOHAMI:claire.aidant@kohami.fr?secret=JBSWY3DPEHPK3PXP&issuer=KOHAMI",
                    "recovery_codes": [
                      "A1B2C3D4E5",
                      "F6G7H8I9J0",
                      "K1L2M3N4O5",
                      "P6Q7R8S9T0",
                      "U1V2W3X4Y5",
                      "Z6A7B8C9D0",
                      "E1F2G3H4I5",
                      "J6K7L8M9N0"
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT requis"
          },
          "403": {
            "description": "Réservé aux rôles aidant/admin"
          },
          "409": {
            "description": "2FA désactivé globalement (TWO_FACTOR_ENABLED=false) OU 2FA déjà actif sur ce compte (désactiver d'abord via DELETE /v1/auth/2fa avant de réenrôler)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Enrôler une authentification 2FA TOTP",
        "tags": ["Auth · 2FA"]
      }
    },
    "/auth/2fa/verify": {
      "post": {
        "description": "Échange le challenge token retourné par /v1/auth/login (quand `two_factor_required: true`) contre une session complète. Le code peut être un TOTP 6 chiffres ou un recovery code one-time-use. Rate-limité à 5 req/min/user ; après 5 échecs, le compte est verrouillé 15 min.",
        "operationId": "TwoFactorController_verify",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Code TOTP/recovery + challenge token",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["code", "two_factor_challenge_token"],
                "properties": {
                  "code": {
                    "type": "string",
                    "example": "123456"
                  },
                  "two_factor_challenge_token": {
                    "type": "string",
                    "example": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.challenge.signature"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session complète émise"
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "Code invalide, expiré ou réutilisé"
          },
          "429": {
            "description": "Lockout (5 fails / 60 s)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Valider un code 2FA via challenge token",
        "tags": ["Auth · 2FA"]
      }
    },
    "/auth/2fa/recovery-codes/regenerate": {
      "post": {
        "description": "Invalide tous les anciens recovery codes et en génère N nouveaux. Le 2FA doit être actif sur ce compte.",
        "operationId": "TwoFactorController_regenerateRecoveryCodes",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Nouveaux recovery codes (les anciens sont définitivement invalidés)",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "recovery_codes": ["A1B2C3D4E5", "F6G7H8I9J0"]
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT requis"
          },
          "404": {
            "description": "2FA non actif sur ce compte"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Régénérer les recovery codes",
        "tags": ["Auth · 2FA"]
      }
    },
    "/auth/2fa": {
      "delete": {
        "description": "Désactive le 2FA de l'utilisateur authentifié. Un admin peut désactiver le 2FA d'un autre user via DELETE /v1/auth/2fa/:userId.",
        "operationId": "TwoFactorController_disableSelf",
        "parameters": [],
        "responses": {
          "204": {
            "description": "2FA désactivé"
          },
          "401": {
            "description": "JWT requis"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Désactiver le 2FA sur son propre compte",
        "tags": ["Auth · 2FA"]
      }
    },
    "/auth/2fa/{userId}": {
      "delete": {
        "description": "Admin-only — désactive le 2FA d'un autre user, ex. quand l'aidant perd son appareil authentificator. Retire le secret chiffré, les recovery codes et le flag enabled.",
        "operationId": "TwoFactorController_disableForUser",
        "parameters": [
          {
            "name": "userId",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "2FA désactivé sur l'utilisateur cible"
          },
          "401": {
            "description": "JWT requis"
          },
          "403": {
            "description": "Réservé aux admins (sauf disable self via DELETE /v1/auth/2fa)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Désactiver le 2FA d'un autre utilisateur (admin only)",
        "tags": ["Auth · 2FA"]
      }
    },
    "/auth/2fa/status": {
      "get": {
        "description": "Retourne enabled + enrolled_at + recovery_codes_remaining. Ne révèle jamais le secret.",
        "operationId": "TwoFactorController_status",
        "parameters": [],
        "responses": {
          "200": {
            "description": "État courant",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "enabled": true,
                    "enrolled_at": "2026-05-19T12:30:00.000Z",
                    "recovery_codes_remaining": 7
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT requis"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lire le statut 2FA du compte authentifié",
        "tags": ["Auth · 2FA"]
      }
    },
    "/seniors/me/absence-mode": {
      "get": {
        "operationId": "AbsenceModeController_read",
        "parameters": [],
        "responses": {
          "200": {
            "description": "État courant `{ active, until }`"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lire l'état du mode absence du senior connecté (INV-S07)",
        "tags": ["Me"]
      },
      "post": {
        "operationId": "AbsenceModeController_update",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "`{ active: boolean, until?: ISO-8601 }` — `until` requis si active=true",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["active"],
                "properties": {
                  "active": {
                    "type": "boolean"
                  },
                  "until": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "État appliqué"
          },
          "400": {
            "description": "Body invalide (Zod) ou `until` dans le passé"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Activer / désactiver le mode absence (INV-S07, SOP V3.2 §4.3). Suspend inactivité + check-in matinal — laisse INV-S01/S02 actifs.",
        "tags": ["Me"]
      }
    },
    "/seniors/me/heartbeat": {
      "post": {
        "description": "Ping périodique de la tablette kiosque pour signaler qu'elle est vivante (réseau OK + écran kiosque actif). Le payload est optionnel — battery_level, network_quality et uptime_s sont propagés pour l'observabilité. Si battery_level < 20 %, le contrôleur fan-out vers kohami.battery.low pour relayer dans le pipeline aidant existant (BatteryLowHandler). 204 No Content sur succès.",
        "operationId": "DeviceHeartbeatController_ingest",
        "parameters": [],
        "responses": {
          "204": {
            "description": "Heartbeat enregistré"
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "JWT sans senior_id (token aidant/admin refusé)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Heartbeat tablette toutes les 60 s (SOP V3.2 §SOP 4.5)",
        "tags": ["Seniors"]
      }
    },
    "/admin/seniors/{seniorId}/inactivity-config": {
      "get": {
        "description": "Retourne la configuration courante (wake / alert / auto-learn). Si aucune row n'existe encore en base, retourne les defaults (30 min wake, 360 min alert = 6h SOP V3.2, auto-learn off).",
        "operationId": "SeniorInactivityConfigController_getConfig",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Configuration courante",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "senior_id": "00000000-0000-0000-0000-000000000001",
                    "wake_threshold_minutes": 30,
                    "alert_threshold_minutes": 360,
                    "auto_learn_enabled": false,
                    "updated_at": "2026-05-20T07:30:00.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Actor non autorisé à voir ce senior"
          },
          "404": {
            "description": "Senior inexistant"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lire les seuils d'inactivité par senior",
        "tags": ["Admin · Seniors"]
      },
      "put": {
        "description": "PUT partiel. Champs omis conservent leur valeur stockée (ou la valeur par défaut si aucune row n'existe). Contrainte sémantique : wake_threshold_minutes < alert_threshold_minutes. Une entrée `audit_logs` est inscrite (action `update`, resource `senior_inactivity_config`, payload `{ diff, actor_user_id }`).",
        "operationId": "SeniorInactivityConfigController_putConfig",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Patch partiel — tous les champs sont optionnels.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "wake_threshold_minutes": {
                    "type": "integer",
                    "minimum": 5,
                    "maximum": 120,
                    "example": 45
                  },
                  "alert_threshold_minutes": {
                    "type": "integer",
                    "minimum": 60,
                    "maximum": 1440,
                    "example": 240
                  },
                  "auto_learn_enabled": {
                    "type": "boolean",
                    "example": false
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Configuration mise à jour"
          },
          "400": {
            "description": "Body Zod invalide"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Actor non autorisé à modifier ce senior"
          },
          "404": {
            "description": "Senior inexistant"
          },
          "422": {
            "description": "Validation sémantique échouée (wake_threshold_minutes >= alert_threshold_minutes)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Mettre à jour les seuils d'inactivité par senior",
        "tags": ["Admin · Seniors"]
      }
    },
    "/admin/alerts/{alertId}/resolve": {
      "post": {
        "description": "Marque l'alerte `resolved` et stoppe la cascade d'escalade aidants si encore active. Admin : résout n'importe quelle alerte. Aidant : résout UNIQUEMENT une alerte de son sénior (lien JWT 1:1 OU carnet `contacts`) — sinon 404 (anti-leak, jamais 403). Sénior et autres rôles : 403. Émet `alert.escalated` (escalated_to=primary) pour débloquer le `waitForCaregiverAck` du workflow `emergency-escalation`. Audit log persisté AVANT l'emit. Idempotent : un second appel sur alerte déjà `resolved` retourne 200 avec `cascadeStopped=false` sans nouvelle émission.",
        "operationId": "AdminAlertResolveController_resolve",
        "parameters": [
          {
            "name": "alertId",
            "required": true,
            "in": "path",
            "description": "UUID de l'alerte à résoudre (cf. `alerts.id`)",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "maxLength": 512,
                    "description": "Justification optionnelle propagée dans `metadata.reason` de l'event Inngest + audit_logs.payload.reason"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Alerte résolue (ou déjà résolue, idempotent)",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "alertId": "9f7b3c2c-2f7b-4d2a-8b6b-1f1c8b8e6e5e",
                    "resolvedAt": "2026-05-25T10:00:00.000Z",
                    "resolvedBy": "1f1c8b8e-6e5e-4d2a-8b6b-2f7b9f7b3c2c",
                    "previousStatus": "open",
                    "cascadeStopped": true
                  }
                }
              }
            }
          },
          "400": {
            "description": "alertId invalide ou body Zod"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Sénior ou rôle non autorisé (un sénior ne résout pas)"
          },
          "404": {
            "description": "Alerte introuvable, OU aidant non rattaché au sénior de l'alerte (anti-leak)"
          },
          "429": {
            "description": "Rate limit dépassé (30/min)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Force-resolve d'une alerte (admin, ou aidant pour son sénior)",
        "tags": ["Admin · Alerts"]
      }
    },
    "/alerts/{alert_id}/acknowledge": {
      "post": {
        "description": "Endpoint réservé aux JWT aidant. Le caregiver dashboard l'appelle quand l'aidant prend acte d'une alerte (notification SMS/push reçue). Le back-end émet `alert.escalated` qui débloque le `waitForEvent` du workflow `emergency-escalation` et empêche la cascade vers secondary/tertiary. Idempotent : un second appel sur une alerte déjà acquittée retourne 202 sans nouvelle émission Inngest.",
        "operationId": "AlertAckController_acknowledge",
        "parameters": [
          {
            "name": "alert_id",
            "required": true,
            "in": "path",
            "description": "UUID de l'alerte à acquitter (cf. `alerts.id`)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Ack accepté pour traitement"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "JWT sans rôle aidant"
          },
          "404": {
            "description": "Alerte introuvable ou l'aidant n'est pas rattaché à son senior"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Aidant ack une alerte → arrête la cascade d'escalade (SOP V3 §4.2)",
        "tags": ["Alerts"]
      }
    },
    "/alerts/{alert_id}/trail": {
      "get": {
        "description": "Retourne la séquence chronologique des événements de cascade pour l'alerte (cascade_started → dispatched primary/secondary/tertiary → notify_senior → ack | fallback_juwa). Admin : lit le trail de n'importe quelle alerte. Aidant : lit UNIQUEMENT le trail d'une alerte de son sénior (lien JWT 1:1 OU carnet `contacts`) — sinon 404 (anti-leak, jamais 403). Sénior et autres rôles : 403. RLS PG en defense-in-depth.",
        "operationId": "AlertTrailController_getTrail",
        "parameters": [
          {
            "name": "alert_id",
            "required": true,
            "in": "path",
            "description": "UUID de l'alerte (cf. `alerts.id`)",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Trail trouvé"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "JWT sans rôle admin/aidant (sénior ou autre rôle)"
          },
          "404": {
            "description": "Alerte introuvable, OU aidant non rattaché au sénior de l'alerte (anti-leak)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lit la trace append-only d'une alerte (INV-S02 audit, AC-10-13)",
        "tags": ["Alerts"]
      }
    },
    "/voice/internal/pain-protocol/complete": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le voice fork (`BridgedPainProtocol.publish`). Best-effort : une panne repo/publisher n'interrompt pas la réponse vocale (INV-S02 audit log warn).",
        "operationId": "PainProtocolInternalController_complete",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Audit + event acceptés"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Persiste l'audit du protocole douleur 3 questions + publie l'event business (PO-DOULEUR validé Grégory Fort + JUWA)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/pain-protocol/strong-alert": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par `BridgedPainProtocol.dispatchStrongAlert` quand `severity=strong`. Émet `Alert { kind: 'distress', level: 'red' }` via `AlertPublisherPort` et seed `alert_trails.cascade_started` — cascade aidant immédiate (24/7).",
        "operationId": "PainProtocolInternalController_strongAlert",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Alert publiée pour cascade"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Dispatche une Alert distress red pour le protocole douleur sévère (cascade INV-S01)",
        "tags": ["Voice (interne)"]
      }
    },
    "/events/internal/test-publish-medication": {
      "post": {
        "operationId": "EventsTestController_publishMedication",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Body : `{ senior_id, payload }` (cf. MedicationReminderEventSchema)",
          "content": {
            "application/json": {
              "schema": {
                "type": "string"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": ""
          },
          "403": {
            "description": "Bloqué en prod"
          }
        },
        "summary": "Publish kohami.reminder.medication via LiveKit DataChannel",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-medication-dismiss": {
      "post": {
        "operationId": "EventsTestController_publishMedicationDismiss",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.reminder.medication_dismiss",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-birthday": {
      "post": {
        "operationId": "EventsTestController_publishBirthday",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.reminder.birthday",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-birthday-dismiss": {
      "post": {
        "operationId": "EventsTestController_publishBirthdayDismiss",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.reminder.birthday_dismiss",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-appointment-urgent": {
      "post": {
        "operationId": "EventsTestController_publishAppointmentUrgent",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.reminder.appointment_urgent",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-appointment-conflict": {
      "post": {
        "operationId": "EventsTestController_publishAppointmentConflict",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.reminder.appointment_conflict",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-appointment-dismiss": {
      "post": {
        "operationId": "EventsTestController_publishAppointmentDismiss",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.reminder.appointment_dismiss",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-tool-open": {
      "post": {
        "operationId": "EventsTestController_publishToolOpen",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.tool.open",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-tool-close": {
      "post": {
        "operationId": "EventsTestController_publishToolClose",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.tool.close",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-onboarding-recap": {
      "post": {
        "operationId": "EventsTestController_publishOnboardingRecap",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.onboarding.recap",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-onboarding-recap-dismiss": {
      "post": {
        "operationId": "EventsTestController_publishOnboardingRecapDismiss",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.onboarding.recap_dismiss",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-onboarding-final-recap": {
      "post": {
        "operationId": "EventsTestController_publishOnboardingFinalRecap",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.onboarding.final_recap",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-publish-onboarding-final-recap-dismiss": {
      "post": {
        "operationId": "EventsTestController_publishOnboardingFinalRecapDismiss",
        "parameters": [],
        "responses": {
          "201": {
            "description": ""
          }
        },
        "summary": "Publish kohami.onboarding.final_recap_dismiss",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/internal/test-inbound-count": {
      "get": {
        "operationId": "EventsTestController_inboundCount",
        "parameters": [
          {
            "name": "topic",
            "required": true,
            "in": "query",
            "schema": {
              "example": "kohami.user.medication_taken",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "summary": "Compteur des événements entrants par topic (debug)",
        "tags": ["Events (dev only)"]
      }
    },
    "/events/user-action": {
      "post": {
        "description": "Endpoint REST réservé au senior connecté. Le tablet poste un acquittement (medication_taken, appointment_acknowledged, etc.) qui est validé puis publié sur le bus interne pour réveiller les workflows Inngest en attente. Retourne 202 même si le payload est silencieusement droppé (validation Zod) — l'idempotence est responsabilité du tablet.",
        "operationId": "UserEventsController_ingest",
        "parameters": [],
        "responses": {
          "202": {
            "description": "Event accepté pour traitement"
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "JWT sans rôle senior"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Acquittement senior tablet → back (kohami.user.*)",
        "tags": ["Events"]
      }
    },
    "/onboarding/status": {
      "get": {
        "description": "Retourne l'état actuel de la state machine d'onboarding, le jour courant (0–7), les slots déjà extraits, et le ratio de complétion. Le JWT doit être un access token de rôle `senior` avec un `senior_id` valide.",
        "operationId": "OnboardingController_getStatus",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Snapshot courante",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "state": "day3_interests",
                    "day": 3,
                    "slots": {
                      "first_name": "Jeanne",
                      "favorite_activity": "lecture",
                      "preferred_drink": null
                    },
                    "completionRatio": 0.4
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou expiré"
          },
          "403": {
            "description": "JWT non-senior ou senior_id absent"
          },
          "404": {
            "description": "Onboarding jamais démarré (`{ error: \"ONBOARDING_NOT_STARTED\" }`)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "État courant de l'onboarding (senior connecté)",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/advance": {
      "post": {
        "description": "Dispatche un événement `FORCE_ADVANCE`. La transition résulte de l'état courant : `greeting` → `consents_capture` → `day1_identity` → ... → `voice_fingerprint_opt_in` → `completed`. Réservé aux comptes `admin` ou `aidant`. Un aidant peut omettre `senior_id` : le senior le plus récemment enregistré sur ses appareils est utilisé.",
        "operationId": "OnboardingController_forceAdvance",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "`senior_id` optionnel pour aidant (auto-resolu), requis pour admin.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "senior_id": {
                    "type": "string",
                    "format": "uuid",
                    "example": "8a1b2c3d-4e5f-6789-abcd-ef0123456789"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Snapshot après transition"
          },
          "400": {
            "description": "`senior_id` absent et aucune liaison aidant↔senior"
          },
          "403": {
            "description": "JWT ni admin ni aidant"
          },
          "404": {
            "description": "Onboarding pas démarré pour ce senior (`{ error: \"ONBOARDING_NOT_STARTED\" }`)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Avance la state machine d'un cran (support / QA)",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/advance/to-completed": {
      "post": {
        "description": "Dispatche `FORCE_ADVANCE_TO_COMPLETED`. Accepté depuis n'importe quel état non-terminal. Utilisé pour valider un parcours sans attendre le cron quotidien (testing front, démo).",
        "operationId": "OnboardingController_forceAdvanceToCompleted",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Body identique à `/advance`. `senior_id` optionnel pour aidant.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "senior_id": {
                    "type": "string",
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Snapshot avec `state: \"completed\"`"
          },
          "400": {
            "description": "`senior_id` absent"
          },
          "403": {
            "description": "JWT ni admin ni aidant"
          },
          "404": {
            "description": "Onboarding pas démarré"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Saute directement à `completed` (support / QA)",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/advance/to-state": {
      "post": {
        "description": "Boucle `FORCE_ADVANCE` jusqu'à ce que la state machine atteigne `target_state`. Capé à 16 itérations pour éviter une boucle infinie. La machine est forward-only : une cible en arrière (ou dépassée car la machine a atteint `completed`) est inatteignable → 409 Conflict (erreur client), pas 500.",
        "operationId": "OnboardingController_forceAdvanceToState",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "`target_state` parmi les 9 états valides. `senior_id` optionnel pour aidant.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["target_state"],
                "properties": {
                  "senior_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "target_state": {
                    "type": "string",
                    "enum": [
                      "greeting",
                      "consents_capture",
                      "day1_identity",
                      "day2_environment",
                      "day3_interests",
                      "day4_lifestyle",
                      "day5_relationship",
                      "voice_fingerprint_opt_in",
                      "completed"
                    ],
                    "example": "day3_interests"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Snapshot avec `state` égal à `target_state`"
          },
          "400": {
            "description": "Body Zod invalide ou `senior_id` absent"
          },
          "403": {
            "description": "JWT ni admin ni aidant"
          },
          "404": {
            "description": "Onboarding pas démarré"
          },
          "409": {
            "description": "`target_state` inatteignable (machine forward-only)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Avance jusqu'à un état précis (support / QA)",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/reset": {
      "post": {
        "description": "Wipe l'actor xstate en mémoire + DELETE FROM onboarding_states. Le prochain appel à `/start` réouvre le parcours à `greeting`. Réservé `admin` pour éviter qu'un aidant n'efface accidentellement le progrès d'un senior. Idempotent : appel répété sur un senior sans snapshot ne lève pas.",
        "operationId": "OnboardingController_resetOnboarding",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "`senior_id` requis (pas d'auto-resolution).",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["senior_id"],
                "properties": {
                  "senior_id": {
                    "type": "string",
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Snapshot supprimée",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "reset": true,
                    "senior_id": "8a1b2c3d-4e5f-6789-abcd-ef0123456789"
                  }
                }
              }
            }
          },
          "400": {
            "description": "`senior_id` absent du body"
          },
          "403": {
            "description": "JWT non-admin (réservé admin, aidant exclu)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Supprime la snapshot d'onboarding (admin only)",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/profile": {
      "put": {
        "operationId": "OnboardingController_updateProfile",
        "parameters": [],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Met à jour les slots déjà extraits (correction senior)",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/consent": {
      "post": {
        "operationId": "OnboardingController_delegateConsent",
        "parameters": [],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Delegates to consents module (single source of truth).",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/postpone": {
      "post": {
        "description": "Pause le parcours sans perdre l'état xstate. Réservé au senior connecté. `reason` (optionnel) ∈ {fatigue, distraction, unwilling, other}. `wake_up_at` (optionnel, ISO 8601 avec offset) — par défaut J+1 09:00 Europe/Paris. Persiste `postponed_at`, `postponed_reason`, `wake_up_at` dans `onboarding_states` ; trace `audit_logs` (`action='update'`, `resource='onboarding_session'`) ; émet l'event Inngest `onboarding.postponed` pour que le front affiche un widget mood-card de reprise.",
        "operationId": "OnboardingController_postponeOnboarding",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Body optionnel — `reason` + `wake_up_at` facultatifs.",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string",
                    "enum": ["distraction", "fatigue", "other", "unwilling"]
                  },
                  "wake_up_at": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Session reportée",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "status": "postponed",
                    "current_state": "day2_environment",
                    "reason": "fatigue",
                    "postponed_at": "2026-05-19T14:32:11.000Z",
                    "wake_up_at": "2026-05-20T08:00:00.000Z",
                    "resume_url": "/v1/onboarding/resume"
                  }
                }
              }
            }
          },
          "400": {
            "description": "État `greeting` non reportable (`{ error: \"ONBOARDING_NOT_POSTPONABLE_BEFORE_CONSENTS\" }`)"
          },
          "401": {
            "description": "JWT absent ou expiré"
          },
          "403": {
            "description": "JWT non-senior ou senior_id absent"
          },
          "404": {
            "description": "Onboarding jamais démarré (`{ error: \"ONBOARDING_SESSION_NOT_FOUND\" }`)"
          },
          "409": {
            "description": "Onboarding déjà terminé (`{ error: \"ONBOARDING_ALREADY_COMPLETED\" }`)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Reporter la session d'onboarding (« je suis fatigué »)",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/resume": {
      "post": {
        "description": "Restaure une session précédemment reportée via `POST /v1/onboarding/postpone`. L'état xstate est conservé : reprise au même état, mêmes slots déjà collectés. Trace `audit_logs` + émet l'event Inngest `onboarding.resumed`.",
        "operationId": "OnboardingController_resumeOnboarding",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Session reprise",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "status": "resumed",
                    "current_state": "day2_environment",
                    "next_missing_slots": [
                      "family_children",
                      "nearby_relatives"
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou expiré"
          },
          "403": {
            "description": "JWT non-senior ou senior_id absent"
          },
          "409": {
            "description": "Aucune session en pause (`{ error: \"ONBOARDING_NOT_POSTPONED\" }`)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Reprendre l'onboarding reporté",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/summary": {
      "get": {
        "operationId": "OnboardingController_getSummary",
        "parameters": [],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Récap onboarding pour la validation senior J5",
        "tags": ["Onboarding"]
      }
    },
    "/onboarding/caregiver-config": {
      "get": {
        "description": "JWT aidant uniquement. Retourne la configuration courante du senior auquel l'aidant authentifié est rattaché (`users.senior_id`). Si aucune row n'existe encore, retourne la configuration par défaut (7 jours actifs, 600s session, voice fingerprint off, no priority topics, sms+email).",
        "operationId": "CaregiverConfigController_getConfig",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Configuration courante",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "senior_id": "00000000-0000-0000-0000-000000000001",
                    "days_active": [
                      "monday",
                      "tuesday",
                      "wednesday",
                      "thursday",
                      "friday",
                      "saturday",
                      "sunday"
                    ],
                    "max_session_duration_sec": 600,
                    "voice_fingerprint_opt_in": false,
                    "priority_topics": ["sport", "lecture"],
                    "notification_channels": ["sms", "email"],
                    "updated_at": "2026-05-19T20:30:00.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou expiré"
          },
          "403": {
            "description": "JWT non-aidant"
          },
          "404": {
            "description": "Aucun senior rattaché à l'aidant (users.senior_id NULL)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lire la configuration onboarding gérée par l'aidant pour son senior",
        "tags": ["Onboarding"]
      },
      "put": {
        "description": "JWT aidant uniquement. PUT partiel — tout champ omis garde sa valeur stockée (ou sa valeur par défaut si aucune row n'existe encore). Upserte la row dans `onboarding_caregiver_configs`, écrit une entrée `audit_logs` (action `update`, resource `onboarding_caregiver_config`, payload `{ diff, caregiver_user_id }`), puis émet l'event Inngest `onboarding.caregiver_config.changed`.",
        "operationId": "CaregiverConfigController_putConfig",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Patch partiel — tous les champs sont optionnels. days_active : min 1 / max 7 jours uniques. max_session_duration_sec : 60..1800 (10 sec → 30 min). priority_topics : max 10 strings (1..80 chars, uniques, non-vides). notification_channels : sous-ensemble non vide de [sms, email, push].",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "days_active": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": [
                        "monday",
                        "tuesday",
                        "wednesday",
                        "thursday",
                        "friday",
                        "saturday",
                        "sunday"
                      ]
                    },
                    "example": ["monday", "wednesday", "friday"]
                  },
                  "max_session_duration_sec": {
                    "type": "integer",
                    "minimum": 60,
                    "maximum": 1800,
                    "example": 900
                  },
                  "voice_fingerprint_opt_in": {
                    "type": "boolean",
                    "example": true
                  },
                  "priority_topics": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "example": ["sport", "cuisine", "famille"]
                  },
                  "notification_channels": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": ["sms", "email", "push"]
                    },
                    "example": ["sms", "push"]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Configuration mise à jour"
          },
          "400": {
            "description": "Body Zod invalide"
          },
          "401": {
            "description": "JWT absent ou expiré"
          },
          "403": {
            "description": "JWT non-aidant"
          },
          "404": {
            "description": "Aucun senior rattaché à l'aidant"
          },
          "422": {
            "description": "Validation sémantique échouée (doublons, strings vides après trim)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Mettre à jour la configuration onboarding gérée par l'aidant",
        "tags": ["Onboarding"]
      }
    },
    "/admin/alerts/recent": {
      "get": {
        "description": "Accessible aux admins (vue cross-tenant) ET aux aidants liés (la réponse est automatiquement restreinte au senior rattaché — le filtre `senior_id` d'un aidant est ignoré au profit de son périmètre). Pagination cursor (`cursor` + `limit` 1..200, default 50). Fenêtre par défaut [now-24h, now] modifiable via `from` / `to` (ISO-8601 + offset). Filtres optionnels : `senior_id` (admin uniquement), `level` (N1|N2|N3), `category`, `status` (open|acknowledged|resolved|dismissed). Pas de paramètre `hours` — passer un `from` explicite si besoin d'un rayon plus large.",
        "operationId": "AdminAlertsRecentController_list",
        "parameters": [
          {
            "name": "status",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["open", "acknowledged", "resolved", "dismissed"]
            }
          },
          {
            "name": "category",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "maxLength": 64
            }
          },
          {
            "name": "level",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["N1", "N2", "N3"]
            }
          },
          {
            "name": "senior_id",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "to",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "from",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "description": "ISO-8601 avec offset (ex: `2026-05-01T00:00:00Z`)."
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste paginée des alertes (keyset cursor `triggered_at DESC`)",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "data": [
                      {
                        "id": "9f7b3c2c-2f7b-4d2a-8b6b-1f1c8b8e6e5e",
                        "senior_id": "fb685895-8930-4a5b-a66a-c8e14e231f4a",
                        "session_id": null,
                        "level": "N3",
                        "category": "sos_button",
                        "status": "open",
                        "triggered_at": "2026-05-24T16:30:00.000Z",
                        "resolved_at": null,
                        "payload": {
                          "source": "user_button",
                          "kioskMode": "on"
                        }
                      }
                    ],
                    "meta": {
                      "count": 1,
                      "filters_applied": {
                        "level": "N3"
                      },
                      "from": "2026-05-23T16:30:00.000Z",
                      "to": "2026-05-24T16:30:00.000Z"
                    },
                    "next_cursor": null
                  }
                }
              }
            }
          },
          "400": {
            "description": "Filtres invalides"
          },
          "401": {
            "description": "JWT manquant ou invalide"
          },
          "403": {
            "description": "Rôle non autorisé (ni admin, ni aidant lié à un senior)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Alertes récentes (admin + aidant lié, scope senior) — fenêtre 24h par défaut, filtres senior/level/category/status, payload PII sanitisée",
        "tags": ["Admin · Alerts"]
      }
    },
    "/admin/alerts/seed-test": {
      "post": {
        "description": "Idempotent par `alert_id` aléatoire (chaque appel crée une row). Catégorie par défaut `dashboard_qa_seed` pour faciliter le filtrage / cleanup. Pour purger les rows de test : DELETE direct ou `category=dashboard_qa_seed` côté admin SQL.",
        "operationId": "AdminAlertsRecentController_seedTest",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["senior_id"],
                "properties": {
                  "senior_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "UUID du senior cible (doit exister en base, FK contrainte)"
                  },
                  "level": {
                    "type": "string",
                    "enum": ["N1", "N2", "N3"],
                    "default": "N2"
                  },
                  "category": {
                    "type": "string",
                    "maxLength": 64,
                    "default": "dashboard_qa_seed"
                  },
                  "status": {
                    "type": "string",
                    "enum": ["open", "acknowledged", "resolved", "dismissed"],
                    "default": "open"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Row alerts créée",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "alert_id": "9f7b3c2c-2f7b-4d2a-8b6b-1f1c8b8e6e5e",
                    "senior_id": "fb685895-8930-4a5b-a66a-c8e14e231f4a",
                    "level": "N2",
                    "category": "dashboard_qa_seed",
                    "status": "open",
                    "triggered_at": "2026-05-24T16:30:00.000Z"
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior cible inexistant"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Seed a dashboard-QA alert row (admin only — category defaults to `dashboard_qa_seed`)",
        "tags": ["Admin · Alerts"]
      }
    },
    "/admin/seniors/{seniorId}/appointments": {
      "get": {
        "description": "Liste les lignes `reminders` `kind='appointment'` du senior, triées `(created_at DESC, id DESC)`. Par défaut seules les actives ; `?include_inactive=true` remonte aussi paused/done/cancelled. Aidant non lié → 404 (fail-closed).",
        "operationId": "AdminAppointmentsController_list",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "name": "include_inactive",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["true", "false"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste des rendez-vous"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins et aidants"
          },
          "404": {
            "description": "Senior hors périmètre / inconnu"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lister les rendez-vous (rappels kind=appointment) d'un senior — admin (tout senior) ou aidant (son senior lié)",
        "tags": ["Admin · Appointments"]
      },
      "post": {
        "description": "Crée une ligne `reminders` `kind='appointment'` : `title`, `scheduled_at` (échéance concrète, requise), `location_type` (`exterieur` rappel T-1h / `interieur` rappel T-10min, défaut `exterieur`), `description` (notes : adresse, accompagnant…) et un `schedule_cron` optionnel pour un RDV récurrent. La création EST la définition du rappel : une ligne active avec `scheduled_at` + `voice_prompt_emitted_at` nul est captée par le scan vocal RDV. `active=false` crée la ligne en `paused`. Cron malformé → 400. Aidant non lié → 404. Senior inconnu → 404.",
        "operationId": "AdminAppointmentsController_create",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["title", "scheduled_at"],
                "properties": {
                  "title": {
                    "type": "string",
                    "example": "RDV cardiologue Dr Martin"
                  },
                  "description": {
                    "type": "string",
                    "example": "Clinique du Parc, 3e étage — apporter la dernière ordonnance"
                  },
                  "scheduled_at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "location_type": {
                    "type": "string",
                    "enum": ["exterieur", "interieur"],
                    "default": "exterieur"
                  },
                  "schedule_cron": {
                    "type": "string",
                    "example": "0 9 * * 1"
                  },
                  "active": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Rendez-vous créé"
          },
          "400": {
            "description": "Body invalide ou cron malformé"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins et aidants"
          },
          "404": {
            "description": "Senior hors périmètre / inconnu"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Ajouter un rendez-vous + son rappel pour un senior — admin (tout senior) ou aidant (son senior lié)",
        "tags": ["Admin · Appointments"]
      }
    },
    "/admin/seniors/{seniorId}/appointments/{appointmentId}": {
      "delete": {
        "description": "Soft-cancel : passe la ligne `reminders` `kind='appointment'` en `status='cancelled'` (le scan vocal ne la voit plus). Scopé au senior pour fermer toute fuite cross-périmètre. Rendez-vous inconnu / déjà annulé → 404. Aidant non lié → 404.",
        "operationId": "AdminAppointmentsController_deactivate",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "name": "appointmentId",
            "required": true,
            "in": "path",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Rendez-vous annulé"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins et aidants"
          },
          "404": {
            "description": "Rendez-vous / senior hors périmètre / inconnu"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Annuler un rendez-vous (status=cancelled) — admin ou aidant lié",
        "tags": ["Admin · Appointments"]
      }
    },
    "/admin/audit-logs": {
      "get": {
        "operationId": "AdminAuditLogsController_list",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Liste des logs (payload PII masquée, INV-T07b)"
          },
          "400": {
            "description": "Filtres invalides"
          },
          "401": {
            "description": "JWT manquant ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Liste paginée des audit_logs (admin only) — filtres actor/action/resource/senior_id/from/to, payload PII sanitisée",
        "tags": ["Admin · Audit logs"]
      }
    },
    "/admin/caregivers/{caregiverId}/reactivation-notice": {
      "post": {
        "description": "Émet un event Inngest `caregiver.reactivation.requested` consommé par `caregiver-reactivation-dispatcher`. Audit log inscrit avant l'emit (INV-S02). Rate limit 5 / aidant / 24h. INV-T07b — phone E.164 masqué dans `audit_logs.payload`.",
        "operationId": "AdminCaregiverReactivationController_sendReactivation",
        "parameters": [
          {
            "name": "caregiverId",
            "required": true,
            "in": "path",
            "description": "UUID de l'aidant ciblé (table `contacts.id`)",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Body optionnel : `channel` (sms/email/push/auto, défaut auto) + `reason` (texte custom à concaténer, ≤ 500 chars)",
          "content": {
            "application/json": {
              "schema": {
                "type": "string"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Notice acceptée, event Inngest émis. Payload contient `event_id`, `channel_resolved`, `audit_log_id`."
          },
          "400": {
            "description": "Channel invalide ou body Zod"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins (aidant + senior rejetés)"
          },
          "404": {
            "description": "Aidant inexistant"
          },
          "410": {
            "description": "Aidant désactivé (aucun canal joignable)"
          },
          "429": {
            "description": "Rate limit (5 notices / aidant / 24h) dépassé"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Relance un aidant inactif via SMS / email / push (admin uniquement)",
        "tags": ["Admin · Caregivers"]
      }
    },
    "/admin/cost/breakdown": {
      "get": {
        "operationId": "AdminCostController_breakdown",
        "parameters": [],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Breakdown coût (default 30j) par senior|flux|model — admin only",
        "tags": ["Admin · Cost"]
      }
    },
    "/admin/cost/by-senior": {
      "get": {
        "operationId": "AdminCostController_bySenior",
        "parameters": [],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Top N seniors par coût total cumulé sur la période",
        "tags": ["Admin · Cost"]
      }
    },
    "/admin/cost/export.csv": {
      "get": {
        "operationId": "AdminCostController_exportCsv",
        "parameters": [],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Export CSV des appels token_usage (comptabilité)",
        "tags": ["Admin · Cost"]
      }
    },
    "/admin/seniors/{seniorId}/trigger-end-of-day": {
      "post": {
        "description": "Émet immédiatement l'event Inngest `kohami.agent.end_of_day.recap_ready` (trigger=admin_manual) pour le senior cible. Audit log inscrit avant l'emit. Renvoie 202 Accepted + outcome (fired ou skipped + reason). Rate limit dédié : 5/min/actor pour borner les bursts admin (DoS auto-infligé + spam audit log).",
        "operationId": "AdminEndOfDayTriggerController_trigger",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible (table `seniors.id`)",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Trigger accepté. Payload contient `outcome` (fired/skipped), `skip_reason` éventuel, `audit_log_id`."
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins (aidant + senior rejetés)"
          },
          "404": {
            "description": "Senior inexistant ou sans `preferred_name` onboarding"
          },
          "429": {
            "description": "Rate limit dépassé (5/min/actor)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Déclenche manuellement le récap fin de journée d'un senior",
        "tags": ["Admin · Seniors"]
      }
    },
    "/admin/seniors/{seniorId}/medications": {
      "get": {
        "description": "Liste les lignes `reminders` `kind='medication'` du senior, triées `(created_at DESC, id DESC)`. Par défaut seules les actives ; `?include_inactive=true` remonte aussi paused/done/cancelled. Aidant non lié → 404 (INV-P05 fail-closed).",
        "operationId": "AdminMedicationsController_list",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "name": "include_inactive",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["true", "false"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste des médicaments"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins et aidants"
          },
          "404": {
            "description": "Senior hors périmètre / inconnu"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lister les médicaments (rappels kind=medication) d'un senior — admin (tout senior) ou aidant (son senior lié)",
        "tags": ["Admin · Medications"]
      },
      "post": {
        "description": "Crée une ligne `reminders` `kind='medication'` : `title=drug_label`, `description=dose`, `schedule_cron` (recurrence numérique validée) et/ou `scheduled_at` (prochaine échéance). La création EST la définition du rappel : une ligne active avec `scheduled_at` + `voice_prompt_emitted_at` nul est captée par le scan vocal médicament (cron minute). `active=false` crée la ligne en `paused`. Cron malformé → 400. Aidant non lié → 404.",
        "operationId": "AdminMedicationsController_create",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["drug_label"],
                "properties": {
                  "drug_label": {
                    "type": "string",
                    "example": "Doliprane 1000mg"
                  },
                  "dose": {
                    "type": "string",
                    "example": "1 comprimé"
                  },
                  "schedule_cron": {
                    "type": "string",
                    "example": "0 8 * * *"
                  },
                  "scheduled_at": {
                    "type": "string",
                    "format": "date-time"
                  },
                  "active": {
                    "type": "boolean",
                    "default": true
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Médicament créé"
          },
          "400": {
            "description": "Body invalide ou cron malformé"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins et aidants"
          },
          "404": {
            "description": "Senior hors périmètre / inconnu"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Ajouter un médicament + son rappel pour un senior — admin (tout senior) ou aidant (son senior lié)",
        "tags": ["Admin · Medications"]
      }
    },
    "/admin/seniors/{seniorId}/medications/{medicationId}": {
      "delete": {
        "description": "Soft-deactivation : passe la ligne `reminders` `kind='medication'` en `status='cancelled'` (le scan vocal ne la voit plus). Scopé au senior pour fermer toute fuite cross-périmètre. Médicament inconnu / déjà annulé → 404. Aidant non lié → 404.",
        "operationId": "AdminMedicationsController_deactivate",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "name": "medicationId",
            "required": true,
            "in": "path",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Médicament désactivé"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins et aidants"
          },
          "404": {
            "description": "Médicament / senior hors périmètre / inconnu"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Désactiver un médicament (status=cancelled) — admin ou aidant lié",
        "tags": ["Admin · Medications"]
      }
    },
    "/admin/seniors/{seniorId}/mood-entries/seed-test": {
      "post": {
        "description": "Insère `days` rows (défaut 7, max 30) dans `mood_scores` pour le senior cible, étalées sur les jours passés (1 row / jour à mid-day Europe/Paris). `source='manual_entry'`, `confidence=1.0`, tier dérivé du score aléatoire 1-5. **Refuse 403 quand `KOHAMI_ENV=prod`** sauf si `ALLOW_TEST_SEED_IN_PROD=true`. Rate-limit 10 / actor / heure pour borner la pollution de la base.",
        "operationId": "AdminMoodSeedController_seed",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible (table `seniors.id`)",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "days": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 30,
                    "default": 7,
                    "description": "Nombre de rows à seeder (1 par jour, jour J inclus)"
                  },
                  "source": {
                    "type": "string",
                    "enum": ["manual_entry"],
                    "description": "Source persistée sur les rows. `manual_entry` est la seule valeur acceptée — la table CHECK rejette `test_seed`."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Rows mood_scores créées",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "seniorId": "fb685895-8930-4a5b-a66a-c8e14e231f4a",
                    "seededCount": 7,
                    "moodEntries": [
                      {
                        "entryId": "9f7b3c2c-2f7b-4d2a-8b6b-1f1c8b8e6e5e",
                        "moodLevel": 4,
                        "enteredAt": "2026-05-19T12:00:00.000Z",
                        "source": "manual_entry",
                        "tier": "neutre"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins, OU bloqué par `KOHAMI_ENV=prod`"
          },
          "404": {
            "description": "Senior cible inexistant"
          },
          "429": {
            "description": "Rate limit dépassé (10/h/actor)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Seed N mood entries for the dashboard QA (admin-only, dev-only by default)",
        "tags": ["Admin · Mood"]
      }
    },
    "/admin/seniors/{id}/mood-timeline": {
      "get": {
        "description": "Renvoie 1 row par jour avec AVG score, MODE tier et breakdown per_turn/end_of_day/manual_entry. INV-P05 : un aidant ne peut consulter que ses seniors liés.",
        "operationId": "AdminMoodTimelineController_getMoodTimeline",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "description": "Senior UUID",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "name": "period",
            "required": false,
            "in": "query",
            "description": "Fenêtre temporelle (défaut 30d)",
            "schema": {
              "enum": ["7d", "30d", "90d"],
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Timeline quotidienne"
          },
          "401": {
            "description": "JWT manquant"
          },
          "403": {
            "description": "Actor non autorisé à voir ce senior"
          },
          "404": {
            "description": "Senior inexistant"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Timeline humeur quotidienne d'un senior (admin/aidant)",
        "tags": ["Admin · Mood"]
      }
    },
    "/admin/rgpd/retention": {
      "get": {
        "description": "Renvoie `{ threshold_months, stale_seniors[], next_cursor }`. Le dashboard pagine via `?cursor=<base64>` (opaque) et `?limit=` (1..200, défaut 50). `next_cursor` vaut `null` quand la dernière page est atteinte. Tri stable `created_at ASC, id ASC`. Chaque ligne `stale_seniors` agrège `senior_id`, `display_name`, `created_at` (ISO-8601 UTC), `last_session_at` (peut être `null` si jamais de session), et `consents_revoked_count`. Le dashboard propose ensuite l'anonymisation via POST `/v1/admin/rgpd/anonymize/:seniorId`.",
        "operationId": "AdminRgpdController_retention",
        "parameters": [
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "required": false,
            "in": "query",
            "description": "Opaque pagination cursor returned by the previous response under `next_cursor`. Omit on the first call.",
            "schema": {}
          }
        ],
        "responses": {
          "200": {
            "description": "Rapport rétention paginé",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "threshold_months": 6,
                    "stale_seniors": [
                      {
                        "senior_id": "fb685895-8930-4a5b-a66a-c8e14e231f4a",
                        "display_name": "Jeanne Dupont",
                        "created_at": "2025-09-12T08:00:00.000Z",
                        "last_session_at": "2025-10-30T16:42:11.000Z",
                        "consents_revoked_count": 2
                      }
                    ],
                    "next_cursor": "eyJjIjoiMjAyNS0wOS0xMlQwODowMDowMC4wMDBaIiwiaSI6ImZiNjg1ODk1LTg5MzAtNGE1Yi1hNjZhLWM4ZTE0ZTIzMWY0YSJ9"
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Liste les seniors > 6 mois sans activité (SOP §7.2 rétention)",
        "tags": ["RGPD · Admin"]
      }
    },
    "/admin/rgpd/anonymize/{seniorId}": {
      "post": {
        "operationId": "AdminRgpdController_anonymize",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Anonymise un senior (display_name + memory_facts + voice_fingerprints + consents revoked)",
        "tags": ["RGPD · Admin"]
      }
    },
    "/admin/seniors/{seniorId}/caregivers/cascade": {
      "get": {
        "description": "Lit `contacts` (3 tiers principal/secondaire/tertiaire) joint à la dernière `alerts` row + `alert_trails`. Phone E.164 et display_name masqués (INV-T07b). Si aucune alerte, `last_alert: null`.",
        "operationId": "AdminSeniorsViewsController_getCascade",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cascade view rendered"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior introuvable"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Visualiser la cascade caregivers d'un senior (admin only) — primary/secondary/tertiary + fallback Juwa/SAMU + dernier trail",
        "tags": ["Admin · Seniors Views"]
      }
    },
    "/admin/seniors/{seniorId}/caregivers": {
      "get": {
        "description": "Lit les comptes aidants (`users` role='aidant' liés au senior via `senior_id`, non supprimés) et enrichit chacun via LEFT JOIN `contacts` (cascade) quand la row existe — `display_name`/`relationship`/`phone_e164`/`priority`/`channels` sont `null`/`[]` tant que la cascade n'est pas configurée. Un senior peut avoir PLUSIEURS aidants : tous sont retournés, triés par priorité cascade (principal → secondaire → tertiaire, sans-rang en dernier) puis `created_at` du compte. Données RÉELLES non masquées (endpoint de gestion autorisé, pas un log — contrairement à `/caregivers/cascade` qui masque la PII INV-T07b). Enveloppe `{ senior_id, data }`. Senior sans aidant → `data: []` (200). Aidant lié à un AUTRE senior → 404 (anti-leak INV-P05).",
        "operationId": "AdminSeniorsViewsController_listCaregivers",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste { senior_id, data }"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins et aidants"
          },
          "404": {
            "description": "Senior hors périmètre (aidant)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lister les aidants d'un senior (admin + aidant lié) — vue de GESTION, PII NON masquée",
        "tags": ["Admin · Seniors Views"]
      },
      "post": {
        "description": "Rattache un compte aidant EXISTANT (`users` role='aidant', `senior_id IS NULL`) au senior en posant `users.senior_id`. Body `{ user_id }`. Vérifs : senior existe (404 sinon) ; compte cible existe, role='aidant', non soft-deleted (404/409 sinon). Plafond CDC : 4e aidant actif (`deleted_at IS NULL`) → 409. Idempotent : déjà rattaché à CE senior → 200 + son CaregiverSummary (aucune écriture). Rattaché à un AUTRE senior → 409 (le rattachement multi-senior est différé). Sinon → 201 + le CaregiverSummary du nouvel aidant (même forme que la liste). Tracé dans `audit_logs` (event=caregiver_attach + trigger users_audit_update). Réservé aux admins — l'aidant lié ne peut PAS muter le roster (≠ la liste, ouverte à l'aidant lié).",
        "operationId": "AdminSeniorsViewsController_attachCaregiver",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "`{ user_id }` — UUID du compte aidant existant à rattacher",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["user_id"],
                "properties": {
                  "user_id": {
                    "type": "string",
                    "format": "uuid"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Déjà rattaché à ce senior (idempotent) + CaregiverSummary"
          },
          "201": {
            "description": "Aidant rattaché + CaregiverSummary"
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior ou compte aidant introuvable"
          },
          "409": {
            "description": "Plafond 3 atteint, compte non-aidant, OU déjà rattaché à un autre senior"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Rattacher un aidant existant à un senior (admin only) — pose users.senior_id, plafond CDC 3",
        "tags": ["Admin · Seniors Views"]
      }
    },
    "/admin/seniors/{seniorId}/caregivers/{userId}": {
      "delete": {
        "description": "Détache un aidant en remettant `users.senior_id = NULL` : le COMPTE survit (ce n'est PAS un soft-delete RGPD). Idempotent : un aidant déjà détaché ou rattaché à un AUTRE senior → 204 sans écriture (pas de leak du roster d'un autre senior). Senior inexistant ou compte aidant introuvable/non-aidant → 404. Tracé dans `audit_logs` (event=caregiver_detach + trigger users_audit_update). Réservé aux admins.",
        "operationId": "AdminSeniorsViewsController_detachCaregiver",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "name": "userId",
            "required": true,
            "in": "path",
            "description": "UUID du compte aidant à détacher",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Aidant détaché (ou déjà détaché)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior ou compte aidant introuvable"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Détacher un aidant d'un senior (admin only) — pose users.senior_id = NULL (≠ supprimer le compte)",
        "tags": ["Admin · Seniors Views"]
      }
    },
    "/admin/seniors/{seniorId}/reminders": {
      "get": {
        "operationId": "AdminSeniorsViewsController_listReminders",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "name": "kind",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["appointment", "medication"]
            }
          },
          {
            "name": "within_days",
            "required": false,
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 365,
              "default": 30
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste des rappels"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior introuvable"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lister les rappels d'un senior (admin only) — medication / appointment / tous, fenêtre configurable",
        "tags": ["Admin · Seniors Views"]
      }
    },
    "/admin/seniors/{seniorId}/reminders/all": {
      "get": {
        "description": "Sémantique opposée à `/reminders` (qui filtre `status='active' AND scheduled_at >= now() AND kind IN ('appointment','medication')`). Conçu pour l'admin dashboard `/seniors/:id` quand on veut auditer l'historique complet. Filtres opt-in : `?status=`, `?kind=`, `?from=`, `?to=`. Pagination cursor (`?cursor=&limit=`) ordonnée `(created_at DESC, id DESC)`.",
        "operationId": "AdminSeniorsViewsController_listAllReminders",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "name": "cursor",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "to",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "from",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "kind",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["medication", "appointment", "birthday", "custom"]
            }
          },
          {
            "name": "status",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["active", "paused", "done", "cancelled"]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste paginée { data, meta, next_cursor }"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lister TOUS les rappels d'un senior (admin only) — vue brute table reminders, tous statuses + tous kinds + passé + futur, filtres opt-in",
        "tags": ["Admin · Seniors Views"]
      }
    },
    "/admin/seniors/{seniorId}/mood-entries": {
      "get": {
        "description": "Sémantique opposée à `/mood-timeline` (qui retourne le rollup quotidien `AVG score + MODE tier`). Ici on remonte une row par capture `mood_scores` avec `score` 0-100, `confidence` 0-1, `source` (`per_turn` | `end_of_day` | `manual_entry` | `onboarding_j7`), `summary` (résumé léger 2-3 phrases de l'échange, dérivé par Mistral Small en même temps que le score sur les captures `per_turn` ; `null` sur les autres sources, sur les rows legacy, ou si le consentement `conversation` est absent), `note` (toujours `null` — la table ne porte pas de note, le champ est exposé pour symétrie avec le journal côté senior), `captured_at` (alias de `recorded_at`). Filtres opt-in : `?source=`, `?from=`, `?to=` sur `captured_at`. Pagination cursor `(recorded_at DESC, id DESC)` (`?cursor=&limit=` 1..200, default 50).",
        "operationId": "AdminSeniorsViewsController_listMoodEntries",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "name": "cursor",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          },
          {
            "name": "to",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "from",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "source",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "per_turn",
                "end_of_day",
                "manual_entry",
                "onboarding_j7"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste paginée { data, meta, next_cursor }"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lister les entrées brutes du journal d'humeur d'un senior (admin only) — 1 row / capture, sans agrégation",
        "tags": ["Admin · Seniors Views"]
      }
    },
    "/admin/seniors/{seniorId}/absence-mode": {
      "get": {
        "operationId": "AdminSeniorsViewsController_readAbsenceMode",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "État courant `{ active, until }`"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Senior introuvable"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lire l'état du mode absence d'un senior (admin only) — INV-S07",
        "tags": ["Admin · Seniors Views"]
      },
      "put": {
        "description": "Suspend inactivité + check-in matinal pour ce senior — laisse INV-S01/S02 actifs. `until` requis si active=true et doit être strictement dans le futur.",
        "operationId": "AdminSeniorsViewsController_updateAbsenceMode",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "description": "UUID du senior cible",
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "`{ active: boolean, until?: ISO-8601 }` — `until` requis si active=true",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["active"],
                "properties": {
                  "active": {
                    "type": "boolean"
                  },
                  "until": {
                    "type": "string",
                    "format": "date-time"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "État appliqué"
          },
          "400": {
            "description": "Body invalide (Zod) ou `until` dans le passé"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Activer / désactiver le mode absence d'un senior (admin only) — INV-S07, SOP V3.2 §4.3",
        "tags": ["Admin · Seniors Views"]
      }
    },
    "/admin/settings/rgpd": {
      "get": {
        "description": "Retourne la configuration RGPD globale. Si aucune valeur n'a été écrite, retourne les défauts `{ auto_delete: false, anonymize_audit_after_days: 90, retention_days: 365 }`. PUT par un admin pour modifier.",
        "operationId": "AdminSettingsController_getRgpd",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Réglages RGPD courants (ou défauts)",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "auto_delete": false,
                    "anonymize_audit_after_days": 90,
                    "retention_days": 365
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Récupère les réglages globaux RGPD (auto-delete, anonymize_audit_after_days, retention_days)",
        "tags": ["Admin · Settings"]
      },
      "put": {
        "description": "UPSERT idempotent du singleton `admin_settings(section='rgpd')`. Tous les champs sont obligatoires (`auto_delete: boolean`, `anonymize_audit_after_days: int [30..3650]`, `retention_days: int [30..3650]`).",
        "operationId": "AdminSettingsController_putRgpd",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Réglages RGPD mis à jour",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "auto_delete": true,
                    "anonymize_audit_after_days": 180,
                    "retention_days": 730
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Met à jour les réglages globaux RGPD (admin only)",
        "tags": ["Admin · Settings"]
      }
    },
    "/admin/settings/notifications": {
      "get": {
        "description": "Defaults `{ email_enabled: true, sms_enabled: true, critical_only: false }` quand aucune row écrite.",
        "operationId": "AdminSettingsController_getNotifications",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Réglages notifications courants (ou défauts)",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "email_enabled": true,
                    "sms_enabled": true,
                    "critical_only": false
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Récupère les réglages globaux Notifications (email, sms, critical-only)",
        "tags": ["Admin · Settings"]
      },
      "put": {
        "operationId": "AdminSettingsController_putNotifications",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Réglages notifications mis à jour",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "email_enabled": false,
                    "sms_enabled": true,
                    "critical_only": true
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Met à jour les réglages globaux Notifications (admin only)",
        "tags": ["Admin · Settings"]
      }
    },
    "/admin/settings/editorial": {
      "get": {
        "description": "Variables autorisées dans les templates: `{prenom}` (optionnelle). Defaults: `welcome_morning_message=\"Bonjour {prenom}, comment vous sentez-vous aujourd'hui ?\"`, `inactivity_message=\"Je ne vous ai pas vu depuis un moment. Tout va bien ?\"`.",
        "operationId": "AdminSettingsController_getEditorial",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Messages éditoriaux courants (ou défauts)",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "welcome_morning_message": "Bonjour {prenom}, comment vous sentez-vous aujourd'hui ?",
                    "inactivity_message": "Je ne vous ai pas vu depuis un moment. Tout va bien ?"
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Récupère les messages éditoriaux (welcome_morning_message, inactivity_message)",
        "tags": ["Admin · Settings"]
      },
      "put": {
        "operationId": "AdminSettingsController_putEditorial",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Messages éditoriaux mis à jour"
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Met à jour les messages éditoriaux (admin only)",
        "tags": ["Admin · Settings"]
      }
    },
    "/admin/settings/inactivity-thresholds": {
      "get": {
        "description": "Defaults: `{ wakeup_hours: 8, alert_hours: 12 }`. `alert_hours` doit toujours être > `wakeup_hours`. Bornes: `wakeup_hours [1..24]`, `alert_hours [1..72]`. Ces valeurs servent de DEFAULT global pour la création de `senior_inactivity_configs` (override par senior conservé).",
        "operationId": "AdminSettingsController_getInactivityThresholds",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Seuils inactivité par défaut (ou défauts in-code)",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "wakeup_hours": 8,
                    "alert_hours": 12
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Récupère les seuils d'inactivité globaux par défaut (wakeup_hours, alert_hours)",
        "tags": ["Admin · Settings"]
      },
      "put": {
        "operationId": "AdminSettingsController_putInactivityThresholds",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Seuils mis à jour",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "wakeup_hours": 6,
                    "alert_hours": 18
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod) — y compris quand alert_hours <= wakeup_hours"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Met à jour les seuils d'inactivité par défaut (admin only)",
        "tags": ["Admin · Settings"]
      }
    },
    "/admin/settings/appointment-reminders": {
      "get": {
        "description": "Defaults: `enabled=true`, 2 règles `[{label:'H-1', minutes_before:60, channels:['application','vocal'], home_only:false, enabled:true}, {label:'H-15', minutes_before:15, channels:['vocal'], home_only:true, enabled:true}]`, template `\"Rappel : vous avez un rendez-vous {titre} dans {delai}.\"`. Variables autorisées: `{titre}`, `{delai}`, `{prenom}`.",
        "operationId": "AdminSettingsController_getAppointmentReminders",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Règles courantes (ou défauts)",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "enabled": true,
                    "rules": [
                      {
                        "label": "H-1",
                        "minutes_before": 60,
                        "channels": ["application", "vocal"],
                        "home_only": false,
                        "enabled": true
                      },
                      {
                        "label": "H-15",
                        "minutes_before": 15,
                        "channels": ["vocal"],
                        "home_only": true,
                        "enabled": true
                      }
                    ],
                    "message_template": "Rappel : vous avez un rendez-vous {titre} dans {delai}."
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Récupère les règles de rappels d'agenda (H-1 / H-15 / canaux / template)",
        "tags": ["Admin · Settings"]
      },
      "put": {
        "operationId": "AdminSettingsController_putAppointmentReminders",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Règles mises à jour"
          },
          "400": {
            "description": "Body invalide (Zod) — rules.length > 10, channels[] vide, minutes_before hors bornes [1..1440], message_template vide ou > 500 chars"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Met à jour les règles de rappels d'agenda (admin only). Max 10 règles.",
        "tags": ["Admin · Settings"]
      }
    },
    "/admin/stats/overview": {
      "get": {
        "operationId": "AdminStatsController_overview",
        "parameters": [],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "KPIs globaux (users, sessions, tokens, coût 30j)",
        "tags": ["Admin · Stats"]
      }
    },
    "/admin/stats/tokens": {
      "get": {
        "operationId": "AdminStatsController_tokens",
        "parameters": [],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Série temporelle tokens × flux × model (90j)",
        "tags": ["Admin · Stats"]
      }
    },
    "/admin/stats/sessions": {
      "get": {
        "operationId": "AdminStatsController_sessions",
        "parameters": [],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Série temporelle sessions (90j)",
        "tags": ["Admin · Stats"]
      }
    },
    "/admin/stats/alerts": {
      "get": {
        "operationId": "AdminStatsController_alerts",
        "parameters": [],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Série temporelle alertes par niveau et statut (90j)",
        "tags": ["Admin · Stats"]
      }
    },
    "/admin/stats/tokens/seed-test": {
      "post": {
        "description": "Insère `days_back × models.length × flux.length` rows dans `token_usage` étalées sur les jours passés (1 row / (jour, flux, model) à midi UTC). Coûts et tokens dérivés déterministiquement de (`days_back`, `flux`, `model`) pour que Nathan compare deux screenshots sans variance. **Refuse 403 quand `KOHAMI_ENV=prod`** sauf si `ALLOW_TEST_SEED_IN_PROD=true`. Rate-limit 5 / actor / heure.",
        "operationId": "AdminStatsSeedController_seed",
        "parameters": [],
        "requestBody": {
          "required": false,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "days_back": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 90,
                    "description": "Nombre de jours à seeder (default 30, max 90)."
                  },
                  "models": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "minItems": 1,
                    "maxItems": 10,
                    "description": "Models à répartir (default mistral-medium-2604, mistral-small-latest, voxtral-mini-latest)."
                  },
                  "flux": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "enum": ["cache", "memory", "agent"]
                    },
                    "minItems": 1,
                    "maxItems": 3,
                    "description": "Flux orchestrateur à seeder (default cache, memory, agent)."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Rows token_usage créées",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "senior_id": "fb685895-8930-4a5b-a66a-c8e14e231f4a",
                    "days_back": 30,
                    "models": ["mistral-small-2603", "mistral-small-2603"],
                    "flux": ["cache", "memory", "agent"],
                    "seeded_count": 180,
                    "tokens_used_total": 124500,
                    "cost_eur_micros_total": 1234500
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins, OU bloqué par `KOHAMI_ENV=prod`"
          },
          "422": {
            "description": "Aucun senior en base — impossible d'attacher les rows (FK)"
          },
          "429": {
            "description": "Rate limit dépassé (5/h/actor)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Seed token_usage rows for the dashboard QA (admin-only, dev-only by default)",
        "tags": ["Admin · Stats"]
      }
    },
    "/admin/users": {
      "get": {
        "description": "Pagination cursor (`cursor` + `limit` 1..100, default 50). Filtres optionnels : `role` (senior|aidant|admin), `include_deleted` (true|false — default false). Chaque ligne senior inclut un objet `devices` agrégeant l'état d'enrôlement tablette (`active_count`, `revoked_count`, `last_seen_at` UTC ISO-8601, `primary_device_id` = device_id Android le plus récent, `primary_device_uuid` = UUID interne). Les lignes aidant/admin renvoient `devices: null`.",
        "operationId": "AdminUsersController_list",
        "parameters": [
          {
            "name": "search",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 256
            },
            "description": "Recherche case-insensitive (ILIKE `%search%`) sur `users.email` et `seniors.display_name`. Trim + length 1..256."
          },
          {
            "name": "include_deleted",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["true", "false"]
            },
            "description": "Inclure les users soft-deleted (`deleted_at IS NOT NULL`). Par défaut `false`."
          },
          {
            "name": "role",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["senior", "aidant", "admin"]
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Cursor opaque renvoyé par la page précédente (champ `next_cursor`)."
          }
        ],
        "responses": {
          "200": {
            "description": "Page d'utilisateurs avec compteurs globaux (window functions, ignorent le cursor, respectent les filtres `role` / `include_deleted` / `search`) : `total_count` (tous), `total_count_actif` (`deactivated_at IS NULL AND deleted_at IS NULL`), `total_count_desactivate` (`deactivated_at IS NOT NULL AND deleted_at IS NULL`), `total_count_inactif` (comptes actifs dormants depuis > 60 jours, mesurés depuis le dernier signal d'activité ou, à défaut, depuis `created_at` ; signal selon le rôle — heartbeat tablette pour un senior, `last_login_at` pour un aidant/admin ; un compte sans signal mais créé il y a > 60 jours est inactif, un compte récemment créé sans signal est exclu ; comptes désactivés et soft-deleted exclus).",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "next_cursor": null,
                    "total_count": 42,
                    "total_count_actif": 38,
                    "total_count_desactivate": 4,
                    "total_count_inactif": 3,
                    "users": [
                      {
                        "id": "b956f5d8-413a-4b98-b3ad-c34a8c618d2f",
                        "email": "jeanne.dupont@kohami.fr",
                        "role": "senior",
                        "senior_id": "fb685895-8930-4a5b-a66a-c8e14e231f4a",
                        "deleted_at": null,
                        "created_at": "2026-05-24T16:29:41.893Z",
                        "updated_at": "2026-05-24T16:29:41.893Z",
                        "devices": {
                          "active_count": 1,
                          "revoked_count": 0,
                          "last_seen_at": "2026-05-24T16:25:18.412Z",
                          "primary_device_id": "android-tablet-de:ad:be:ef:00:01-c5b1a7e6",
                          "primary_device_uuid": "f7b2c5d4-1a3e-4b78-9b6a-2c5d4e6f7a8b"
                        }
                      },
                      {
                        "id": "182c0828-2bef-4676-9fd4-b3821228421c",
                        "email": "marie.dupont@kohami.fr",
                        "role": "aidant",
                        "senior_id": null,
                        "deleted_at": null,
                        "created_at": "2026-05-24T16:29:41.776Z",
                        "updated_at": "2026-05-24T16:29:41.776Z",
                        "devices": null
                      }
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Liste des users (admin only, pagination cursor)",
        "tags": ["Admin · Users"]
      }
    },
    "/admin/users/{id}": {
      "get": {
        "operationId": "AdminUsersController_findOne",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "User introuvable"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Détail user",
        "tags": ["Admin · Users"]
      },
      "put": {
        "operationId": "AdminUsersController_update",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Modifier role / senior_id (admin only)",
        "tags": ["Admin · Users"]
      },
      "delete": {
        "operationId": "AdminUsersController_remove",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "User soft-deleted"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Soft-delete RGPD (admin only)",
        "tags": ["Admin · Users"]
      }
    },
    "/admin/users/{id}/deactivate": {
      "post": {
        "description": "Stampe `users.deactivated_at = now()` : le compte est verrouillé et masqué (`is_active=false`) de la liste active mais sa ligne et ses PII restent intactes — réversible via `/:id/reactivate`. Distinct du soft-delete RGPD (`DELETE /:id`, qui stampe `deleted_at`). Idempotent : un compte déjà désactivé renvoie 204 sans nouvelle entrée d'audit. L'action est tracée dans `audit_logs` (action=update, resource=users, actor=admin:<id>, payload.deactivated_changed=true) via le trigger `users_audit_update`.",
        "operationId": "AdminUsersController_deactivate",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Compte désactivé (ou déjà inactif)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "User introuvable"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Désactiver un compte (réversible, admin only)",
        "tags": ["Admin · Users"]
      }
    },
    "/admin/users/{id}/reactivate": {
      "post": {
        "description": "Remet `users.deactivated_at = NULL` : le compte redevient actif (`is_active=true`). Idempotent : un compte déjà actif renvoie 204 sans nouvelle entrée d'audit. Tracé dans `audit_logs` (action=update, payload.deactivated_changed=true) via le trigger `users_audit_update`.",
        "operationId": "AdminUsersController_reactivate",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Compte réactivé (ou déjà actif)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "User introuvable"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Réactiver un compte désactivé (admin only)",
        "tags": ["Admin · Users"]
      }
    },
    "/admin/users/{id}/anonymize": {
      "post": {
        "description": "Scrub PII + credentials (email → `anonymized+<id>@deleted.local`, password_hash, secret TOTP + codes de récupération) et stampe `deleted_at`, en gardant la ligne pour l'intégrité référentielle (`contacts.caregiver_user_id`, audit). Le compte ne peut plus se connecter. Réservé aux admins. Refuse un `senior` (chemin droit à l'oubli senior dédié), refuse le dernier admin actif (anti-lockout). Idempotent : une ligne déjà anonymisée renvoie 204. L'action est tracée dans `audit_logs` (action=update, actor=admin) via le trigger `users_audit_update`.",
        "operationId": "AdminUsersController_anonymize",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "User anonymisé (ou déjà anonyme)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "User introuvable"
          },
          "409": {
            "description": "Cible senior, OU dernier admin actif (anti-lockout)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Anonymiser un aidant ou un admin (RGPD, admin only)",
        "tags": ["Admin · Users"]
      }
    },
    "/admin/users/{id}/export": {
      "get": {
        "operationId": "AdminUsersController_exportUser",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Export JSON minimal (RGPD admin) — full export = BLOC-14",
        "tags": ["Admin · Users"]
      }
    },
    "/admin/users/import-csv": {
      "post": {
        "description": "Body JSON `{ csv_base64: string }` (le dashboard encode son CSV en base64 avant POST). Headers requis: `role, email, display_name, phone` (4 colonnes, ordre libre). Le lien aidant↔senior n'est PAS porté par le CSV : les aidants sont créés avec `senior_id=NULL` puis rattachés via la bulk-action du dashboard (cf. JSDoc du service). Atomique : si une ligne est invalide, 400 retourné avec liste détaillée et 0 ligne insérée. Sinon 200 + `temporary_passwords` (les mots de passe générés à communiquer aux utilisateurs ; tous créés avec `must_change_password=true`). Rate-limit 5/h/actor. Content-Length max 3 MB.",
        "operationId": "AdminUsersImportCsvController_importCsv",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["csv_base64"],
                "properties": {
                  "csv_base64": {
                    "type": "string",
                    "description": "CSV encodé en base64 (max ~2 MB après décodage, ~2.7 M chars base64). Header 4 colonnes `role,email,display_name,phone`. Décode ici en `role,email,display_name,phone\\nsenior,jean@doe.fr,Jean Doe,\\naidant,alice@carer.fr,Alice,+14155551212` (le téléphone E.164 est requis pour un aidant).",
                    "example": "cm9sZSxlbWFpbCxkaXNwbGF5X25hbWUscGhvbmUKc2VuaW9yLGplYW5AZG9lLmZyLEplYW4gRG9lLAphaWRhbnQsYWxpY2VAY2FyZXIuZnIsQWxpY2UsKzE0MTU1NTUxMjEy"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "**⚠️ Cette réponse contient des mots de passe en clair temporaires. Le dashboard DOIT les afficher une seule fois et ne JAMAIS les persister ni les logger.** Tableau `temporary_passwords` ordonné, un entry `{ email, password }` par utilisateur créé.",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "accepted": 2,
                    "rejected": 0,
                    "errors": [],
                    "temporary_passwords": [
                      {
                        "email": "jean@doe.fr",
                        "password": "Rg7q5gMqA1bP8eFwYvJL9KtNwXcZBdYU"
                      },
                      {
                        "email": "alice@carer.fr",
                        "password": "0pXa3Mc8L4nVuRz9KqHbWf7sDyEgPmTC"
                      }
                    ]
                  }
                }
              }
            }
          },
          "400": {
            "description": "Au moins une ligne est invalide — `error: 'IMPORT_VALIDATION_FAILED'` + `issues[]` détaille chaque rejet sous la forme `{ path: 'line:N|code:CODE', message: '...' }`. Aucune ligne n'a été insérée (atomique).",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "error": "IMPORT_VALIDATION_FAILED",
                    "message": "2 CSV row(s) rejected",
                    "issues": [
                      {
                        "path": "line:2|code:INVALID_EMAIL",
                        "message": "email 'not-an-email' is malformed"
                      },
                      {
                        "path": "line:3|code:MISSING_PHONE",
                        "message": "phone is required for aidant"
                      }
                    ]
                  }
                }
              }
            }
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "413": {
            "description": "Content-Length supérieur à 3 MB"
          },
          "429": {
            "description": "Rate limit dépassé (5/h/actor)"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Bulk import CSV (seniors / aidants / admins) — atomique tout-ou-rien",
        "tags": ["Admin · Users"]
      }
    },
    "/internal/tickets": {
      "post": {
        "description": "Route protégée par le header `X-Internal-Token` (HMAC partagé `INTERNAL_API_TOKEN`). Refuse `type='silent'` (INV-S02 — voir POST /v1/tickets pour le path admin avec override).",
        "operationId": "InternalTicketsController_create",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Ticket créé. Retourne id + opened_at (alias created_at)."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide ou silent refusé"
          },
          "404": {
            "description": "senior_id ou alert_id référencé introuvable"
          },
          "409": {
            "description": "status='closed' refusé à la création"
          }
        },
        "summary": "Créer un ticket depuis un worker interne (BLOC-10 alertes rouges, SOP §4.5 heartbeat)",
        "tags": ["Tickets (interne)"]
      }
    },
    "/tickets": {
      "post": {
        "description": "Pour les workers internes (BLOC-10, SOP §4.5), utiliser plutôt POST /v1/internal/tickets avec X-Internal-Token. Cette route exige un JWT admin/aidant. Champ `opened_reason` obligatoire (1..1000 chars, min 50 chars si `type=silent` sauf override). Champ `senior_id` UUID obligatoire. Champ `title` obligatoire (1..200 chars). Champ `type` obligatoire ('sos' | 'technical' | 'silent').",
        "operationId": "TicketsController_create",
        "parameters": [
          {
            "name": "override_silent_protection",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["true", "false"]
            },
            "description": "Admin only — passer à 'true' pour créer un ticket 'silent' sans l'exigence INV-S02 (opened_reason ≥ 50 chars). Refusé pour les aidants."
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Payload de création de ticket",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["opened_reason", "senior_id", "title", "type"],
                "properties": {
                  "alert_id": {
                    "type": "string",
                    "format": "uuid",
                    "nullable": true,
                    "description": "UUID de l'alerte associée (optionnel)"
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 2000,
                    "nullable": true
                  },
                  "opened_reason": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 1000,
                    "example": "Tablette ne répond plus depuis ce matin"
                  },
                  "priority": {
                    "type": "string",
                    "enum": ["low", "normal", "high", "urgent"]
                  },
                  "senior_id": {
                    "type": "string",
                    "format": "uuid"
                  },
                  "status": {
                    "type": "string",
                    "enum": ["open", "in_progress", "waiting_senior", "closed"]
                  },
                  "title": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 200,
                    "example": "Tablette figée"
                  },
                  "type": {
                    "type": "string",
                    "enum": ["sos", "technical", "silent"],
                    "example": "technical"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Ticket créé",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "id": "9f7b3c2c-2f7b-4d2a-8b6b-1f1c8b8e6e5e",
                    "opened_at": "2026-05-24T16:35:00.000Z"
                  }
                }
              }
            }
          },
          "400": {
            "description": "INV-S02 : opened_reason < 50 chars pour silent"
          },
          "403": {
            "description": "Silent ticket sans override / actor non admin"
          },
          "404": {
            "description": "senior_id ou alert_id référencé introuvable"
          },
          "409": {
            "description": "status='closed' refusé à la création — utiliser PATCH /v1/tickets/:id/close"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Créer un ticket (admin/aidant — INV-S02 silent override géré ici)",
        "tags": ["Tickets"]
      },
      "get": {
        "description": "Pagination cursor (`cursor` + `limit` 1..100, default 50). Filtres optionnels : `senior_id`, `status` (open|in_progress|waiting_senior|closed), `type` (sos|technical|silent). Le scope RLS dépend du rôle : senior → ses tickets, aidant → tickets de son senior rattaché, admin → tout (et peut filtrer par `senior_id`).",
        "operationId": "TicketsController_list",
        "parameters": [
          {
            "name": "type",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["sos", "technical", "silent"]
            }
          },
          {
            "name": "status",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "enum": ["open", "in_progress", "waiting_senior", "closed"]
            }
          },
          {
            "name": "senior_id",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "limit",
            "required": false,
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 100,
              "default": 50
            }
          },
          {
            "name": "cursor",
            "required": false,
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Page de tickets",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "next_cursor": null,
                    "tickets": [
                      {
                        "id": "9f7b3c2c-2f7b-4d2a-8b6b-1f1c8b8e6e5e",
                        "senior_id": "fb685895-8930-4a5b-a66a-c8e14e231f4a",
                        "alert_id": null,
                        "type": "technical",
                        "status": "open",
                        "priority": "normal",
                        "title": "Tablette figée",
                        "description": null,
                        "opened_reason": "Tablette ne répond plus depuis ce matin",
                        "assigned_to": null,
                        "closed_at": null,
                        "closed_by": null,
                        "resolution_notes": null,
                        "created_at": "2026-05-24T16:35:00.000Z",
                        "updated_at": "2026-05-24T16:35:00.000Z"
                      }
                    ]
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lister les tickets (RLS scoped admin/aidant/senior)",
        "tags": ["Tickets"]
      }
    },
    "/tickets/{id}": {
      "get": {
        "operationId": "TicketsController_findOne",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "404": {
            "description": "Ticket introuvable ou non accessible"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Détail ticket + audit_logs liés",
        "tags": ["Tickets"]
      },
      "patch": {
        "description": "PATCH partiel — au moins un champ doit être fourni, sinon 400. Champs autorisés : `assigned_to` (user id ou null), `description` (≤ 2000 chars ou null), `opened_reason` (1..1000 chars), `priority` (low|normal|high|urgent), `status` (open|in_progress|waiting_senior|closed), `type` (sos|technical|silent). Note : la fermeture (status=closed) ne passe PAS par cet endpoint — utiliser PATCH `:id/close` qui exige `resolution_notes` et écrit la trace audit.",
        "operationId": "TicketsController_update",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Patch partiel — au moins un champ requis",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "assigned_to": {
                    "type": "string",
                    "maxLength": 128,
                    "nullable": true,
                    "example": "aidant-uuid-or-display-name"
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 2000,
                    "nullable": true
                  },
                  "opened_reason": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 1000
                  },
                  "priority": {
                    "type": "string",
                    "enum": ["low", "normal", "high", "urgent"]
                  },
                  "status": {
                    "type": "string",
                    "enum": ["open", "in_progress", "waiting_senior", "closed"]
                  },
                  "type": {
                    "type": "string",
                    "enum": ["sos", "technical", "silent"]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ticket mis à jour"
          },
          "400": {
            "description": "Body vide (au moins un champ requis) ou Zod invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          },
          "404": {
            "description": "Ticket introuvable"
          },
          "409": {
            "description": "status='closed' refusé ici — utiliser PATCH /v1/tickets/:id/close"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Modifier un ticket (admin uniquement)",
        "tags": ["Tickets"]
      },
      "delete": {
        "operationId": "TicketsController_remove",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Ticket soft-deleted"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Soft-delete d'un ticket (admin uniquement)",
        "tags": ["Tickets"]
      }
    },
    "/tickets/{id}/close": {
      "patch": {
        "description": "PATCH (pas POST). Body obligatoire `{ resolution_notes: string }` — 1..2000 chars, ne peut pas être vide. Trace audit_log écrite avec `action=close`. Renvoie le ticket mis à jour.",
        "operationId": "TicketsController_close",
        "parameters": [
          {
            "name": "id",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Notes de résolution (1..2000 chars, obligatoire)",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["resolution_notes"],
                "properties": {
                  "resolution_notes": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 2000,
                    "example": "Tablette rebootée à distance, aidant prévenu par SMS"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Ticket fermé"
          },
          "400": {
            "description": "`resolution_notes` absent ou invalide (Zod)"
          },
          "404": {
            "description": "Ticket introuvable"
          },
          "409": {
            "description": "Ticket déjà fermé"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Fermer un ticket (admin/aidant)",
        "tags": ["Tickets"]
      }
    },
    "/users/me/export": {
      "get": {
        "operationId": "UserMeRgpdController_exportSelf",
        "parameters": [],
        "responses": {
          "200": {
            "description": ""
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Export RGPD complet (user + senior associé + devices/consents/alerts/sessions/tickets/audit)",
        "tags": ["RGPD · Self-service user"]
      }
    },
    "/users/me/erasure": {
      "post": {
        "operationId": "UserMeRgpdController_erase",
        "parameters": [],
        "responses": {
          "204": {
            "description": "Compte effacé"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Droit à l'oubli — anonymisation immédiate + purge < 72h",
        "tags": ["RGPD · Self-service user"]
      }
    },
    "/assistance/describe-image": {
      "post": {
        "operationId": "AssistanceImagesController_describeImage",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Description sanitizée + entités détectées"
          },
          "413": {
            "description": "Image plus grande que ASSISTANCE_IMAGE_MAX_BYTES"
          },
          "415": {
            "description": "mimeType non supporté"
          },
          "422": {
            "description": "Visage humain détecté → traitement bloqué (INV-T05) ou délai dépassé"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Décrire en français factuel un courrier admin photographié par le senior (SOP V3.2 §3.4b)",
        "tags": ["Assistance"]
      }
    },
    "/vision/letter-read": {
      "post": {
        "operationId": "LetterReadController_readLetter",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Description OCR + raw_text + reformulated_text + flag sensitive_zones_masked"
          },
          "413": {
            "description": "Image plus grande que ASSISTANCE_IMAGE_MAX_BYTES"
          },
          "415": {
            "description": "mimeType non supporté"
          },
          "422": {
            "description": "Visage humain détecté → traitement bloqué (INV-T05) ou délai dépassé"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "OCR + reformulation d'un courrier administratif (CDC §5.9). Masque IBAN/NIR/mots de passe.",
        "tags": ["Vision"]
      }
    },
    "/notifications/send": {
      "post": {
        "description": "Endpoint de déclenchement manuel depuis le dashboard administrateur. Persiste d'abord une row dans `notifications` (INV-S02 : aucune trace silencieuse, même en cas de timeout réseau), puis appelle le port correspondant au canal (`sms` → Twilio, `push` → Expo Push). Seuls `sms` et `push` sont supportés à ce stade — `email` / `in-app` retournent 400. Retour **202 Accepted** : la livraison provider peut être asynchrone, le client doit consulter `GET /v1/notifications/history` pour suivre le statut final (`queued` → `sent` → `delivered` ou `failed`).",
        "operationId": "NotificationsController_send",
        "parameters": [],
        "requestBody": {
          "required": true,
          "description": "Payload de dispatch de notification",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["senior_id", "channel", "recipient", "body"],
                "properties": {
                  "senior_id": {
                    "type": "string",
                    "format": "uuid",
                    "description": "UUID du senior cible (table `seniors.id`)"
                  },
                  "caregiver_user_id": {
                    "type": "string",
                    "format": "uuid",
                    "nullable": true,
                    "description": "UUID de l'aidant destinataire (optionnel — si la notification est destinée à un aidant en particulier plutôt qu'au senior lui-même)"
                  },
                  "channel": {
                    "type": "string",
                    "enum": ["sms", "push", "email", "in-app"],
                    "description": "Canal de livraison. **Seuls `sms` et `push` sont dispatchables** pour l'instant (les autres canaux retournent 400).",
                    "example": "push"
                  },
                  "recipient": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 512,
                    "description": "Identifiant du destinataire selon le canal : numéro E.164 pour `sms`, ExponentPushToken pour `push`",
                    "example": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]"
                  },
                  "body": {
                    "type": "string",
                    "minLength": 1,
                    "maxLength": 2000,
                    "description": "Corps du message livré au destinataire",
                    "example": "Rappel : votre rendez-vous médical est demain à 10h."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Notification persistée + envoi déclenché",
            "content": {
              "application/json": {
                "schema": {
                  "example": {
                    "notification_id": "9f7b3c2c-2f7b-4d2a-8b6b-1f1c8b8e6e5e",
                    "status": "sent"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Body invalide (Zod) ou canal non supporté (`email`, `in-app`)"
          },
          "401": {
            "description": "JWT absent ou invalide"
          },
          "403": {
            "description": "Réservé aux admins"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Dispatcher une notification (admin only)",
        "tags": ["Notifications"]
      }
    },
    "/notifications/history": {
      "get": {
        "description": "Senior ne voit que ses propres notifications (INV-P05). Aidant ne voit que celles de son senior (`senior_id` claim JWT). Admin voit tout, peut filtrer par `?senior_id=…`.",
        "operationId": "NotificationsController_history",
        "parameters": [],
        "responses": {
          "200": {
            "description": "Page d'historique"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Historique des notifications (RLS scoped)",
        "tags": ["Notifications"]
      }
    },
    "/me/entertainment-toggle": {
      "get": {
        "operationId": "EntertainmentToggleController_read",
        "parameters": [],
        "responses": {
          "200": {
            "description": "État du toggle"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Lire l'état du toggle divertissement du senior connecté (default ON)",
        "tags": ["Me"]
      },
      "put": {
        "operationId": "EntertainmentToggleController_update",
        "parameters": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": ["enabled"],
                "properties": {
                  "enabled": {
                    "type": "boolean"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Toggle mis à jour"
          }
        },
        "security": [
          {
            "bearer": []
          }
        ],
        "summary": "Activer ou désactiver le divertissement (alertes restent actives même OFF)",
        "tags": ["Me"]
      }
    },
    "/admin/seniors/{seniorId}/subscription-tier": {
      "patch": {
        "operationId": "AdminSubscriptionController_updateTier",
        "parameters": [
          {
            "name": "seniorId",
            "required": true,
            "in": "path",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": ""
          }
        },
        "tags": ["AdminSubscription"]
      }
    },
    "/voice/internal/memory/retrieve": {
      "post": {
        "description": "Route protégée par `X-Internal-Token` (HMAC partagé `INTERNAL_API_TOKEN`). Consommée par le child fork du voice agent (`BridgedMemoryRetrieval`). Pas de fact retourné si `ConsentPort.hasConsent(seniorId, 'conversation')` retourne false.",
        "operationId": "VoiceInternalController_retrieveMemory",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste de facts récupérés."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Retourne les souvenirs long-terme pertinents pour un transcript senior (filtrage consentement INV-C01)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/memory/facts": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le smoke L4 C3 (`smoke-l4-c3-consent-memoire`) pour verrouiller le cycle write/read/revoke/re-grant des consents. Pipeline synchrone : (1) UPSERT idempotent du consent pour la catégorie cible (mirror du pattern `/turn`), (2) embed via `MemoryEmbedderPort.embed(text)` — 1024 dims Mistral, (3) INSERT dans `memory_facts` avec confidence optionnel, (4) renvoie `{fact_id, persisted:true}`. Bypass volontaire du workflow Inngest `memory-extraction-capture` pour donner aux smokes une vue déterministe (post-POST → row visible).",
        "operationId": "VoiceInternalController_createMemoryFact",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Fact persisté en pgvector."
          },
          "400": {
            "description": "Payload invalide"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Persiste un memory fact pgvector pour un senior, avec auto-grant du consentement de la catégorie (INV-C01)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/distress/classify": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le child fork du voice agent (`BridgedDistressClassifier`). Le classifier reçoit un `ConversationContext` synthétique (sessionId généré côté serveur, seniorId du body).",
        "operationId": "VoiceInternalController_classifyDistress",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Résultat de classification."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Classifie le niveau de détresse d'un transcript senior (Mistral Large, INV-S01)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/distress/alert": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le child fork (`BridgedDistressAlert`). Gated par `DISTRESS_CLASSIFIER_ENABLED` (SOP V3.2 §3.5 reportée V2 — DIV-017, ADR `distress-v1-neutralization.md`). Quand le flag est `false` (V1 défaut), répond 202 + log structuré uniquement (aucune cascade Inngest — la détresse V1 passe par le bouton SOS SOP 4.1 et le prompt voix). Quand le flag est `true` (V2 future), émet `alert.created` sur Inngest pour déclencher la cascade `emergency-escalation` (BLOC-10).",
        "operationId": "VoiceInternalController_dispatchDistressAlert",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Alerte acceptée pour escalade."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Dispatche une alerte de détresse (BLOC-10 escalade Inngest, INV-S01)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/session/ended": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le child fork (`SessionEndedDispatcher`). Émet l'event Inngest `voice/memory.extraction.requested` qui pilote le workflow `memory-extraction-capture` (Mistral Small extraction + Mistral Embed + persist via `MemoryPort` avec gate `ConsentPort`). Best-effort : une défaillance Inngest n'est pas propagée — le bundle est perdu, jamais le pipeline voix.",
        "operationId": "VoiceInternalController_receiveSessionEnded",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Bundle accepté pour extraction asynchrone."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Reçoit le bundle de transcripts d'une session voix terminée et déclenche l'extraction mémoire post-session via Inngest",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/onboarding/extract-incremental": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le child fork (`IncrementalOnboardingExtractionDispatcher`) après chaque tour user. Émet uniquement `voice/onboarding.extraction.requested` (pas de memory extraction — économie d'embed). Le workflow Inngest est idempotent (state machine merge), donc plusieurs émissions sur la même session sont sans effet de bord. Pourquoi : le tablet KOHAMI reste 24/7 dans la room LiveKit (le senior coupe juste le micro), donc le `session.close` ne se produit jamais — la state machine onboarding ne pourrait pas avancer sans cette extraction par-tour.",
        "operationId": "VoiceInternalController_receiveOnboardingIncrementalExtraction",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Tour accepté pour extraction asynchrone."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Déclenche une extraction NLU onboarding incrémentale à partir du buffer cumulé d'une session voix toujours active",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/onboarding/extract-and-advance": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le child fork (`SynchronousExtractorClient`) depuis le hook `KohamiAgent.onUserTurnCompleted`. Garantie de séquence : `SELECT FOR UPDATE` + Mistral Small NLU + xstate advance + COMMIT happen avant que `llmNode` ne démarre côté voice-worker, donc le LLM voit toujours le `missingSlots` post-extraction. Retourne le snapshot frais (post-advance) pour que le voice-worker court-circuite un round-trip supplémentaire de `BridgedOnboardingContextResolver`.",
        "operationId": "VoiceInternalController_extractAndAdvanceOnboarding",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Extraction committed, snapshot post-advance retourné."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Extraction NLU onboarding synchrone — commit slot + advance xstate AVANT retour HTTP (race-fix Option A)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/onboarding/context": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le child fork (`BridgedOnboardingContextResolver`). Retourne `{ context: null }` quand le senior n'est pas onboardé ou que le parcours est `completed` — Helena tombe alors sur le prompt baseline.",
        "operationId": "VoiceInternalController_retrieveOnboardingContext",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Context onboarding ou null si rien à suggérer."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Retourne le contexte onboarding du senior pour enrichir le prompt voix (slots manquants + jour courant)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/onboarding/ensure-started": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le child fork (`BridgedOnboardingAutoStarter`) à la 1re session voix d'un senior. Boucle la vision « onboarding invisible » : pas d'endpoint REST manuel, le parcours xstate démarre automatiquement.",
        "operationId": "VoiceInternalController_ensureOnboardingStarted",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "État courant du senior + flag `started=true` si la state machine vient d'être démarrée."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Démarre la state machine onboarding du senior si elle ne tourne pas encore (idempotent)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/environment-context/resolve": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Consommée par le child fork (`BridgedEnvironmentContextProvider`) à chaque tour LLM. Compose les ports underneath (météo, last activity, mood, reminders, birthdays) avec un graceful fallback : un sous-port qui échoue ne casse jamais le tour, son champ retourne null/empty.",
        "operationId": "VoiceInternalController_resolveEnvironmentContext",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Contexte environnemental résolu pour le senior."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Compose le contexte environnemental complet du senior (heure, date, météo, mood, dernière interaction, rappels, anniversaires) pour enrichir le prompt voix",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/me/birthdays": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Wrap autour de `MeDataService.listBirthdays` — laisse le tool LLM accéder à la même source que le endpoint REST `/v1/me/birthdays`.",
        "operationId": "VoiceInternalController_listMeBirthdays",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste des anniversaires upcoming"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Liste les anniversaires de proches à venir pour un senior — consommé par le voice tool `getBirthdayList`",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/me/reminders": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Wrap autour de `MeDataService.listReminders` — même source que le endpoint REST `/v1/me/reminders`.",
        "operationId": "VoiceInternalController_listMeReminders",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste des rappels upcoming"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Liste les rappels (medication/appointment) à venir pour un senior — consommé par le voice tool `getAppointmentList`",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/me/reminders/create": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Helena doit avoir confirmé la date/heure auprès du senior AVANT d'invoquer ce endpoint (cf. prompt UX-02). Le service valide la fenêtre temporelle (date future, < 365 j) et persiste via `CreateReminderService`.",
        "operationId": "VoiceInternalController_createReminderRoute",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Rappel créé, `reminderId` retourné."
          },
          "400": {
            "description": "Payload invalide"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Crée un rappel (rendez-vous ou médicament) pour un senior — consommé par le voice tool `createAppointment` (UX-02)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/me/weekly-recap": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Wrap autour de `WeeklyRecapService.buildRecap`. Retourne `{summary, highlights}` — Helena verbalise le summary tel quel.",
        "operationId": "VoiceInternalController_fetchWeeklyRecap",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Récap construit (peut être vide si peu d'historique)"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Récap chaleureux des activités récentes du senior — consommé par le voice tool `getWeeklyRecap` (UX-23)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/assistance/describe-latest": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Récupère le dernier blob non purgé, le déchiffre et invoque l'`ImageDescriptionPort` (Mistral Small 4 unifié). Retourne `{found:false}` quand aucune image n'est stockée, sinon `{found:true, imageId, description, detectedEntities, confidence}`.",
        "operationId": "VoiceInternalController_describeLatestImageRoute",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Description de l'image (ou found:false si aucune image stockée)"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          },
          "422": {
            "description": "Visage humain détecté (INV-T05) ou délai vision Mistral dépassé"
          }
        },
        "summary": "Décrit la dernière image stockée pour un senior — consommé par le voice tool `describeImage` (P-07 Slack Nathan 2026-05-19)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/assistance-technique/observe": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Délègue à `AssistanceTechniqueTracker.observe()`. Quand le seuil 6 actions / 15 min est franchi, le tracker émet `assistance.technique.escalation.requested` sur Inngest + notifie le senior (INV-S02). La réponse inclut `escalation: {reason, step_count, elapsed_ms}` quand l'escalade a été déclenchée pour que le voice fork puisse publier `assistance:session_escalated` sur le DataChannel.",
        "operationId": "VoiceInternalController_observeAssistanceTechnique",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Step enregistrée, escalation possiblement déclenchée."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Enregistre une étape d'assistance technique (SOP V3.2 §3.4a) — consommé par le voice tool `recordAssistanceStep`",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/assistance-administrative/observe": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Délègue à `AssistanceAdministrativeTracker.observe()`. Quand le seuil 3 demandes / 20 min est franchi, le tracker émet `assistance.administrative.escalation.requested` sur Inngest + notifie le senior (INV-S02). Même contrat de réponse que la route technique : `escalation` non-null = seuil franchi, voice fork publie `assistance:session_escalated`.",
        "operationId": "VoiceInternalController_observeAssistanceAdministrative",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Demande enregistrée, escalation possiblement déclenchée."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Enregistre une demande d'assistance administrative (SOP V3.2 §3.4b) — consommé par le voice tool `recordAssistanceStep`",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/assistance/contact-aidant/invoke": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Délègue à `InvokeContactAidantService` qui résout le nom (`aidantName`) en `aidantId` via `PgAidantWhitelistResolver.findByName` puis invoque la stack sécurisée `AidantNotificationPort` (PR #471 — killswitch → idempotency → rate limit → whitelist → circuit breaker → audit → Twilio adapter). Retourne le `call_id` + `outcome` + identifiants masqués (INV-T07b) que le voice fork rejoue sur la DataChannel (`outbound_call:started` / `:ended`). INV-S02 — tout appel laisse une trace via l'audit log + l'event DataChannel.",
        "operationId": "VoiceInternalController_invokeContactAidantRoute",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome de l'appel (success / failed_*) + identifiants masqués prêts pour la DataChannel"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Déclenche un appel sortant vers un aidant whitelisté du senior — consommé par le voice tool `contactAidant` (BLOC-09 runtime wiring)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/entertainment/suggest-activity": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Délègue à `EntertainmentToolRegistryService.invokeByName('entertainment.suggest_activity')` qui honore le toggle ON/OFF par senior (AC-08-13). Quand le toggle est OFF, renvoie `skipped:true` + payload neutre. Quand ON, renvoie l'activité retenue (label + durée + rationale + mood bucket) — le voice fork publie ensuite `entertainment:suggestion` + `tool.open` sur la DataChannel.",
        "operationId": "VoiceInternalController_suggestEntertainmentActivity",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Suggestion retournée (ou skipped:true si toggle OFF, payload neutre)"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Propose une activité adaptée au mood courant du senior (SOP V3.2 §2.1, §2.3) — consommé par le voice tool `suggestActivity` (deep audit V2 runtime-orphans 2026-05-19)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/entertainment/cultural-content": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Délègue à `EntertainmentToolRegistryService.invokeByName('entertainment.cultural_content')` qui honore le toggle ON/OFF par senior. Le V1 catalog enforce une `source` non-vide à chaque entrée (AC-08-11). Le voice fork publie ensuite `entertainment:cultural` + `tool.open` sur la DataChannel.",
        "operationId": "VoiceInternalController_shareEntertainmentCulturalContent",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Capsule culturelle retournée (ou skipped:true si toggle OFF, payload neutre)"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Retourne une capsule culturelle avec source attribuée (CDC §3.8, AC-08-10/11) — consommé par le voice tool `shareCulturalContent` (deep audit V2 runtime-orphans 2026-05-19)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/alerts/cancel-latest": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Émet l'event Inngest `alert.button.cancelled` que le workflow `alert-button-handler` matche sur `senior_id` via son `waitForEvent`, débloquant la cascade avant émission du ticket aidant. INV-S02 — toute annulation est tracée via Inngest (event-key dedup) ; un duplicate retry reste observable. Retourne `outcome=cancelled` si l'event a été envoyé, `outcome=no_active_alert` si aucune alerte n'est en cours (ce cas est traité côté tool comme une demande indue, Helena répond chaleureusement sans drama).",
        "operationId": "VoiceInternalController_cancelLatestAlert",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Annulation acceptée (Inngest event émis)"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Annule la dernière alerte en cours (cascade panic-button / détresse) en émettant `alert.button.cancelled` sur Inngest — consommé par le voice tool `cancelLastAlert` (Nathan UX gap 2026-05-21)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/turn": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Pipeline synchrone : auto-grant du consentement `conversation` (idempotent UPSERT sur (senior_id, category)) → `MoodScorerPort.score(transcript)` → mapping score→tier (0-25 preoccupant, 26-50 melancolique, 51-75 neutre, 76-100 enjoue) → `MoodScoreRepositoryPort.insert({source:'per_turn'})`. Retourne `{turn_id, mood_score, mood_tier, reply_text, e2e_latency_ms}`. Bypass volontaire de l'Inngest async path pour donner aux smokes L4 une vue déterministe (post-POST → row visible). Le production voice path (LiveKit → Voxtral) reste inchangé.",
        "operationId": "VoiceInternalController_receiveInternalTurn",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Turn traité, mood persisté, latence mesurée."
          },
          "400": {
            "description": "Payload invalide"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Endpoint interne text-only pour simuler un turn voix sans WebRTC — consommé par les smokes L4 INV-C02 (mood par turn) + INV-C07 (latence e2e)",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/internal/dev/inject-user-transcript": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. **Dev-only** : refuse 403 quand `KOHAMI_ENV=prod` ou quand `DEV_MOCK_TRANSCRIPT_INJECTION_ENABLED !== 'true'`. Publie un message sur le topic LiveKit `kohami.dev.mock_user_transcript`, consommé côté child fork voice-worker par un listener strictement gated sur `KOHAMI_ENV !== 'prod'`. Le fork appelle alors `session.generateReply({ userInput })` pour pousser le transcript comme user message dans `chatCtx` (comme si la STT avait transcrit la phrase). Utilisé exclusivement par les smokes L4 (`safety-distress-ideation*`) dont la TTS Voxtral est bloquée par le guardrail safety Mistral (code 1920).",
        "operationId": "VoiceInternalController_injectUserTranscript",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Transcript publié sur la room (best-effort)."
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide, KOHAMI_ENV=prod, ou flag dev désactivé"
          }
        },
        "summary": "(dev-only) Pousse un faux transcript senior sur la room LiveKit pour les fixtures bloquées par Mistral safety guardrail",
        "tags": ["Voice (interne)"]
      }
    },
    "/voice/dry-run": {
      "post": {
        "description": "Route protégée par `X-Internal-Token`. Exerce cache/memory/distress en parallèle pour `(transcript, seniorId)` et retourne le routing summary qu'aurait produit `KohamiAgent.llmNode`. Utilisé par les smoke tests CI pour asserter le routing sans STT.",
        "operationId": "VoiceDryRunController_dryRun",
        "parameters": [
          {
            "name": "x-internal-token",
            "required": true,
            "in": "header",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Routing summary"
          },
          "401": {
            "description": "X-Internal-Token absent"
          },
          "403": {
            "description": "X-Internal-Token invalide"
          }
        },
        "summary": "Dry-run du routing 3-flux + détresse sans appel Mistral (capability E2E)",
        "tags": ["Voice (debug)"]
      }
    }
  },
  "info": {
    "title": "KOHAMI API",
    "description": "Back-end de l'avatar IA KOHAMI (apps/api)",
    "version": "1.0.0",
    "contact": {}
  },
  "tags": [],
  "servers": [],
  "components": {
    "securitySchemes": {
      "bearer": {
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "type": "http"
      }
    },
    "schemas": {
      "MeBirthdayItemSchema": {
        "type": "object",
        "properties": {
          "birthdayDate": {
            "type": "string",
            "description": "Anniversaire en ISO-8601 (date à minuit UTC).",
            "example": "1955-05-20T00:00:00.000Z"
          },
          "createdByCaregiverId": {
            "type": "string",
            "description": "`users.id` de l'aidant/admin créateur depuis le dashboard, `null` si le proche a été ajouté par le sénior via Helena.",
            "nullable": true,
            "example": null
          },
          "daysUntil": {
            "type": "number",
            "description": "Nombre de jours entiers jusqu'à la prochaine occurrence.",
            "example": 6,
            "minimum": 0
          },
          "personName": {
            "type": "string",
            "description": "Nom du proche.",
            "example": "Marie"
          },
          "source": {
            "type": "string",
            "enum": ["dashboard", "helena"],
            "description": "Provenance dérivée de `createdByCaregiverId` (`null` → `helena`, sinon `dashboard`).",
            "example": "dashboard"
          }
        },
        "required": [
          "birthdayDate",
          "createdByCaregiverId",
          "daysUntil",
          "personName",
          "source"
        ]
      },
      "MeBirthdaysListingSchema": {
        "type": "object",
        "properties": {
          "birthdays": {
            "description": "Anniversaires à venir, triés par date croissante.",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MeBirthdayItemSchema"
            }
          },
          "withinDays": {
            "type": "number",
            "description": "Fenêtre appliquée (écho de `within_days`, défaut 30).",
            "example": 30
          }
        },
        "required": ["birthdays", "withinDays"]
      },
      "MeReminderItemSchema": {
        "type": "object",
        "properties": {
          "createdByCaregiverId": {
            "type": "string",
            "description": "`users.id` de l'aidant/admin créateur depuis le dashboard, `null` si le rappel a été créé par le sénior via Helena.",
            "nullable": true,
            "example": null
          },
          "kind": {
            "type": "string",
            "enum": ["appointment", "medication"],
            "description": "Type de rappel.",
            "example": "medication"
          },
          "scheduledAt": {
            "type": "string",
            "description": "Échéance en ISO-8601.",
            "example": "2026-05-31T09:00:00.000Z"
          },
          "source": {
            "type": "string",
            "enum": ["dashboard", "helena"],
            "description": "Provenance dérivée de `createdByCaregiverId` (`null` → `helena`, sinon `dashboard`).",
            "example": "helena"
          },
          "title": {
            "type": "string",
            "description": "Libellé du rappel.",
            "example": "Doliprane"
          }
        },
        "required": [
          "createdByCaregiverId",
          "kind",
          "scheduledAt",
          "source",
          "title"
        ]
      },
      "MeRemindersListingSchema": {
        "type": "object",
        "properties": {
          "kind": {
            "type": "string",
            "enum": ["all", "appointment", "medication"],
            "description": "Filtre appliqué (écho de `?kind`, défaut `all`).",
            "example": "all"
          },
          "reminders": {
            "description": "Rappels à venir, triés par `scheduledAt` croissant.",
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/MeReminderItemSchema"
            }
          },
          "withinDays": {
            "type": "number",
            "description": "Fenêtre appliquée (écho de `within_days`, défaut 30).",
            "example": 30
          }
        },
        "required": ["kind", "reminders", "withinDays"]
      }
    }
  }
}
