From 6c455e4d7c8d3756628cd5d946268d3da0c2dd1f Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Thu, 13 Aug 2026 10:11:35 +0200 Subject: [PATCH] =?UTF-8?q?Lot=20F=20livr=C3=A9=20c=C3=B4t=C3=A9=20serveur?= =?UTF-8?q?,=20manager-app=20et=20landing=20=E2=80=94=20et=20six=20points?= =?UTF-8?q?=20de=20STATUS=20qui=20affirmaient=20du=20faux?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le volet manager-service du lot F est refermé, plus le bouton du portail, le diviseur de questions et la FAQ de la landing. Ce que la vérification dans le code a corrigé dans les docs : - la ligne « À faire » du §1 de STATUS était périmée sur six points. Le rate limiting y figurait comme à faire alors qu'il est livré (politique, UseRateLimiter et les deux endpoints décorés), tout comme l'écran d'audit log, la gestion des users et ParcoursIds. L'unification QuestionType n'est pas « à faire » mais écartée. - l'endpoint de mise à jour d'ApplicationInstance n'a jamais manqué : le CRUD est complet. Ce qui manque est l'écran SuperAdmin, déjà parqué en V2. - les quotas seed 5M/20M étaient déjà alignés. Kanban : la carte du portail est close, deux entrées entrent dans Fait récemment. Compteurs re-mesurés — Planifié 25, Fait récemment 51 — colonnes et bandeau vérifiés l'un contre l'autre, jamais par delta. Ce commit emporte aussi les 3 cartes V2 stagées par une autre session (nettoyage downloadConfiguration, swagger périmés, colonne path écrasée) : elles vivent dans le même fichier kanban.html. Co-Authored-By: Claude Opus 5 --- STATUS.md | 13 +++++++++-- kanban.html | 62 +++++++++++++++++++++++++++++++++++++++++------------ v1-plan.md | 23 ++++++++++++++++---- 3 files changed, 78 insertions(+), 20 deletions(-) diff --git a/STATUS.md b/STATUS.md index 658d3b4..f4cd69a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -14,7 +14,8 @@ Résumé de la table de suivi de ce fichier : - **Fait** : SectionEvent, Escape/Puzzle games, GuidedPath + progression carte, push, stats tracking, sync agenda/météo, traduction IA, quotas (IA/stockage/stats) par plan, audit log backend fiabilisé, refacto SectionParcours/GuidedPath/SectionGame (backend, vérifié 2026-07-15). - **Partiel** : Beacons/geofencing (manager-app UI ⚠️), AI Assistant (manager-app ⚠️), **mode offline — diagnostic posé le 2026-08-07, bien pire qu'un « sélectif » : le pipeline de collecte des ressources est commenté des deux côtés, une visite téléchargée n'embarque ni images d'articles ni audios → [v2/offline-visit-plan.md](v2/offline-visit-plan.md)**, repasse UI globale, refacto SectionParcours côté manager-app/visitapp-web/mymuseum-visitapp (UI présente mais sous-comportements non revérifiés en détail — voir section "Refactoring majeur" de todo-features.md). - **🧪 Implémenté mais jamais testé (2026-08-05)** : **onboarding self-service complet** — inscription sur myinfomate-landing (`/[lang]/signup`), création auto de l'instance + du premier user, essai 14j, Stripe (customer/Checkout/webhook/Tax), 9 emails Resend (domaine vérifié, envoi réel validé), job Hangfire du cycle d'essai, watermark « Aperçu » visitapp-web, écran Abonnement manager-app, mot de passe oublié + invitation user, plafond IA d'essai. Les 4 repos compilent, migrations appliquées en base locale, **aucun parcours exécuté** → checklist de test dans `todo-features.md` (section "🧪 Checklist de test end-to-end"). Reste à faire côté config : alias mail `onboarding@myinfomate.be`, et `whsec_` de la Stripe CLI pour tester le webhook en local. -- **À faire** : écran audit log manager-app (backend ✅, front ❌ — ajouté le 2026-07-15), rate limiting API Keys, gestion users par instance admin, unification QuestionType (TextLibre/Digicode/ExpectedAnswer), nettoyage SectionEvent.ParcoursIds, Stripe Customer Portal, dashboard super-admin V2, facturation V2. +- **À faire** : dashboard super-admin V2, facturation V2. ✅ **Le Stripe Customer Portal est complet le 2026-08-13** (backend + bouton manager-app). + - ⚠️ **Cette ligne était périmée sur six points, revérifiés dans le code le 2026-08-13.** Sont **faits** : écran audit log manager-app (front livré le 12/08), **rate limiting API Keys** (livré, pas seulement décidé — politique `ai` dans `Startup.cs:279`, `UseRateLimiter()` en `:344`, les deux endpoints décorés), gestion des users par instance (plafond serveur + compteur UI, 12/08), nettoyage `SectionEvent.ParcoursIds` (11/08), et le **backend** du Customer Portal (13/08). L'**unification `QuestionType`** n'est pas « à faire » mais **écartée** : le trio TextLibre/Digicode/ExpectedAnswer n'a jamais existé dans le code, l'enum a trois valeurs et le digicode est un comportement dérivé, pas un type — reste une propreté front, elle aussi livrée (alias nommés, 11/08). - **📦 Reporté en V2 (décidé le 2026-08-07)** : **SectionForm**, **ressource 360°**, **AR image tracking (Mind AR)**. Les trois sont spécifiés en détail dans [todo-features.md](todo-features.md) mais sortent du périmètre V1 — aucun n'est un prérequis de la migration Postgres ni de la mise en prod. À reprendre tels quels quand la V1 sera stabilisée. **Prochaine chose à faire si tu as 1h** : jouer le **§19.13 cas E** du plan de test (chasse au trésor / carnaval) de bout en bout, depuis la création dans manager-app. C'est le scénario le plus complet — il valide SectionMap + SectionParcours + géodéclenchement + questions + mode jeu d'un coup. @@ -273,7 +274,11 @@ Ordre à respecter, sous peine de travailler deux fois : ### Restes non chiffrés, à caser en cours de route -Ratio jetons → questions (`/1000`) à caler sur du réel · endpoint de mise à jour d'`ApplicationInstance` pour rendre les canaux activables depuis l'écran · quotas seed 5M/20M tokens à ajuster · ~~`features.reqPerMonth` de la landing~~ ✅ corrigé, la clé n'existe plus dans `myinfomate-landing/src` (vérifié le 2026-08-11). +~~Ratio jetons → questions (`/1000`) à caler sur du réel~~ ✅ **des deux côtés le 2026-08-13.** Serveur : `InstanceQuotaDTO.aiTokensPerQuestion`, mesuré sur les `VisitorQuestion.TokensUsed` de l'instance, avec un seuil de 20 questions avant de faire foi ; en dessous, repli sur l'hypothèse de la grille tarifaire. Front : `guide_ia_screen` lit ce diviseur au lieu du `/1000` codé en dur, et **masque la ligne « ~N questions »** si l'appel échoue. ⚠️ **Le `/1000` était faux d'un facteur 10** — la grille vend Premium à 20 M de jetons pour ~2 000 questions, soit **10 000 par question** — et c'est un chiffre montré au client : le crédit restant affiché était dix fois trop généreux. + +⛔ ~~Endpoint de mise à jour d'`ApplicationInstance`~~ **n'a jamais manqué** (vérifié le 2026-08-13) : `ApplicationInstanceController` porte un CRUD complet (`POST :75`, `PUT :115`) et `InstanceController.Updateinstance` écrit déjà `IsMobile`/`IsTablet`/`IsWeb` (`:209-211`). Activer un canal est faisable par l'API depuis le début ; ce qui manque est l'**écran** SuperAdmin, parqué en V2 (voir §« Add-on IA »). ⛔ ~~Quotas seed 5M/20M~~ **déjà ajustés** par `AlignSubscriptionPlansWithPricing` : Essentiel 0, Pro 0, Premium 20 M, Enterprise `long.MaxValue`. Le « 5M » datait d'avant l'alignement. + +~~`features.reqPerMonth` de la landing~~ ✅ corrigé, la clé n'existe plus dans `myinfomate-landing/src` (vérifié le 2026-08-11). ### Hors périmètre V1, assumé @@ -430,6 +435,10 @@ Remplacé par `GetReferencedResourceIds()`, déjà implémentée sur les 13 sous **Lot F — le volet `manager-service` est livré le 2026-08-12 : les quatre chantiers de dette backend.** `dotnet test` **163 → 200**, aucun test sauté. Quatre commits, `manager-service` seul. +✅ **Volet `manager-service` refermé le 2026-08-13** — Stripe Customer Portal (backend) et ratio jetons → questions calé sur du réel. **6 tests**, `dotnet test` **211 : 197 passés, 14 sautés** (Testcontainers sans démon Docker), 0 échec. **Trois des cinq points restants étaient déjà faits** : rate limiting, endpoint `ApplicationInstance`, quotas seed — détail au §« Restes non chiffrés » ci-dessus et dans [v1-plan.md lot F](v1-plan.md). + +> ⚠️ **Ce qui reste du lot F n'est plus dans `manager-service`** : le bouton du portail et le diviseur `aiTokensPerQuestion` dans `manager-app` (petit), et surtout les trois chantiers `mymuseum-visitapp` — déclenchement proactif (+ M3), miroir de la conversation vocale, émission des événements vocaux. Plus l'aperçu de conversation (**L9**), indépendant. + **1. Le journal d'audit voyait tout sauf le contenu.** `AuditedTypes.Contains(entry.Entity.GetType())` exigeait l'égalité **exacte** de type, or `Section` est **abstraite** : le type runtime est toujours `SectionMap`, `SectionQuiz`… Aucune des 13 sortes de section n'était journalisée — soit précisément ce que l'écran du 12/08 devait tracer. `Resource`, `Configuration`, `Device`, `User` et `Instance` passaient, eux : ils sont concrets, et **c'est ce qui rendait le trou invisible**. Remplacé par une remontée à la classe de base auditée (`IsInstanceOfType`). > **Tranché : normalisation côté serveur.** `EntityType` porte `Section`, pas `SectionMap`. Trois raisons : le filtre « Section » **déjà présent** dans l'écran se met à rendre des lignes sans toucher `manager-app`, donc sans coordonner deux repos ; élargir le filtre front aurait coûté 13 entrées de liste et 39 clés i18n **et** laissé tout futur sous-type sortir du filtre en silence — la classe de panne exacte qu'on ferme ici ; et le sous-type concret **n'est pas perdu**, le discriminateur TPH est une propriété du modèle, donc sérialisée dans `NewValues` (`"Discriminator": "Article"`). Un test le vérifie — ce n'était pas une supposition. diff --git a/kanban.html b/kanban.html index 569aee0..061dbc8 100644 --- a/kanban.html +++ b/kanban.html @@ -458,9 +458,9 @@
1Migration v3
2Bugs ouverts
6À tester
-
23Planifié
+
25Planifié
8Bascule prod
-
48Fait récemment
+
51Fait récemment
@@ -596,11 +596,36 @@
-

Planifié

23
+

Planifié

25
+
+
visitappcode mortV2
+

Nettoyer downloadConfiguration.dart

+

Trouvé en livrant D4 le 12/08, laissé volontairement hors périmètre du lot D. Deux blocs morts dans le même fichier :

+

1. ~130 lignes de classe commentée/*class DownloadConfiguration { ligne 328 jusqu'au */ ligne 461. C'est ce bloc qui a fait croire au plan V1 que « les deux chemins de téléchargement divergent » : la ligne citée comme preuve vivait dedans. Un doublon commenté qui ressemble à du code actif coûte plus cher que pas de code du tout — il a produit un diagnostic faux.

+

2. cleanLocalResources, appelée nulle part — et non réactivable telle quelle : la table locale resources n'a pas de colonne configurationId, elle est globale à toutes les visites, et le paramètre configuration de la fonction n'est jamais utilisé. Deux issues : la supprimer, ou lui donner un périmètre en ajoutant la colonne. ⚠️ Conséquence à assumer si on supprime : les lignes DB des ressources retirées d'une visite ne sont purgées par personne. Impact réel faible — rien ne les lit pour le rendu, CachedCustomResource retrouve le fichier en listant le répertoire — mais la table croît sans borne.

+ v1-plan.md lot D — D4 · STATUS.md §K5 +
+ +
+
3 reposContredit le code
+

Les swagger.yaml embarqués sont périmés

+

Relevé en livrant D2 le 12/08. Les trois lib/api/swagger.yaml (manager-app, mymuseum-visitapp, tablet-app) décrivent un ResourceDTO qui n'a ni sizeBytes (ajouté à l'époque de C1/C3) ni dateUpdate (D2).

+

Ce sont des artefacts de génération, et le client s'édite à la main : ils ne sont plus la source de vérité. Le risque n'est pas fonctionnel — rien ne les lit à l'exécution — mais ils contredisent le code en silence, et c'est exactement ce qui a coûté du temps sur le « fallback Voice → Mobile » du lot F : une doc affirmative et fausse. Deux issues : les régénérer une fois et les tenir, ou les supprimer et assumer que le Swagger en ligne est la seule référence.

+ STATUS.md §K5 — relevé D2 +
+ +
+
visitapppiège latentV2
+

La colonne path de la table resources est écrasée à vide

+

Trouvé en livrant D2 le 12/08. DatabaseHelper.insert fait un UPDATE de la ligne entière quand l'id existe déjà : la boucle qui enregistre la charge de ressources (héritée de D1) repasse derrière la boucle de téléchargement et réécrit path avec la valeur par défaut de ResourceModel, soit "".

+

Sans effet aujourd'hui : personne ne lit cette colonne pour le rendu. Mais elle est NOT NULL et prétend porter le chemin local — le premier code qui s'y fiera trouvera une chaîne vide, sans erreur. Même famille que le dateUpdate effacé par la même boucle, corrigé en D2 parce que lui, il est lu. Deux issues : renseigner path dans les deux boucles, ou retirer la colonne et assumer que le répertoire fait foi.

+ v1-plan.md lot D — D2 +
+
après prodrelationnel

Reprendre contact avec Louise Smets — Musée d'Ixelles

@@ -665,15 +690,6 @@ v1-plan.md lot F — miroir vocal
-
-
manager-servicemanager-appConfirmé V1 le 12/08
-

Stripe Customer Portal — changer sa carte

-

État réel, mesuré le 12/08 — moins grave que le plan ne le disait, mais bien cassé. L'e-mail d'échec de paiement pointe vers {managerAppUrl}/billing (StripeWebhookController:124), donc vers un écran à toi, pas vers une URL Stripe fantôme. Et cet écran existe (Screens/Billing/subscription_screen.dart).

-

⚠️ Mais il ne sait faire qu'une chose : lancer un Checkout de souscription. Scénario réel : la carte d'un client expire, il reçoit l'e-mail, clique, et arrive sur un écran qui lui propose de souscrire l'abonnement qu'il a déjà. Il t'appelle. À 4 clients c'est gérable, en self-service non.

-

StripeService sait créer un client et une Checkout Session, mais pas de session de portail. Endpoint Stripe.BillingPortal + bouton dans l'écran existant — Stripe héberge la page, il n'y a aucune UI de paiement à écrire. ~1 jour.

- v1-plan.md lot F · STATUS.md §1 « À faire » -
-
manager-appJamais ouvert

Écrans du lot design — vérification à l'œil

@@ -872,9 +888,27 @@

Fait récemment

-

Quarante-huit chantiers clos entre le 5 et le 12 août 2026.

+

Cinquante-et-un chantiers clos entre le 5 et le 13 août 2026.

+
+ FAQ de la landing — le plan à 179 € s'appelait encore « Bundle » + 41 occurrences en 4 langues (FR, EN, NL, DE) dans src/data/segments.ts, alors que les cartes de tarifs disent « Premium » : un prospect qui lisait la grille puis la FAQ voyait deux noms pour la même offre. Les formes composées des langues germaniques suivent (Bundle-abonnementenPremium-abonnementen, Bundle-PlänePremium-Pläne). Vérifié : plus aucun « Bundle » dans myinfomate-landing/src, et les 41 occurrences étaient toutes de la prose — aucun identifiant touché. npm run build vert. +
+ +
+ Lot F — le bouton du portail Stripe et le diviseur de questions (manager-app) + Portail de facturation : le bouton manquait, et l'écran n'affichait rien du tout hors essaiif (isTrialActive) enveloppait le seul bouton, donc un client payant arrivait sur un écran sans action. C'est désormais Checkout pendant l'essai, portail après ; les deux ne coexistent jamais. ⚠️ Le 409 a son propre message : les 4 instances de la bascule viennent de Mongo et n'ont pas de StripeCustomerId — « réessayez plus tard » les ferait attendre quelque chose qui n'arrivera jamais. 4 clés i18n FR/EN/NL. + Diviseur de questions : guide_ia_screen lit aiTokensPerQuestion du serveur au lieu du /1000 codé en dur, par appel http direct — le motif déjà établi dans cet écran pour knowledge et insights. ⚠️ Si l'appel échoue, la ligne « ~N questions » disparaît plutôt que d'afficher un repli : sur une jauge qui sert à décider d'un achat, pas de chiffre vaut mieux qu'un chiffre faux — ce que le /1000 faisait depuis le début, à un facteur 10 près. flutter analyze lib sans erreur, flutter build web ✅. +
+ +
+ Lot F backend — portail de facturation Stripe et coût réel d'une question + Customer Portal : StripeService.CreateBillingPortalSessionAsync + POST /api/onboarding/billing-portal, calqué sur checkout-session. ⚠️ Une garde absente de l'énoncé : StripeCustomerId peut être nul. CreateCustomerAsync n'est appelée que par l'inscription self-service, donc les 4 instances de la bascule, venues de Mongo, n'ont jamais vu Stripe — sans la garde, elles partaient chercher le portail d'un client inexistant et récupéraient une erreur d'API Stripe illisible côté front. C'est un 409, distinct du 404 d'instance inconnue. + Ratio jetons → questions : InstanceQuotaDTO.aiTokensPerQuestion est désormais mesuré sur les VisitorQuestion.TokensUsed de l'instance, avec un seuil de 20 questions avant de faire foi — une seule réponse citant un long article doublerait la moyenne, et le crédit restant afficherait moitié moins d'un rafraîchissement à l'autre. ⚠️ Le /1000 de manager-app était faux d'un facteur 10, et c'est un chiffre montré au client : la grille vend Premium à 20 M de jetons pour ~2 000 questions, soit 10 000 par question. Le gestionnaire lisait un crédit dix fois trop généreux. Le repli serveur est calé sur 10 000. Reste à brancher le diviseur dans manager-app. + Trois points du lot F étaient déjà faits et n'attendaient qu'une vérification : le rate limiting (livré, pas seulement décidé), l'endpoint de mise à jour d'ApplicationInstance (CRUD complet existant — ce qui manque est l'écran SuperAdmin, déjà parqué en V2) et les quotas seed (alignés par AlignSubscriptionPlansWithPricing). 6 tests ajoutés, dotnet test 197 passés / 14 sautés / 0 échec. +
+
C4 — compression des images à l'upload, 2560 px / JPEG q82 flutter build web vert. Un seul helper appelé par les deux chemins d'upload de resources_screen — le motif exact qui a fait diverger Create et Upload côté serveur, sur StoragePath en C1 puis sur le pré-vol de quota en C3. ⚠️ La compression se fait avant resourceCreate, pas avant l'upload : le quota de C3 porte sur sizeBytes à la création, donc compresser après aurait fait décider le serveur sur la taille d'origine — et un quota qui compte 12× trop se remplit 12× trop vite. @@ -1187,7 +1221,7 @@ // « Fait récemment » n'a pas d'horizon : c'est du passé, pas du reste-à-faire. if (done) { done.classList.toggle('hidden', horizon !== 'all'); } - if (stats[5]) { stats[5].classList.toggle('hidden', horizon !== 'all'); } + if (stats[6]) { stats[6].classList.toggle('hidden', horizon !== 'all'); } if (emptyMsg) { emptyMsg.classList.toggle('hidden', total !== 0); } } diff --git a/v1-plan.md b/v1-plan.md index 7e8d43d..e8905af 100644 --- a/v1-plan.md +++ b/v1-plan.md @@ -174,6 +174,21 @@ Ne change pas : la popup de traduction (un niveau justifié, langues verticales, 🔨 **Volet `manager-service` livré le 2026-08-12** — les quatre chantiers de dette backend sont fermés en une passe : journalisation des sections, plafond de 5 utilisateurs, `GetSummary` en SQL, tests du vector store. `dotnet test` **163 → 200**. Restent l'aperçu de conversation (L9), l'onglet « Vocal », les restes non chiffrés, le rate limiting et le Customer Portal. +✅ **Volet `manager-service` terminé le 2026-08-13.** Customer Portal livré, ratio jetons → questions calé sur du réel ; le rate limiting, l'endpoint `ApplicationInstance` et les quotas seed **étaient déjà faits** et n'attendaient qu'une vérification (détail dans les lignes concernées). `dotnet build` vert, `dotnet test` **211 au total : 197 passés, 14 sautés** (Testcontainers, pas de démon Docker), 0 échec. + +**Ce qui reste au lot F, et dans quel repo** — le backend ne bloque plus rien : +- ~~`manager-app`~~ ✅ **livré le 2026-08-13** : bouton du Customer Portal et diviseur `aiTokensPerQuestion`. `flutter analyze lib` **sans erreur**, `flutter build web` ✅. Détail ci-dessous. +- `mymuseum-visitapp` : déclenchement proactif (+ M3), miroir de la conversation vocale, émission des événements vocaux. C'est désormais **tout le reste du lot F**, et le plus gros. +- Non commencé, indépendant : l'aperçu de conversation (**L9**). + +**Volet `manager-app` du 2026-08-13 — deux décisions prises en écrivant, à ne pas re-trancher :** + +⚠️ **Le bouton du portail ne remplace pas celui du Checkout, il prend sa place quand l'essai est fini.** L'écran n'affichait rien du tout hors essai (`if (isTrialActive)` enveloppait le seul bouton) : un client payant arrivait sur un écran sans action. C'est maintenant Checkout **pendant** l'essai, portail **après** — les deux ne coexistent jamais, souscrire deux fois n'a pas de sens. + +⚠️ **Le 409 a son propre message, et c'est le point qui se serait vu en prod.** Les 4 instances de la bascule viennent de Mongo et n'ont pas de `StripeCustomerId` : leur montrer « réessayez plus tard » les ferait attendre quelque chose qui n'arrivera jamais. Le message dit que leur facturation passe directement par nous. **4 clés i18n FR/EN/NL.** + +⚠️ **Le diviseur vient du serveur, et son absence masque la ligne au lieu de mentir.** `guide_ia_screen` appelle `GET /api/Instance/{id}/quota` par `http` direct — le motif déjà établi dans cet écran pour `knowledge` et `insights`, un agrégat en lecture seule ne justifiant pas d'étendre `manager_api_new`. Si l'appel échoue, `~N questions` **disparaît** : sur une jauge qui sert à décider d'un achat, pas de chiffre vaut mieux qu'un chiffre faux — c'est exactement ce que le `/1000` faisait depuis le début. + | Quoi | Lien | |---|---| | ~~Volet RGPD~~ | **Déplacé au lot J**, fin de backlog (décidé le 2026-08-11) | @@ -191,9 +206,9 @@ Ne change pas : la popup de traduction (un niveau justifié, langues verticales, | ~~**`WeatherSyncService` inondait le journal**~~ | ✅ **Corrigé le 2026-08-12 — régression du correctif ci-dessus, trouvée en creusant « pourquoi un index ? ».** `WeatherSyncService` écrit `section.WeatherResult` sur cron, à 6 h et à 13 h. Le job était bénin tant que les sections n'étaient pas auditées ; depuis, chaque rafraîchissement produisait une ligne portant la **prévision OpenWeather complète en avant ET en après** — quelques dizaines de Ko, deux fois par jour, par section météo. Le stockage est le moindre problème : ce bruit **noie les modifications humaines** que l'écran existe pour montrer. Correctif : une liste de colonnes machine (`WeatherResult`, `WeatherUpdatedDate`, `DateUpdate`) exclues du journal, et **aucune ligne produite** quand une modification ne touche qu'elles. `DateUpdate` y est pour une raison distincte — estampillé à chaque `SaveChanges`, il figurait dans tous les diffs sans rien y apprendre ; le signal était là, un test devait l'écarter à la main pour rester lisible. Vérifié : `AgendaSyncService` écrit des `EventAgenda`, non audités, et ne touche pas la ligne `Section` | | ~~`AuditLog` n'a pas d'index sur `Timestamp`~~ | ⛔ **Fausse alerte, retirée le 2026-08-12.** Signalée par réflexe sur « la table va grossir » — elle allait grossir à cause de la météo, pas des humains. Le flot machine coupé, il reste les éditions réelles : de l'ordre de quelques dizaines de milliers de lignes par an sur 4 clients, qu'un `ORDER BY Timestamp DESC LIMIT 50` trie en millisecondes sans index. **Donc pas d'index, pas de migration, et le gel du lot B n'est pas remis en cause** | | **La bascule écrira ~2 700 lignes d'audit dans la transaction globale** | ⚠️ **Vu le 2026-08-12.** 2 374 ressources + 307 sections + le reste, chacune avec un instantané JSON complet, dans la transaction unique posée par l'écart (h) du lot G. Pas fatal, mais c'est **neuf pour les sections** et c'est la transaction qui ne doit pas échouer. À surveiller au prochain dry run | -| Restes non chiffrés : ratio jetons → questions (`/1000`) calé sur du réel, endpoint de mise à jour d'`ApplicationInstance` pour rendre les canaux activables, quotas seed 5M/20M ajustés | §1quater | -| **Rate limiting sur les API Keys** | ✅ **Confirmé V1 le 2026-08-12.** `AddRateLimiter` (natif ASP.NET Core 8), politique partitionnée par `InstanceId` sur les routes IA. **La cible n'est pas l'abus générique mais le quota de jetons** : `/api/AI/chat` est le seul endpoint qui coûte de l'argent réel, et la clé qui y donne accès est publique par construction | -| **Stripe Customer Portal** | ✅ **Confirmé V1 le 2026-08-12.** Endpoint créant une session de portail (`Stripe.BillingPortal`) + bouton dans l'écran `Screens/Billing/subscription_screen.dart`, **qui existe déjà** mais ne sait que lancer un Checkout de souscription. Stripe héberge la page, il n'y a pas d'UI de paiement à écrire | +| ~~Restes non chiffrés~~ | ✅ **Traités le 2026-08-13 — et deux des trois étaient déjà faits.**
**Ratio jetons → questions** : livré côté serveur. `InstanceQuotaDTO.aiTokensPerQuestion` est désormais **mesuré** sur les `VisitorQuestion.TokensUsed` de l'instance, avec un seuil de 20 questions avant de faire foi — sans ce seuil, une seule réponse citant un long article doublerait la moyenne et le crédit restant affiché changerait de moitié d'un rafraîchissement à l'autre. En dessous du seuil, repli sur l'hypothèse de la grille tarifaire.
⚠️ **Le `/1000` de manager-app était faux d'un facteur 10, et c'est un chiffre montré au client.** `guide_ia_screen.dart:428` fait `(used / 1000)`, alors que la grille vend Premium à 20 M de jetons pour ~2 000 questions, soit **10 000 jetons par question** (`MyInfoMateDbContext:619`). Le gestionnaire lisait donc un crédit restant **dix fois trop généreux**. Le repli du serveur est calé sur 10 000, pas sur 1 000. **Reste à faire dans `manager-app`** (session séparée) : diviser par `aiTokensPerQuestion` au lieu de la constante.
⛔ **Endpoint de mise à jour d'`ApplicationInstance` : il existe déjà.** `ApplicationInstanceController` porte un CRUD complet — `PUT /api/ApplicationInstance` (`:115`) et `POST` (`:75`) — et `InstanceController.Updateinstance` écrit déjà `IsMobile`/`IsTablet`/`IsWeb` (`:209-211`). Activer un canal est donc **déjà faisable par l'API** ; ce qui manque est l'écran SuperAdmin, explicitement parqué en V2 au §5. Rien à écrire ici.
⛔ **Quotas seed 5M/20M : déjà ajustés** par `AlignSubscriptionPlansWithPricing` — le seed dit Essentiel 0, Pro 0, Premium 20 M, Enterprise `long.MaxValue`. Le « 5M » de cette ligne datait d'avant l'alignement | §1quater | +| ~~**Rate limiting sur les API Keys**~~ | ✅ **Livré — constaté le 2026-08-13**, la ligne du 12/08 ne notait qu'une décision. Tout est en place : politique `ai` partitionnée par `InstanceId` (`Startup.cs:279-299`), `app.UseRateLimiter()` (`:344`) et les **deux** endpoints décorés (`AiController:312/349`). Plafond 120/min, 429 + `Retry-After: 60`.
⚠️ **L'ordre du pipeline est un choix, pas un hasard** — un commentaire le dit dans `Startup.cs` : le limiteur est **après `UseCors`** (sinon un 429 sans en-têtes CORS s'affiche comme une erreur CORS dans le navigateur et `visitapp-web` ne voit jamais le vrai code) et **après `UseAuthentication`** (sinon la partition n'a pas encore le claim d'instance et tout le monde tombe dans le même seau). Ne pas réordonner | +| ~~**Stripe Customer Portal**~~ | 🔨 **Backend livré le 2026-08-13.** `StripeService.CreateBillingPortalSessionAsync` + `POST /api/onboarding/billing-portal`, calqué sur `checkout-session` : Stripe héberge la page, on ne renvoie qu'une URL de redirection. **2 tests** (les deux gardes ; l'appel Stripe lui-même part sur le réseau et n'est pas couvert).
⚠️ **Une garde qui n'était pas dans l'énoncé : `StripeCustomerId` peut être nul.** Les 4 instances de la bascule viennent de Mongo et n'ont **jamais vu Stripe** — `CreateCustomerAsync` n'est appelée que par l'inscription self-service (`OnboardingController:224`). Sans la garde, elles seraient parties chercher un portail pour un client inexistant et auraient reçu une erreur d'API Stripe illisible côté front. C'est un **409**, distinct du 404 d'instance inconnue.
**Reste dans `manager-app`** (session séparée) : le bouton dans `Screens/Billing/subscription_screen.dart`, qui ne sait toujours que lancer un Checkout de souscription | | ~~Tests du vector store via `Testcontainers.PostgreSql`~~ | ✅ **Livré le 2026-08-12 — et deux résultats contredisent ce que cette ligne supposait.** 14 tests sur l'image de `Deployment/Dockerfile.postgres`, donc le même PostGIS + pgvector que la prod ; ils se sautent proprement (`SkippableFact`) sans démon Docker — vérifié : 186 passés / 14 sautés / 0 échec.
**Établi** : pgvector **0.8.6**, `SET hnsw.iterative_scan` est accepté — ce SET est posé à chaque recherche par `SearchAsync` et n'existe qu'à partir de 0.8, donc sur une version antérieure **toute recherche échouait en production** ; les extensions `vector` et `postgis` sont bien créées par les migrations ; et à deux instances, l'une saturant l'axe de la question à **30 contre 1**, `SearchAsync` rend le bon nombre de résultats, tous de la bonne instance, **aucune fuite d'un client vers un autre**.
⚠️ **1. Ce qui protège du post-filtrage n'est PAS le parcours itératif, c'est l'index sur `(InstanceId, ContentType)`.** Le planificateur filtre par instance d'abord et trie exactement : l'index HNSW n'est jamais touché, donc il n'y a rien à post-filtrer. Vérifié à 620 lignes (test) et à **22 000** hors suite, avec 2 000 lignes pour l'instance cible. Un test fige ce plan — si cet index disparaissait, la recherche se dégraderait **sans qu'aucune erreur ne le dise**.
⚠️ **2. Et si on retire cet index, `hnsw.iterative_scan = relaxed_order` ne rattrape rien** : le parcours s'épuise après ~335 lignes (`Rows Removed by Filter` à l'`EXPLAIN ANALYZE`) sans atteindre l'instance minoritaire, et rend **zéro**. Le même jeu de données avec l'index HNSW construit **après** l'insertion rend bien ses 20 lignes : c'est la **connectivité du graphe** qui décide, et nos migrations créent l'index sur une table vide. Le SET de `SearchAsync` est correct et sans coût — on le garde — mais **il ne couvre pas ce qu'on croyait**.
**Outillage** : `Testcontainers.PostgreSql` épinglé en **3.10.0** (la 4.x parle l'API Docker 1.44, l'engine local plafonne à 1.43) et l'image construite par le **CLI docker**, pas par le constructeur d'images de Testcontainers — il relit le `FROM` pour pré-tirer l'image de base et ne sait pas parser `tag@sha256:`. Ce digest protège la base d'un changement de glibc sous ses index : il ne se retire pas pour arranger un test | **L14** | ### Lot G — `MigrationController` à niveau + dry run (3 à 4 jours) — le plus gros @@ -361,7 +376,7 @@ Les CGU §8 ont été réécrites le 2026-08-11 (`cgu-myinfomate.md`) et le text > ⚠️ **À arbitrer avant le lot H** : Pro n'inclut pas l'IA, donc rien ne s'indexera pour MDLF et le Fort et leur guide ne répondra jamais. Sur 4 instances, la chaîne RAG ne serait éprouvée que sur 2 — or le lien **L14** demande justement de tester le post-filtrage HNSW **à deux instances au moins**. Deux sorties : les passer en Premium le temps des tests, ou leur poser un quota IA à la main (l'add-on ci-dessus), en pensant au rattrapage d'indexation. -**4. Écart restant, hors bascule** : la FAQ de `myinfomate-landing` (`src/data/segments.ts`) appelle encore le plan à 179€ « **Bundle** » — **41 occurrences en 4 langues** — alors que les cartes de tarifs disent « Premium ». Un prospect qui lit la grille puis la FAQ voit deux noms pour la même offre. Chantier de texte commercial, sans effet sur la migration. +**4. ~~Écart restant, hors bascule~~** ✅ **Corrigé le 2026-08-13.** La FAQ de `myinfomate-landing` (`src/data/segments.ts`) appelait le plan à 179 € « **Bundle** » alors que les cartes de tarifs disent « Premium » : **41 occurrences en 4 langues** (FR, EN, NL, DE) passées en « Premium ». Vérifié : plus aucun « Bundle » dans `myinfomate-landing/src`, et les formes composées des langues germaniques suivent (`Bundle-abonnementen` → `Premium-abonnementen`, `Bundle-Pläne` → `Premium-Pläne`). `npm run build` vert. Aucun identifiant touché — les 41 occurrences étaient toutes de la prose. | I7 | **Recette de bascule — [test-plan.md §22](test-plan.md)**, ajoutée le 2026-08-11. Comptages globaux, puis **par type de section**, collections filles, colonnes générées, et comparaison à l'écran ancienne app vs nouvelle. ⚠️ **À jouer pendant que Mongo est encore lisible** : c'est la seule fenêtre où les deux bases coexistent, après la comparaison est impossible | Le dry run compare des **comptages**, le §22 compare le **contenu** — un compte juste ne dit pas qu'un quiz a gardé ses questions ni qu'un article a gardé ses traductions. Les écarts b et c ne se voient qu'ici | | I8 | Bascule DNS / API, Mongo gardé en lecture seule quelques jours, `pg_dump` après | ⚠️ **Remplacé par la stratégie de coexistence ci-dessous, décidée le 2026-08-12** |