DOCS/v1-plan.md
Thomas Fransolet 4a4523e3a2 Correction : tablet-app a bien un i18n, et V2 gagne l'orientation
Correction d'une affirmation fausse écrite plus tôt dans la journée et
reprise dans la maquette, le plan et une décision validée : « tablet-app
n'a aucun i18n ». Il en a un — lib/Helpers/translations.dart, table de 10
langues (FR EN NL DE IT ES PL CN AR UK), ~150 entrées, lue par
TranslationHelper.getFromLocale à 19 endroits, avec déjà une clé back.
Ce n'est pas de l'ARB : chercher l10n/ ou AppLocalizations ne le trouve
pas, alors que le CLAUDE.md du repo le décrit.

Ce que ça change : le motif donné pour « hors dates, aucune prose » — un
bandeau serait français pour un néerlandophone — était faux. Un libellé
traduit coûte une clé × 10 langues. La règle reste défendable (rien à
traduire vaut mieux que quelque chose à traduire) mais devient un choix,
pas une contrainte. À reconfirmer.

Corollaire d'implémentation : le bouton Retour des deux écrans passe par
la clé back existante, il ne s'écrit pas en dur.

Ajouté en V2 — portrait et paysage sur toute la borne. tablet-app n'a
aucune stratégie d'orientation : chaque écran est écrit pour la forme
qu'on lui a vue, et la maquette du 12/08 ne tranche que le paysage, pour
deux types. Une borne se monte pourtant aussi bien en totem qu'en
comptoir, et le choix appartient au lieu. Le chantier n'est pas
« ajouter le portrait » mais rendre les treize types indifférents à
l'orientation — repasse UI transversale, même famille que la repasse
globale de manager-app. La maquette est compatible (le rail qui tombe
sous le texte est le comportement portrait) mais ne le spécifie pas.

Kanban : carte V2 ajoutée, Planifié 21 -> 22 (colonne et bandeau). Le
motif « et tablet-app ne compile même plus » de la carte Kiosk web est
corrigé — il est tombé avec K1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:55:39 +02:00

461 lines
83 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# V1 — plan d'implémentation jusqu'à la mise en prod
> Établi le 2026-08-10 en croisant [STATUS.md](STATUS.md) (§1 à §6bis), [kanban.html](kanban.html) (43 cartes) et des vérifications directes dans le code.
>
> Ce fichier ne remplace ni STATUS.md ni le kanban : il donne l'**ordre d'exécution** et les **liens entre chantiers**, que ni l'un ni l'autre ne portent — le kanban trie par état, STATUS.md par domaine, et personne ne dit ce qui doit précéder quoi.
>
> Règle héritée du §1ter et maintenue ici : **ce qui touche au schéma part avant la bascule, ce qui est additif attend.**
---
## 0. Ce que la vérification du code corrige dans les docs
Trois écarts relevés avant d'établir l'ordre. Ils déplacent des chantiers entiers.
| Ce que disent les docs | Ce que dit le code (2026-08-10) | Conséquence sur le plan |
|---|---|---|
| §4 : « Rien n'est committé depuis le 2026-07-17 », 75 + 40 + 14 + 4 fichiers en working tree | **Périmé.** Les 4 repos ont un commit du **2026-08-09**. Reste non committé : `manager-service` 41 fichiers (= le lot 3 RAG du 10/08), `manager-app` 2, `tablet-app` 4, `myinfomate-landing` 7, `visitapp-web` 1 commit non poussé | Le point reste en tête mais devient une demi-heure, pas une urgence à trois semaines de travail |
| §1quater : `GetReferencedResourceIds()` « réclamé par le plan offline, à écrire dans la même passe » que les 13 `GetEmbeddableText()` | **Déjà écrit** dans les 13 sous-types (`Data/SubSection/*.cs`) — la passe commune a bien eu lieu. Mais **appelé nulle part** : le switch de collecte de `ConfigurationController.Export` est commenté en bloc (`:~400-577`), 180 lignes mortes | Le bug offline nº1, présenté comme « bloquant produit », coûte **le branchement d'une méthode existante** à la place d'un bloc mort. Il descend en coût, pas en priorité |
| §1quater et §6bis : « reste à corriger la landing, `features.reqPerMonth` dit encore req/mois » | **Corrigé.** La clé n'existe plus dans `myinfomate-landing/src` | Un item de moins ; le kanban avait raison, STATUS.md est en retard sur deux sections |
À répercuter dans STATUS.md quand ce plan sera exécuté.
---
## 1. Les liens qui déterminent l'ordre
C'est la partie qui manque partout ailleurs. Neuf liens réels, dont quatre imposent l'ordre et cinq évitent de travailler deux fois.
### Liens bloquants — l'ordre n'est pas négociable
| # | Lien | Pourquoi |
|---|---|---|
| L1 | **Rotation des secrets → création de l'environnement prod** (point 16 du §1quinquies) | Écrit noir sur blanc au §1quinquies : « ne pas créer la prod avec les clés committées ». Créer la prod d'abord, c'est rotationner deux fois. **⚠️ Modifié le 2026-08-11 : la rotation quitte le lot A pour le lot I (I0), mais le lien reste entier.** Ce n'est pas un assouplissement du lien, c'est un déplacement des deux bouts : les 6 repos sont privés sur un Gitea auto-hébergé, donc l'exposition ne justifie pas d'ouvrir le backlog avec elle. **La rotation doit toujours précéder I3.** Elle redevient urgente si un repo passe public, gagne un collaborateur externe, ou est cloné par un CI tiers |
| L2 | **Gel du schéma → réécriture de `MigrationController`** | `BuildSection` doit écrire dans le schéma **final**. Tout renommage ou unification de colonne fait après oblige à repasser dans les 13 branches du mapping. Voir §2 lot B |
| L3 | **`pg_dump` → bascule (point 18)** *et* **→ activation de `visit-events-purge`** | Un seul item débloque deux choses : le filet du jour J, et le job de purge des stats volontairement inactif tant que `Stats:RetentionDays` n'est pas défini |
| L4 | **RGPD → mise en service de `VisitorQuestion`****repoussé en fin de backlog le 2026-08-11 (lot J)** | Ce lien reste vrai, mais il ne contraint plus l'ordre : **rien ne part en prod avant que tout le backlog soit terminé** (§1quater de STATUS.md). Le lot J étant le dernier avant la bascule, le volet RGPD est en place avant la première question de visiteur réelle — la journalisation ne tourne d'ici là que sur des bases de développement. **La condition tient tant que cette porte tient** : si un déploiement anticipé était décidé, le lot J redeviendrait bloquant |
| L19 | **Portage des apps visiteur (lot K) → bascule****ajouté le 2026-08-12** | Le contrat d'API a changé avec Postgres v3 : `ConfigurationDTO.roundedValue`, `GeoPointDTO.latitude/longitude` ne sont plus servis. **Les apps déployées parlent l'ancien contrat.** Basculer sans avoir porté et publié les apps, c'est dégrader les clients existants le jour J — sans erreur visible, juste des cartes vides. ⚠️ **Le parc des téléphones visiteurs ne se force pas** : c'est ce qui impose la stratégie de coexistence du lot I plutôt qu'une bascule sèche |
### Liens d'économie — même code, une seule passe
| # | Lien | Ce qu'on paie deux fois sinon |
|---|---|---|
| L5 | **Backfill médias ⊂ `MigrationController`** (écart e du §1quinquies) | Le §1quater le range dans le lot médias, le §1quinquies dit « à faire *dans* la migration ». Les deux ont raison : il faut **un seul calculateur** de `StoragePath`/`SizeBytes`, appelé par le backfill local et par la migration. Deux implémentations divergeraient sur les 7 types URL et la ligne sans blob |
| L6 | **Rename `MapResourceId` → `IconResourceId` = écart (g)** du §1quinquies | Le §6bis le classe en « nettoyage backend », le §1quinquies en écart de migration. C'est le même changement. Le faire pendant le lot B et l'écart (g) tombe tout seul |
| L7 | **Port PG fermé + `app.myinfomate.be` + compose prod = un seul fichier** | Trois cartes distinctes du kanban (une « Urgent », une « Planifié », une dans le §1quinquies) qui touchent le même `docker-compose` et le même Traefik. Les faire séparément, c'est trois fenêtres de redéploiement |
| L8 | **Nettoyage `manager_api_new` → 5 chantiers manager-app** | Compression images, exposition du watermark, écran audit log, gestion des users, Guide IA lot 4 : tous dans manager-app, tous privés de `flutter analyze` comme garde-fou tant que les 14 fichiers modèle orphelins produisent 68 erreurs de bruit |
| L9 | **Aperçu de conversation (Guide IA lot 4) ≡ « Mode preview / cible Assistant-Persona »** de todo-features | Deux specs pour le même écran, dans deux documents. Converger avant de coder, comme le note déjà le §1quater |
### Liens de dépendance fonctionnelle
| # | Lien | Effet |
|---|---|---|
| L10 | **Backfill `SizeBytes` → quota de stockage autoritaire** | Sans backfill, `SizeBytes = 0` partout : un quota « autoritaire » laisserait tout passer sur l'existant. L'ordre est backfill puis contrôle, jamais l'inverse |
| L11 | **§21 (test offline sur device) → priorité des 4 autres bugs offline** | Le §21 est le seul moyen de savoir si « ça marche mal dans le fort » vient de là. Il conditionne l'urgence, pas l'existence, des bugs de fraîcheur / extensions / purge / échecs silencieux |
| L12 | **Déploiement `app.myinfomate.be` → test §18 (onboarding)** | L'onboarding vend un plan Essentiel web-only. Jouer le parcours d'inscription de bout en bout sans le web déployé valide tout sauf ce que le client achète |
| L13 | **Rétention 13 mois (livrée) → `GetSummary` en mémoire** | La fenêtre est passée de 30 jours à 13 mois : `eventsQuery.ToList()` charge maintenant 13 mois d'événements avant d'agréger. C'est la rétention livrée qui a rendu ce point pressant, pas un nouveau besoin |
| L14 | **Bascule multi-instances → post-filtrage HNSW non éprouvé** | Le RAG a tourné sur une base à **une seule instance** — exactement le cas où le problème de post-filtrage ne se voit pas. La bascule crée le cas de test en prod. À couvrir avant, par Testcontainers ou à défaut à la main sur deux instances locales |
| L15 | **Stats de l'assistant IA ⊂ écran Guide IA** | « Ce que demandent vos visiteurs » est le **deuxième onglet** de l'écran Guide IA (§7 du plan), et cette coquille à onglets n'existe pas encore. Deux chantiers séparés = la coquille construite deux fois |
| L16 | **Design de l'onglet → job de regroupement en thèmes** | Ici le design pilote le backend : la forme d'affichage détermine la forme des données agrégées. Job écrit d'abord = job réécrit |
| L17 | **Socle visuel `constants.dart` → les 3 écrans** | 11 tailles de police dans `lib/Components/`, pas d'échelle typo. Socle d'abord : les écrans deviennent de l'assemblage. Socle après : 3 repasses |
| L18 | **§5 Aperçu de conversation → arbitrage A/B de SectionParcours** | Le seul gain réel de l'option B est une colonne d'aperçu visiteur en direct — déjà couverte par le §5 du Guide IA (lui-même à converger avec le Mode preview, L9). Le gain de B tombe, **A reste** |
---
## 2. Les lots, dans l'ordre
Neuf lots. Le chemin critique passe par A → B → G → H ; les lots C à F sont parallélisables mais tous obligatoires avant H.
### Lot A — Sauver et assainir (½ jour) — **révisé le 2026-08-11**
Rien de créatif, tout est un verrou pour la suite.
| Quoi | Où | Lien |
|---|---|---|
| ~~Committer le lot 3 RAG du 10/08~~ | — | ✅ **Fait le 2026-08-11.** Les **9 repos** sont propres et synchronisés avec leur remote. `manager-service` `18e4240`, `manager-app` `eb8e849`. ⚠️ **`DOCS/` est lui-même un repo git** (branche `main`) — facile à oublier quand on énumère les repos de code, et c'est là que vivent ce plan, STATUS.md et le kanban |
| ~~**Rotation des secrets**~~ | `manager-service` | 📅 **Déplacée au lot I (I0)** le 2026-08-11 — repos privés sur Gitea auto-hébergé. **L1 tient : elle précède toujours I3.** Voir la note de L1 pour les conditions qui la font remonter |
| Nettoyer `manager_api_new` : supprimer les 14 fichiers modèle orphelins + `lib/api/openApiTest.dart` | `manager-app` | **L8** — rend `flutter analyze` exploitable pour les lots D, E, F. **Diagnostic fermé le 2026-08-11**, détail ci-dessous |
| ~~Réparer le build `tablet-app`~~ | `tablet-app` | ➡️ **Déplacé au lot K le 2026-08-12. Le diagnostic « un seul défaut, purement Gradle » était doublement faux** — il a tenu tant que personne n'avait lancé un vrai build. Ce n'est pas un correctif de lot A, c'est un chantier de rattrapage. Voir le lot K |
| Sécurité rapide : `EnableSensitiveDataLogging` coupé en prod (`Startup.cs:248`), backdoor `#if DEBUG` retirée, exceptions internes plus exposées | `manager-service` | À faire avant que la prod existe, pas après |
**Pourquoi le nettoyage de `manager_api_new` est sans risque de build** — vérifié le 2026-08-11, à ne pas re-douter :
`lib/api.dart` déclare **139 `part`** pour **153 fichiers** dans `lib/model/`. Les 14 restants portent `part of openapi.api;` mais **ne sont déclarés `part` par personne**. En Dart, un fichier `part` non listé par sa bibliothèque est **hors du graphe de compilation** : l'analyzer le lit (il parcourt tout `lib/`), le compilateur ne le voit jamais. C'est toute l'explication des 68 erreurs de bruit — et la garantie que **supprimer ces fichiers ne peut pas changer un résultat de build.**
`lib/api/openApiTest.dart` part avec eux pour une raison différente : ce n'est pas du modèle mort, c'est **le déclencheur de la génération** (annotation `@Openapi` + `build_runner`, dernier run daté du 2026-05-07), importé nulle part. Le supprimer ne retire aucun code exécuté — ça **verrouille la règle du projet** : tant qu'il est là, un `flutter pub run build_runner build` lancé par réflexe régénère le client et écrase les éditions manuelles (`onboarding_api.dart`, le câblage d'`AIApi`, le mapping `isGood``isCorrect`).
> À ne pas régénérer : `manager_api_new` s'édite à la main (règle du projet). Le nettoyage supprime des fichiers orphelins **et le moyen de régénérer** ; il ne relance rien.
### Lot B — Geler le schéma (2 à 3 jours) — le point d'articulation
**C'est le lot que ni STATUS.md ni le kanban n'isolent**, et c'est lui qui conditionne le coût du lot G. Tant qu'un changement de modèle reste ouvert, réécrire `MigrationController` revient à le réécrire deux fois (**L2**).
| Quoi | Décision préalable | Lien |
|---|---|---|
| ~~Renommer `SectionMap.MapResourceId` → `IconResourceId`~~**2026-08-11** | Aucune, déjà tranché au §6bis | **L6** = écart (g), **qui tombe**. ⚠️ Il fallait renommer **aussi la propriété de navigation `MapResource`**`IconResource` : la convention EF l'appariait au FK, la laisser aurait fabriqué un FK fantôme. Elle n'était utilisée nulle part ailleurs. Sites touchés : `SectionMap.cs:25/26/45/77`, `SectionFactory:227/559`, `ResourceController:469`, `MigrationController:469` |
| ~~**Supprimer `SectionEvent.ParcoursIds`**~~**2026-08-11** — champ, DTO, `SectionFactory:35/199/529`, et **un montage de test** (`SectionEventControllerTests:47`) que l'investigation initiale n'avait pas couvert : elle disait « zéro contrôleur, zéro service », ce qui était exact, mais les tests n'étaient pas dans le périmètre. La ligne retirée était une simple initialisation, sans rapport avec l'assertion du test | ✅ **Tranché le 2026-08-11 : supprimer.** Investigation : le champ n'est **lu ni écrit nulle part** (entité + DTO + migrations, zéro contrôleur, zéro service), son commentaire dit `// Liens vers GeoPoints spécifiques` ce qui contredit son nom, et le lien événement↔parcours existe dans l'autre sens via `GuidedPath.SectionEventId`, lui bien entretenu (`SectionMapController:396/449`, `SectionController:878`). Argument décisif : il est porté par **`SectionEvent`, pas par `ProgrammeBlock`** — il ne peut donc pas exprimer « les parcours de tel jour », le cas carnaval pour lequel on le gardait | Nettoyage §1 « À faire » |
| ⛔ ~~Supprimer `SectionEvent.IconResourceId`~~ **— écarté le 2026-08-11 : ce champ n'existe pas** | **Erreur de doc, à ne pas rouvrir.** `SectionEvent` n'a aucun `IconResourceId`. La ligne visée (`SectionEvent.cs:105`) appartient à la classe **imbriquée `MapAnnotation`**, déclarée dans le même fichier — et elle est **très utilisée** : `SectionEventController:59/230/330`, `SectionAgendaController:58`, `SectionMapController:349/560`, plus les deux `.SelectMany(a => ResourceId(a.IconResourceId))` de `GetReferencedResourceIds` (lignes 50 et 53). `MapAnnotation` est partagée par SectionEvent, SectionAgenda **et** SectionMap : la supprimer aurait cassé les icônes d'annotation des trois types **et** la collecte de ressources offline | §6bis |
| ~~Unifier `QuestionType`~~**sorti du lot B le 2026-08-11** | ✅ **Tranché : on ne touche pas au schéma.** Le trio TextLibre/Digicode/ExpectedAnswer **n'existe pas dans le code** : l'enum a trois valeurs (`Simple`, `MultipleChoice`, `Puzzle`) référencées à **un seul endroit** côté serveur, et le digicode n'est pas un type mais un comportement dérivé d'une réponse numérique. Reste une propreté front : les comparaisons par entier brut (`…?.value == 2 ? 'Puzzle' : …`) dans `showNewOrUpdateGuidedStep.dart:409` et `guided_step_challenge.dart:14`**déplacé au lot E** | **Ne bloque plus `MigrationController`** |
| Implémenter `Section.meterZoneGPS` côté visiteur (la colonne existe, une constante à 100 m la remplace) | Aucune, tranché au §6bis | Bug M3 du kanban — même passe |
| Flag watermark sur `Instance` + exposition dans la config, retrait du `if instanceId == "633ee379…"` de `ResourceController:265` | Aucune | Point 6 du §1ter |
Sortie du lot : `dotnet build` + `dotnet test` verts, une migration EF unique regroupant les renommages, et **plus aucun changement de modèle en attente**.
**Lot B livré le 2026-08-11.** Migration unique `20260811130738_LotB_FreezeSchema`. **Référence tenue : 130/130 tests avant comme après**, build **Debug et Release** verts (Release vérifié parce que le point sécurité ci-dessous change ce que Release compile).
Deux points vérifiés plutôt que supposés :
- **EF a généré un `RenameColumn`, pas un drop+add** pour `MapResourceId``IconResourceId` : les icônes déjà configurées survivent à la migration. C'était le vrai risque du lot.
- **L'avertissement « may result in the loss of data » d'EF ne porte que sur le `DropColumn` de `ParcoursIds`** — c'est l'intention du lot, pas un effet de bord.
**Reste hors de ce lot** : `Section.meterZoneGPS` côté visiteur (Flutter/TypeScript, pas le schéma) — la colonne serveur est correctement mappée, c'est le bug M3 qui attend son implémentation visiteur.
### Lot C — Terminer le lot médias (2 à 3 jours)
L'étape A du §1quater est faite (`StoragePath`/`SizeBytes` écrits à `Create` depuis le 10/08). Reste l'étape C.
| # | Quoi | Note |
|---|---|---|
| ~~C1~~**2026-08-11** | **Calculateur extrait** dans `Helpers/ResourceStorage.cs` (statique, comme `ImageHelper`/`SlugHelper` — logique pure, pas d'I/O, donc appelable sans DI depuis `MigrationController` et le futur backfill). Trois membres : `HasBlob(type)`, `PathFor(type, instanceId, resourceId)`, `Apply(resource, sizeBytes)`. **13 tests** fixent l'invariant des types URL. `dotnet test` **143/143** | **L5** — consommé par C2 *et* par l'écart (e) du lot G.<br>⚠️ **L'extraction a révélé la divergence qu'elle devait empêcher** : il y a **deux** chemins de création dans `ResourceController`, et le chemin **multipart** (téléversement) écrivait `SizeBytes` mais laissait **`StoragePath` nul** — seul le chemin JSON écrivait les deux. Les deux passent désormais par `Apply`. C'est une partie des lignes que C2 doit rattraper.<br>⚠️ **Angle mort laissé volontairement** : `Update` peut faire passer `Type` d'un type URL à un type fichier, laissant `StoragePath` nul sur une ligne qui a maintenant un blob. Non corrigé ici — recalculer le chemin sur une ressource existante pointerait vers un objet qui n'a pas bougé dans le bucket. À trancher avec C3 (quota), pas avant |
| ~~C2~~**2026-08-12** | **Backfill livré** : `POST /api/Resource/backfill-storage?dryRun=&instanceId=`, SuperAdmin, **`dryRun` à true par défaut** (la migration se joue sur une base vide, celui-ci sur des lignes de production). `StoragePath` par `ResourceStorage.PathFor`, `SizeBytes` par HEAD. **7 tests** | ⚠️ **La méthode annoncée ici était inapplicable** : « listing du bucket Firebase » supposait un client de stockage que le serveur n'avait pas. Le sondage passe par HEAD sur l'URL publique, comme la migration.<br>**Le sondeur est extrait, pas recopié** — `Helpers/ResourceSizeProbe.cs`, consommé par `MigrationController` *et* le backfill (même raisonnement que L5).<br>⚠️ **L'extraction a bouché un trou** : l'original ne notait l'échec que dans son `catch`, or un HEAD sur un blob absent **ne lève pas** (404 sans `Content-Length`). Ces ressources arrivaient à 0 octet **sans figurer dans le rapport**. Corrigé des deux côtés.<br>Le « 37 sur 45 » n'était pas vérifiable : le backfill rend son propre inventaire, `Orphans` (pas d'URL) et `Unsized` (URL muette) séparés — ce sont deux causes distinctes |
| ~~C3~~**2026-08-12** | **Quota autoritaire livré** : pré-vol sur les **deux** chemins de création, suppression du blob à `Delete`, et l'angle mort d'`Update` tranché. **8 tests**, `dotnet test` **163/163** | **L10** respecté — après C2.<br>⚠️ **Le pré-vol existait déjà à moitié, non documenté** : `Upload` (multipart) contrôlait et renvoyait 413, `Create` (JSON) ne contrôlait rien — or c'est le chemin qu'emprunte manager-app. Le dépassement passait par la porte de service.<br>⚠️ **Et les deux lectures du quota divergeaient** : `Upload` lisait le **plan**, `GetQuota` lisait l'**instance** avec le plan en repli. Une instance à quota surchargé — le mécanisme de l'add-on — affichait un chiffre et se faisait bloquer sur un autre. Fermé par `Helpers/StorageQuota`, seule source de vérité.<br>**L'angle mort de C1 était une fausse crainte** : `PathFor` ne produit qu'un `pictures/{instanceId}/{resourceId}`**le type n'entre pas dans le chemin**, il décide seulement s'il y en a un. Recalculer ne peut pas pointer ailleurs. `Update` rejoue donc `Apply` |
| C4 | Compression images côté client, 2560 px / JPEG q82 (~12×) | `manager-app`. Indépendant de C2 : ne touche que les nouveaux uploads |
| C6 | **Retirer la suppression de blob de `manager-app`**`show_resource_popup.dart:130-137` | `manager-app`. Depuis C3 le serveur supprime le blob **avant** la ligne : le client tombe désormais sur un objet déjà absent. Ce n'est plus dangereux, c'est devenu **du code mort**. ⚠️ Ne pas le retirer *avant* d'avoir renseigné `Firebase:StorageBucket` en prod (I9), sinon plus personne ne supprime |
| ~~C5~~**2026-08-12** | Alerte de budget GCP **posée dans la console** | Fait côté GCP, rien à vérifier dans le code |
### Lot D — Bugs offline (1 à 2 jours, après mesure)
**Commencer par le test, pas par le code** (**L11**).
| Ordre | Quoi |
|---|---|
| D0 | **Jouer le test-plan §21 sur un device** — 15 cas. Sortie : lesquels des 4 bugs restants se manifestent réellement |
| ~~D1~~**2026-08-11** | **Fait des deux côtés, et le bloc mort était plus gros qu'annoncé : 157 lignes dans `Export`, plus 126 dans `Import`.** Avant : une visite téléchargée n'embarquait que l'image de la configuration, celle du loader et l'image de chaque section — ni contenus d'articles, ni audios, ni icônes de carte, ni images de quiz. **Serveur** : `Export` matérialise les entités (pas seulement leurs DTO) et appelle `GetReferencedResourceIds(language)` ; `Import` cesse de redécouvrir les ressources section par section et crée toute la charge en une passe (`createResource` est idempotente). **Client** : `downloadConfiguration.dart` enregistre la charge au lieu de parser `section.data` type par type — ce qui répare aussi la purge, `usedImageOrAudioIds` pilotant la suppression des fichiers obsolètes.<br>**4 tests** ajoutés, dont un qui vérifie par réflexion que **les 13 sous-types implémentent la collecte** : le trou d'origine venait d'un `switch` où un type oublié passait dans le `default` sans bruit. `dotnet test` **148/148**, `flutter analyze` sans erreur.<br>⚠️ Le volet visiteur n'est vérifié que par l'analyse — l'APK de `mymuseum-visitapp` ne compile pas (Gradle/NDK). **Confirmation sur device au §21**, qui reste ouvert |
| D2 | Filtre incrémental sur la **version** de la ressource, pas sa présence |
| D3 | `audio/mpeg` ajouté à la table d'extensions (`audio/mp3` n'est pas un MIME standard) |
| D4 | Réactiver la purge des fichiers obsolètes (`deleteSync()` commenté) |
| D5 | Échecs de téléchargement remontés au lieu d'un `print` — une visite incomplète ne doit pas s'annoncer téléchargée |
### Lot D-bis — Les trois chantiers de design (1,5 à 2 semaines)
> **État mesuré le 2026-08-10, plus ouvert que ce qu'annoncent STATUS.md et le kanban.**
>
> | Écran | Docs | Code |
> |---|---|---|
> | Guide IA | « onglet Configuration livré le 07/08 » | `guide_ia_screen.dart` : 545 l., **5 cards en colonne unique, aucun `TabBar`**. Manquent le §4 (Sources de connaissance + recherche vectorielle) et le §5 (Aperçu de conversation) |
> | Stats assistant IA | « onglet Vocal (V1) » spécifié | `statistics_screen.dart` : 1481 l., « vocal » n'apparaît **que dans un commentaire**. Stats assistant à zéro |
> | SectionParcours | 2 options maquettées | Les 3 popups intactes. Rien n'a commencé — **résolu par DB4 le 2026-08-11 : les 3 popups sont supprimées** |
**Le problème de méthode avant le contenu.** Les trois maquettes sont des artifacts hébergés sur un **autre compte Claude** : illisibles depuis la machine de dev (constaté le 2026-08-10, cf. le §« Comment mettre à jour ce fichier » de STATUS.md). Toute session qui reprend un de ces écrans redessine — c'est la cause directe du Guide IA livré à la moitié de sa spec.
Même découpage que le kanban : **contenu versionné dans le repo, diffusion par artifact depuis le compte propriétaire.** La convention existe déjà (`DOCS/claude design/`, 3 maquettes HTML). Les plans texte, eux, sont précis et suffisent à régénérer les maquettes.
| Ordre | Quoi | Lien |
|---|---|---|
| ~~**DB0**~~ ✅ 2026-08-11 | **HTML des maquettes rapatrié** dans `DOCS/claude design/``guide-ia-screen.html` (31,8 Ko), `statistics-screen.html` (27,4 Ko), `sectionparcours-refonte-flux.html` (45,6 Ko), JS interactif compris. Méthode : bascule temporaire sur le compte propriétaire, lecture de la source, copie. L'assistant `visitapp-web` n'a jamais été publié ; le kanban vit déjà dans `DOCS/kanban.html` | La source de vérité du design vit désormais dans le repo, l'artifact ne sert plus qu'à la diffusion. **Constat** : Guide IA et Statistiques déclarent des tokens **rigoureusement identiques** — c'était déjà un design system, jamais porté en Dart. Et `--brand #264863` **est** `kPrimaryColor` |
| ~~**DB1**~~ ✅ 2026-08-11 | **Socle visuel dans `constants.dart`** : 11 rôles typographiques, 8 pas d'espacement, 5 rayons, paddings de carte et de page. `flutter analyze` propre. **Décision du 2026-08-11 : l'échelle vient des maquettes, les couleurs restent celles de l'app** — un écran neuf se fond dans manager-app, il n'y ouvre pas une seconde palette. Seules les valeurs sans équivalent existant sont reprises (`kSurface2/3`, `kLine`, `kLineSoft`, `kInk3`, `kWarning`). Pas de thème sombre. Reste : `size.width * 0.2` de `NumberInputContainer` | **L17** — sert aussi les 12 autres types de section. La reprise des écrans existants vers ces tokens est un chantier distinct (« repasse UI globale »), pas un effet de bord de celui-ci |
| ~~**DB2**~~ ✅ 2026-08-11 — **puis retiré par DB4 le même jour**, comme prévu | **SectionParcours — plus de perte silencieuse.** Confirmation avant d'abandonner du travail non enregistré, sur les **trois** niveaux de dialogue (Parcours, Étape, Question — chacun perdait tout ce qui était saisi sous lui), plus interception du retour navigateur par `PopScope`. 3 clés i18n FR/EN/NL. `flutter build web` ✅ | **Arbitrage du 2026-08-11 : DB2 se limite au garde-fou, la persistance incrémentale part avec DB4.** Motif : les deux sont **alternatives, pas cumulables** — si chaque étape est persistée à la saisie, le garde-fou n'a plus d'objet. Et la persistance impose de créer le parcours dès la 1ʳᵉ étape (donc des parcours à moitié remplis en base si l'utilisateur renonce, et un « Annuler » qui n'annule plus rien) : c'est précisément la sémantique qu'apporte le rail d'étapes de l'option A. L'écrire maintenant, c'est la câbler dans les callbacks `onSave` que DB4 supprime |
| DB3 | **Écran Guide IA complet, 2 onglets, stats assistant comprises**. 🔨 **Front livré le 2026-08-11** : coquille à onglets, §5 Aperçu de conversation branché sur le **vrai** `POST /api/AI/chat` (`AIApi` était dans le client généré mais jamais exposé dans `client.dart` — câblé), onglet « Ce que demandent vos visiteurs » avec ses tuiles, son bloc ambre des questions sans réponse et ses trois cartes de barres, 24 clés i18n FR/EN/NL. `flutter analyze` propre, `flutter build web` ✅.<br>**Complété le 2026-08-11** : carte §4 « Ce que connaît votre guide » sur `GET /api/Ai/knowledge/{id}` (agrégats `ContentEmbedding`), journalisation `VisitorQuestion` dans `AiController.Chat`, et `GET /api/Ai/insights/{id}` qui remplit l'onglet. `dotnet test` 130/130, `flutter build web` ✅.<br>**Reste dans ce lot** : l'onglet « Vocal » des stats et la vérification à l'œil (DB5). Le job de thèmes et tout le volet RGPD sont **déplacés au lot J**, en fin de backlog. La purge à 90 jours est livrée (`VisitorQuestionPurgeService`) | **L15**, **L16** — le contrat de données est fixé par `GuideIaInsights` dans `visitor_questions_tab.dart`, et `GuideInsightsDTO` en est le miroir exact : c'est lui que le job de thèmes doit remplir, pas l'inverse. **L9** : le §5 converge avec le Mode preview |
| ~~**DB4**~~ ✅ 2026-08-11 | **SectionParcours — la forme, option A livrée.** Les trois `showNewOrUpdate…` sont supprimés. `Parcours/Fields/` porte les trois widgets autonomes (`ParcoursFields`, `EtapeFields`, `QuestionFields`) ; `guided_path_editor.dart` est la coquille : fil d'Ariane, rail d'étapes réordonnable toujours visible, panneau de détail, pied de page « Enregistré ». Une question se **déplie dans le panneau de son étape** — profondeur maximale **2**, la fenêtre puis la traduction. **Sauvegarde à la saisie** : `guided_path_api.dart` unifie `sectionParcoursApi` / `sectionMapApi`, un débounce de 700 ms écrit le parcours (PUT), les étapes (POST/PUT/DELETE) et leurs questions ; les changements de structure (ajout, suppression, réordonnancement) partent sans attendre. **Le garde-fou de DB2 est retiré** — 3 clés i18n supprimées, 14 ajoutées FR/EN/NL. `flutter build web` ✅ | **L18****A** retenue. Deux pièges du backend, tranchés à la lecture du contrôleur : `UpdateGuidedPath` **supprime les étapes absentes du DTO**, donc le payload n'emporte que celles qui ont déjà un id — les autres attendent leur `CreateGuidedStep` et seraient dupliquées. Et il n'y a **pas** d'endpoint QuizQuestion (voulu) : l'id entier attribué par `SyncQuizQuestions` est récupéré après coup **par `order`**, seul repère stable entre la liste locale et celle du serveur. Le parcours est **créé à la première modification**, pas à l'ouverture : fermer une fenêtre neuve intacte ne laisse rien en base. Si une écriture échoue, la fenêtre refuse de se fermer et le pied de page porte un « Réessayer » |
| DB5 | **Boucle de vérification, à chaque écran** : `flutter run -d chrome`, capture, comparaison côte à côte avec la maquette | `flutter analyze` ne prouve rien sur du design — et reste inexploitable tant que le lot A n'a pas nettoyé `manager_api_new` (**L8**) |
Ne change pas : la popup de traduction (un niveau justifié, langues verticales, pas d'onglets), et la conception des composants de `Components/` — leur problème est le dimensionnement, pas la structure.
### Lot E — Parité et finitions visiteur (1 à 2 jours)
| Quoi | Réf. |
|---|---|
| ~~Fournisseur de carte honoré en web~~**2026-08-11 — tranché : option a, le web reste sur Leaflet** | W1. **Ce n'était pas un oubli de câblage** : le web reçoit déjà `mapProvider`, `mapType` et `mapTypeMapbox` (`client.ts:44-45`). Mais « honorer le fournisseur » n'a pas d'équivalent Leaflet gratuit — côté Flutter ce sont **deux SDK** (`GoogleMapView` / `MapBoxView`, `map_page.dart:142-148`) ; MapBox exige un token payant, et les tuiles Google en Leaflet violent leurs conditions.<br>⚠️ **L'option a ne pouvait pas se faire comme annoncée.** « Retirer le champ pour les instances web » est **impossible** : le fournisseur est porté par la **section** (`SectionMap.MapMapProvider`, `SectionAgenda.AgendaMapProvider`), pas par l'instance — et une même Configuration est liée à plusieurs `ApplicationInstance` via `AppConfigurationLink`, donc **servie au mobile comme au web**. Masquer le champ aurait supprimé le réglage mobile.<br>**Ce qui a été fait à la place** : le champ reste (il gouverne toujours le mobile) et manager-app **cesse de laisser croire qu'il vaut pour le web** — mention explicite sous les deux sélecteurs (`map_config.dart`, `agenda_config.dart`), 1 clé i18n FR/EN/NL. Zéro ligne côté `visitapp-web` : Leaflet inconditionnel était déjà le comportement. `flutter build web` ✅ |
| ~~PDF en média d'étape~~**2026-08-11** | Les trois volets faits. **Côté manager-app** : la liste de types du sélecteur de médias devient un paramètre (`kSliderContentResourceTypes` par défaut) et l'étape passe `kGuidedStepResourceTypes` = les types du slider + `Pdf`. **Côté visitapp-web** : cas PDF dans `ResourceViewer.tsx`, réutilisant le `PdfViewer` existant et son proxy `/api/pdf` (contournement CORS déjà en place), importé en `dynamic({ssr:false})` comme le fait `PdfSection`. **Côté mymuseum-visitapp** : ⚠️ **pas dans `showElementForResource`** — cette fonction fait un `return CachedCustomResource(...)` **avant** son `switch`, donc tout son switch est du code mort. Le vrai dispatcher est `CachedCustomResource`, qui a **deux** switch (ressource distante / fichier local déjà téléchargé) tombant tous deux sur `Text("Not supported type")` : les deux sont traités. L'enum du client s'appelle `ResourceType.Pdf`, pas `PDF`.<br>`npm run build` ✅, `flutter build web` (manager-app) ✅. Le volet mymuseum n'est vérifié que par `flutter analyze` — son APK ne compile pas (Gradle/NDK, cf. §1bis) |
| ~~Comparaisons `QuestionType` par entier brut~~**2026-08-11** | Descendu du lot B. Le générateur nommait les valeurs `number0/1/2`, ce qui poussait le front à comparer des entiers. Alias nommés ajoutés à la main dans `question_type.dart` (`simple`, `multipleChoice`, `puzzle`, d'après l'enum serveur `Simple`/`MultipleChoice`/`Puzzle`), `values` inchangé. Les deux sites visés sont corrigés (`showNewOrUpdateGuidedStep`, `guided_step_challenge._kindOf`) **et** les 7 usages `number*` de `showNewOrUpdateQuizQuestion` migrés — plus aucun `QuestionType.number` dans les trois apps. Les libellés français en dur de ce dialogue relèvent de la dette i18n générale, laissés en place |
| ~~Refonte du flux SectionParcours~~ | **Déplacé au lot D-bis** (DB2 et DB4) — c'est un chantier de design, pas une finition de parité |
### Lot F — Guide IA lot 4 et dette produit (1 semaine)
| Quoi | Lien |
|---|---|
| ~~Volet RGPD~~ | **Déplacé au lot J**, fin de backlog (décidé le 2026-08-11) |
| ~~Insertion `VisitorQuestion`~~ · ~~purge 90 j~~ · ~~onglet « Ce que demandent vos visiteurs »~~ | ✅ **Livrés le 2026-08-11** |
| ~~Job de regroupement en thèmes~~ | **Déplacé au lot J** (J2) — il conditionne la promesse d'agrégats des CGU |
| Aperçu de conversation | **L9** — converger avec « Mode preview / cible Assistant-Persona » |
| Onglet « Vocal » dans les stats | |
| `GetSummary` agrégé en SQL au lieu de `eventsQuery.ToList()` | **L13** — devenu pressant par la rétention 13 mois |
| ~~Écran d'audit log~~ · ~~gestion des users par instance (compteur UI)~~ | 🔨 **Front livré le 2026-08-12**, `manager-app` seul. Écran `Screens/Audit/` sur `GET /api/Audit` (filtres instance / type / utilisateur / dates, pagination 50, détail avant-après), entrée de menu `menuId: 13` réservée à `role.value == 0`. Compteur « X / 5 utilisateurs » et bouton d'ajout désactivé au plafond, SuperAdmin exclu (sa liste couvre toutes les instances). 40 clés i18n FR/EN/NL, `flutter build web` ✅. **`manager_api_new` intact** : `http.get` direct au Bearer, comme le Guide IA — une lecture seule ne justifie pas d'étendre un client qui s'édite à la main.<br>⚠️ **Deux dettes backend ouvertes par ce chantier, volontairement non traitées** (voir les deux lignes suivantes) | **L8** |
| **Plafond de 5 utilisateurs côté serveur** | ❌ `UserController.CreateUser` ne compte rien, pas de 422. Ce qui est livré côté front est un **garde-fou d'interface** : un POST direct sur l'API passe toujours. Le plafond n'est pas non plus dans `SubscriptionPlan` (5 champs, aucun sur les utilisateurs) — s'il devait varier par plan, c'est une colonne, donc un changement de schéma **après** le gel du lot B. Le retour d'erreur du POST est déjà branché côté front : le 422 sera visible sans retouche |
| **Aucune section n'est journalisée** | ❌ `AuditedTypes.Contains(entry.Entity.GetType())` (`MyInfoMateDbContext.cs:126`) exige l'égalité exacte, or `Section` est **abstraite** (`Section.cs:17`) : le type runtime est toujours `SectionMap`, `SectionQuiz`… donc `typeof(Section)` ne matche jamais. Le journal couvre `Resource`/`Configuration`/`Device`/`User`/`Instance` mais **pas le contenu** — précisément ce que l'écran devait tracer. Correctif : `Any(t => t.IsInstanceOfType(...))`. ⚠️ `EntityType` portera alors `SectionMap` et non `Section` : soit normaliser côté serveur, soit élargir la liste du filtre front aux 13 sous-types |
| 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, Stripe Customer Portal | §1 « À faire » |
| Tests du vector store via `Testcontainers.PostgreSql` sur `Dockerfile.postgres` | **L14** — au minimum, éprouver le post-filtrage HNSW **à deux instances** avant la bascule |
### Lot G — `MigrationController` à niveau + dry run (3 à 4 jours) — le plus gros
Les 8 écarts a→h du §1quinquies. Ne démarre qu'après les lots B et C1 (**L2**, **L5**).
| Écart | Quoi | Attention |
|---|---|---|
| ~~c~~**2026-08-11** | `Instance` : 4 champs migrés sur ~35 — **requalifié : ce n'était pas un oubli de mapping.** `OldInstance` et l'export réel ne contiennent que `_id`, `Name`, `DateCreation`, `PinCode`. Les 31 autres colonnes **n'existent pas dans la source** : elles sont nées avec Postgres v3. Il n'y a donc rien à mapper, il faut **générer, dériver ou décider**. `PublicApiKey` → générer · `WebSlug` → dériver du `Name` (`SlugHelper` existe) · `IsMobile`/`IsTablet`/`IsWeb` → dériver · `SubscriptionPlanId` → décidé.<br>**Livré** : `PublicApiKey` générée avec le même schéma que l'inscription self-service (`ap_` + 32 octets cryptographiques) · `WebSlug` via `SlugHelper.GenerateUniqueSlug` (accents et collisions déjà gérés) · `IsTablet`/`IsMobile` dérivés des configurations Mongo, comme le fait déjà `MigrateApplicationInstancesAsync` ; `IsWeb` reste faux, le canal web n'existait pas · plan affecté par nom, un nom inconnu retombant sur **Pro**, jamais sur un plan qui offrirait l'IA · `IsAssistant` allumé seulement si le plan donne des jetons.<br>⚠️ `ApplyPlanQuotas` est **dupliqué** depuis `InstanceController` plutôt qu'appelé : la migration ne doit pas casser si ce contrôleur change de forme. Les deux doivent rester alignées | **Le plus dangereux : aucune erreur levée.** `PublicApiKey` null = les apps visiteur ne s'authentifient plus ; `WebSlug` null = visitapp-web injoignable ; `SubscriptionPlanId` null = quotas à 0, donc `AiTokensPerMonth = 0`, donc **rien ne s'indexe** et l'IA tourne sans compteur. Réutiliser `ApplyPlanQuotas` |
| ~~a~~ | ⛔ **Fausse alerte — vérifiée dans l'export le 2026-08-11.** `BuildSection` couvre **exactement les types que Mongo contient**. L'enum va de `Map`=0 à `Weather`=10, puis `Event`=11 et `Parcours`=12 ; or `TabletDb.Sections.json` (327 sections) ne contient **que les valeurs 0 à 10**. `SectionEvent` et `SectionParcours` **n'existaient pas dans Mongo** — ce sont des types nés avec Postgres v3. Le `default` est un filet, pas un trou | Le kanban parlait de « perte de données » et du « scénario carnaval » : ce scénario est du **contenu à créer** dans Postgres, pas du contenu à migrer. Vérifié aussi : le renommage `SectionPuzzle``SectionGame` est **déjà traité** — la branche `Game` parse `OldPuzzleDTO` et pose `GameType = GameTypes.Puzzle`, et la valeur 8 n'a pas bougé |
| ~~b~~**2026-08-11** | Collections filles jamais remplies | **Vrai, et mesuré** : `QuizQuestions = new()` jetait les questions de **5 sections quiz, soit 41 questions**, réponses comprises. Migrées. Le type de validation n'existant pas dans l'ancien modèle (tout y était à choix multiples), elles arrivent en `MultipleChoice` plutôt qu'au défaut `Simple`. **En revanche `EventAgendas = new()` et l'absence de `GuidedPath` sont corrects** : les agendas Mongo ne portent que `mapProvider` et `resourceIds`, aucun événement, et SectionParcours est né avec Postgres |
| ~~d~~**2026-08-11** | `Role = UserRole.ContentEditor` en dur | **Vrai, et pire que décrit** : Mongo **n'a pas de champ Role**, donc les 10 utilisateurs y avaient un accès complet. Le défaut les **dégradait tous en silence** — plus personne n'aurait pu gérer les utilisateurs de son instance. Passé à `InstanceAdmin`, ce qui préserve leurs droits d'hier et correspond au rôle que l'inscription self-service donne au premier utilisateur. ⚠️ Aucun `SuperAdmin` n'est créé par la migration : à poser à la main |
| ~~e~~**2026-08-11** | Colonnes `Resource` non renseignées, `SizeBytes` déduit d'un `HEAD` avalé | **L5 fermé** : passe par `ResourceStorage.Apply`, le même calculateur que la création et le backfill — `StoragePath` n'était pas écrit **du tout**. Et l'échec du `HEAD`, jusqu'ici silencieux, **remonte dans le rapport** : une ressource à 0 octet que le quota compte pour rien, ça se sait |
| ~~f~~ | ⛔ **Fausse alerte — vérifiée dans l'export le 2026-08-11.** Ces trois colonnes **n'existent pas dans Mongo** : les forcer à `false` est le bon défaut, pas une perte. Contrôle inverse fait aussi — les cinq réglages qui, eux, **existent** dans l'export (`IsDate`, `IsHour`, `IsSectionImageBackground`, `RoundedValue`, `ScreenPercentageSectionsMainPage`) **sont bien mappés**, sur `AppConfigurationLink` | Rien à corriger |
| g | `MapResourceId = dto?.iconResourceId` | **L6** — tombe avec le lot B |
| ~~h~~**2026-08-11** | Pas de transaction globale, `catch` qui renvoie 200 OK | Transaction unique sur les neuf étapes : soit tout est là, soit rien. Et l'échec fatal renvoie désormais **500** — le piège était qu'un script de bascule testant le code HTTP concluait au succès, l'erreur n'étant que dans le corps. En `dryRun`, pas de transaction ouverte : rien n'est écrit |
Puis : **dry run** (`dryRun=true`) et **comparaison des comptages par entité**. C'est la seule preuve que les écarts sont fermés.
🔨 **Joué le 2026-08-11 sur l'export du 1ᵉʳ avril.** Automatisé en test (`MigrationDryRunTests`), qui **se saute si aucun Mongo n'écoute** sur `MIGRATION_TEST_MONGO` (défaut `mongodb://localhost:27018`) — la suite reste donc verte sans dépendance. `dotnet test` **144/144**.
⚠️ **`MigrationController` lit un MongoDB vivant, pas les fichiers de `migration-data/`.** Ces JSON sont un export : il faut les importer dans un Mongo avant tout dry run. Recette (rien à installer sur l'hôte) :
```bash
docker run -d --name myim_mongo_dryrun -p 27018:27017 mongo:6
# monter ailleurs que sous /data : l'image mongo y déclare déjà des volumes
for c in Instances Users Configurations Resources Sections Devices; do
docker run --rm --network container:myim_mongo_dryrun -v "$PWD/migration-data:/import:ro" mongo:6 mongoimport --uri mongodb://localhost:27017 --db TabletDb --collection $c --file /import/TabletDb.$c.json --jsonArray --drop
done
dotnet test --filter MigrationDryRunTests
```
**Résultat, et ce qu'il a trouvé du premier coup :**
| Entité | Mongo | Migré |
|---|---|---|
| Instances | 4 | 4 |
| Users | 10 | 10 |
| Configurations | 15 | 15 |
| Resources | 2374 | 2374 |
| **Sections** | **327** | **307** + 20 signalées |
| Devices | 46 | 46 |
**20 sections manquaient, avec `Erreurs : 0`** — la perte silencieuse même. Diagnostic : elles référencent **deux configurations qui n'existent plus** dans Mongo (`6863c9af…`, `6863cac9…`) — 19 articles et un slider, du contenu MDLF (« Senteur : fraises », « Pupitre vigne », « Ruche 7 »). Ce n'est **pas un défaut de la migration** : les sections sont collectées configuration par configuration, donc les orphelines n'étaient jamais énumérées, et la FK `ConfigurationId` les refuserait de toute façon. Le défaut était le **silence**. Elles partent désormais dans `Skipped`, et le test affirme l'invariant qui compte : `migrées + signalées = Mongo`.
> ⚠️ **Décision produit en attente** : ces 20 sections sont du contenu réel qui n'arrivera pas en Postgres. Soit on recrée les 2 configurations manquantes dans Mongo **avant** la bascule pour les récupérer, soit on acte leur perte. À trancher avec MDLF, pas en interne.
⚠️ **L'export date du 1ᵉʳ avril, la prod tourne encore sur Mongo** : ce dry run valide la mécanique, pas les comptages du jour J. **À rejouer sur un `mongodump` frais** juste avant la bascule — de préférence avec un utilisateur Mongo `readAnyDatabase` créé pour l'occasion, plutôt qu'en pointant l'app de dev sur la prod : les six `DatabaseService` exposent `InsertOne`/`ReplaceOne`/`DeleteOne`, et `dryRun` ne protège que Postgres.
### Lot H — Tests end-to-end (1 semaine)
Ordre du §2 de STATUS.md, corrigé par **L12**.
| Ordre | Quoi | Note |
|---|---|---|
| H1 | **§0 — porte d'entrée build**, 5 commandes | Possible seulement après le lot A (tablet-app) |
| H2 | **§8bis / §8ter / §8quater** — écran Statistiques, export PDF, rétention | Jamais ouvert dans un navigateur. Sur le PDF : accents, et logo — s'il manque, c'est le CORS du bucket Firebase, le rapport doit se générer quand même |
| H3 | **§19 — parité par type de section**, puis **§19.13 cas E** (chasse au trésor / carnaval) | Le cas E valide SectionMap + SectionParcours + géodéclenchement + questions + mode jeu d'un coup. Le cas 0 valide les 4 correctifs du 09/08 |
| H4 | **§18 — onboarding self-service** | **L12** — après le déploiement web du lot I. Prérequis de config : alias `onboarding@myinfomate.be` et `whsec_` de la Stripe CLI |
| H5 | §1-17 par lot, §16 non-régression | |
### Lot K — Apps visiteur à niveau pour la bascule (3 à 5 jours) — **ajouté le 2026-08-12**
> **Ce lot n'existait pas, et il est bloquant.** Il est né d'un vrai build de `tablet-app`, le premier depuis des mois : le diagnostic des docs (« un seul défaut, purement Gradle, peut-être déjà réglé par `5a6701d` ») ne tenait que parce que personne n'avait lancé la commande.
>
> ⚠️ **Le point qui fait de ce lot un bloquant de la bascule, et que le plan ne portait nulle part : le contrat d'API a changé, les apps déployées parlent l'ancien.** `ConfigurationDTO.roundedValue` et `GeoPointDTO.latitude/longitude` ne sont plus servis par Postgres v3. Une tablette en service chez MDLF ou au Fort ne s'arrêterait pas proprement le jour J — elle afficherait des cartes sans points et des écrans dégradés. Voir **L19**.
**`tablet-app` construit — `flutter build apk --debug` vert le 2026-08-12**, APK produit pour la première fois depuis le 16 avril. K1, K2 et K4 sont livrés et **prouvés par un build**, pas seulement par `flutter analyze`. L'APK passe de 174 à 248 Mo : effet attendu du cran 3 (bibliothèques natives non compressées).
**K1 — Outillage Android `tablet-app`, 2026-08-12. Dix crans**, chacun dicté par l'erreur du précédent — à ne pas re-découvrir un par un. ⚠️ **Le compte de « six crans » écrit plus tôt dans la journée était provisoire** : il datait du moment où la chaîne Gradle passait, avant que la compilation Kotlin ne révèle la suite.
| # | Changement | Ce qu'il débloquait |
|---|---|---|
| 1 | Gradle **7.5 → 8.11.1** | le plugin Kotlin exigeait ≥ 7.6.3 |
| 2 | AGP **7.2.0 → 8.5.0**, Kotlin **1.9.0 → 2.0.10** | `onVariants$default` : Kotlin 1.9 appelle une API absente d'AGP 7.2 |
| 3 | `android.bundle.enableUncompressedNativeLibs` **retirée** | option supprimée en AGP 8.1. Valait `false`, le défaut est `true` : les `.so` ne sont plus compressés dans l'AAB |
| 4 | AGP → **8.7.3** | minimum exigé par Flutter (8.6.0) |
| 5 | AGP → **8.9.1** | réclamé par `androidx.browser:1.9.0` et `androidx.core:core-ktx:1.17.0`, tirés par les plugins |
| 6 | `jcenter()``mavenCentral()` | jcenter est arrêté depuis 2021 |
| 7 | `jvmargs` **1536M → 4096M** | `Java heap space` dans `JetifyTransform` sur les jars Flutter |
| 8 | `android.enableJetifier` **true → false** | Jetifier ne sert plus à rien depuis qu'AndroidX est partout, et c'est lui qui saturait la heap |
| 9 | **`resolutionStrategy` sur `androidx.lifecycle` retiré** | ⚠️ **Le plus instructif** : il forçait la version **2.4.0** avec le commentaire « To fix mapbox issue ». Il réglait un problème mapbox d'il y a trois ans et **causait** celui d'aujourd'hui — `mapbox_maps_flutter` 2.21.1 appelle `setViewTreeLifecycleOwner`, absent de 2.4.0, et sa compilation Kotlin échouait. Ne pas le remettre : un commentaire le dit dans `android/build.gradle` |
| 10 | Kotlin **2.0.10 → 2.3.10** | les dépendances tirent `kotlin-stdlib 2.3.10`, métadonnées binaires incompatibles avec un compilateur 2.0. Satisfait au passage l'exigence Flutter ≥ 2.2.20, annoncée comme prochaine rupture |
**Les crans 7, 8 et 9 sont des alignements sur `mymuseum-visitapp`**, qui utilise le même plugin mapbox et ne rencontre aucun de ces problèmes : `-Xmx4096M`, `enableJetifier=false`, aucun forçage de `lifecycle`. **Réflexe à garder pour K5** : avant de chercher, comparer les deux `gradle.properties` et les deux `build.gradle`.
⚠️ **Un bloc `buildscript` entièrement commenté dans `android/build.gradle`** déclarait AGP 8.5.0 / Kotlin 2.0.10 alors que la config appliquée vivait dans `settings.gradle` (AGP 7.2.0 / Kotlin 1.9.0). Il a fait conclure deux fois à une configuration qui n'était pas celle du build — **il est supprimé**, les versions ne sont plus déclarées qu'à un seul endroit. C'est le troisième piège « bloc commenté » du projet, après les deux pistes Dart écartées le 11/08.
⚠️ **Dette d'outillage restante, non bloquante, à traiter en V2** : Flutter annonce l'abandon de Kotlin < 2.2.20, et **8 plugins appliquent le Kotlin Gradle Plugin** (`mapbox_maps_flutter`, `video_player_android`, `webview_flutter_android`, `device_info_plus`, `fluttertoast`, `package_info_plus`, `shared_preferences_android`, `wakelock_plus`) les futures versions de Flutter refuseront de construire dans ce cas. **`compileSdkVersion 36` n'est pas négociable** : sans lui, plus de mise à jour publiable sur le store.
| # | Quoi | Note |
|---|---|---|
| ~~**K2**~~ **2026-08-12** | **Portage livré. Les 132 erreurs de contrat sont tombées** ; `flutter analyze lib` ne renvoie plus que les 6 erreurs `PuzzleDTO`, qui sont K4. Fait : `TabletAppContext.currentAppConfigurationLink` (copie du getter mymuseum), `lib/Helpers/geo.dart` avec l'extension `lat`/`lng`, 67 accès `roundedValue` rebranchés, `main_view` et les 4 vues carte portées.<br>⚠️ **Deux bugs latents trouvés — c'est ce qui justifiait de le faire à la main.**<br>**(1)** `applicationInstanceDTO` n'était assigné **que si `instanceDTO.isAssistant == true`** (`main_view:339`) : il servait de drapeau d'assistant. Ce DTO portant désormais les `AppConfigurationLink`, le getter aurait renvoyé `null` sur **toute instance sans IA — donc MDLF et le Fort** — et les cinq réglages seraient retombés sur leurs défauts, sans erreur ni log. Corrigé : assignation inconditionnelle + drapeau explicite `isAssistantEnabled`, qui **préserve le ET** instance × canal (les deux gardes de `AiController:315/360`).<br>**(2)** `ConfigurationDTO.isTablet` a disparu : le filtre « configurations pour tablette » de `getConfigurations` n'avait plus de source. Il porte désormais sur les `AppConfigurationLink` du canal `AppType.Tablet`, même appel que `main_view`. ⚠️ **À valider sur device (§0/§18)** : si un client a des configurations non rattachées à son canal tablette, l'écran de sélection sera **vide**. L'ancien code avait le même angle mort, mais la donnée qui pilote le filtre a changé.<br>⚠️ **Piège de vérification** : `flutter analyze` écrit `error - `, pas `error • ` — un grep sur la mauvaise forme a fait conclure « 0 erreur » alors qu'il en restait 10 |
| ~~K2 — notes de conception~~ | ⚠️ **Deux conventions de coordonnées coexistent** dans le projet — `GeoPointDTO` en `[lng, lat]`, géométries de `GuidedStep` en `[lat, lng]`. Une confusion **ne lève aucune erreur**, elle déplace les points. Détail et tableau dans **STATUS.md §1sexies**. Fondations déjà posées le 2026-08-12 : `TabletAppContext.currentAppConfigurationLink` et `lib/Helpers/geo.dart`.<br>**Portage du contrat d'API** — **132 erreurs Dart, 3 causes** : `ConfigurationDTO.roundedValue`/`isDate`/`isHour`/`isSectionImageBackground`/`screenPercentageSectionsMainPage` ont migré vers **`AppConfigurationLinkDTO`** (73 err.) · `GeoPointDTO.latitude/longitude`**`GeometryDTO? geometry`**, effet de PostGIS (40 err.) · les `min()` sur `dynamic` en découlent (19 err.) | **Le modèle est déjà écrit** : `mymuseum-visitapp` a fait ce portage (`visitAppContext.currentAppConfigurationLink?.roundedValue`, `Models/visitContext.dart`). Copier, ne pas concevoir. Les 40 sites `latitude`/`longitude` se traitent par **une extension Dart sur `GeoPointDTO`** plutôt qu'un à un |
| **K3** | **Périmètre kiosk — tranché le 2026-08-12.****`tablet-app` couvre 10 des 13 types, pas 11 — recompté dans le `switch` le 2026-08-12.** `main_view.getContent` traite Map, Slider, Video, Web, Menu, Quiz, Pdf, Game, Agenda, Weather. **`SectionArticle` (type 6) manque** : son `case` est commenté « TODO » et `Screens/Article/article_view.dart` fait 1 Ko — un article tombe dans le `default`, « Ce type n'est pas supporté ». **Ce type-là existait dans Mongo**, contrairement aux deux ci-dessous : c'est un vrai retard, à trancher (le supporter ou l'assumer) et non à compter comme couvert. Énoncé d'origine, qui reste valable : `tablet-app` couvre **11 des 13** types. **`SectionParcours` : exclu définitivement** — un parcours guidé fait marcher le visiteur avec géodéclenchement, ça n'a aucun sens sur borne fixe. **`SectionEvent` : à supporter** — une borne d'accueil qui affiche le programme du jour et le plan est le cas d'usage kiosk par excellence, et c'est le scénario du §19.13 cas E | Ni l'un ni l'autre n'est un retard : les deux types **sont nés avec Postgres v3**, ils n'ont jamais existé dans Mongo (cf. lot G, écart a) |
| ~~**K4**~~**2026-08-12** | **Écran Game repris de mymuseum.** Les 6 fichiers de `Screens/Sections/Game/` sont dans `tablet-app/lib/Screens/Game/`, contexte adapté ; `lib/Screens/Puzzle/` supprimé ; `main_view` bascule sur `SectionType.Game` / `GameDTO` / `GamePage`. **Gains** : le puzzle glissant (mélange par mouvements valides, donc toujours soluble), le bouton d'indice, et un dimensionnement qui respecte le ratio de l'image — 508 lignes contre 237 à l'ancien `puzzle_view`.<br>⚠️ **Une décision de rendu à valider à l'œil** : mymuseum code ses couleurs **en dur par flavor client** (`kMainColor0/1/2` — rouge MDLF, bleu Fort). `tablet-app` n'a pas de flavor : un seul APK sert tous les clients et la charte vient de la configuration choisie au pincode. Recopier ces constantes aurait affiché du bleu chez MDLF. Le dégradé et le bouton d'indice sont donc **dérivés de `configuration.primaryColor`** (même parsing que `audio_player`/`loading_common`, repli `kTestSecondColor`, 3 nuances par `Color.lerp`). Plus juste sur le fond, mais **le rendu diffère de la version mobile** : si le dégradé dessiné à la main est préféré, figer les trois couleurs |
| ~~K4 — énoncé d'origine~~ | **`SectionPuzzle``SectionGame` + le type glissant.** Le serveur a renommé et porte `GameTypes { Puzzle, SlidingPuzzle }` ; `tablet-app` a encore un écran `Puzzle/` qui ne connaît que l'ancien. **Reprendre le code de `mymuseum-visitapp/lib/Screens/Sections/Game/`**`game_page.dart` et `sliding_puzzle_piece.dart` existent déjà là-bas, et cette version est **moins buguée** que celle de tablet-app | Récupération, pas réécriture |
| ~~**K5**~~**2026-08-12** | **Les trois flavors de `mymuseum-visitapp` construisent** (`dev`, `mdlf`, `fortsaintheribert`), exit 0, APK à l'appui. **D0 est débloqué.**<br>**L'énoncé était faux : il n'y avait pas de « même rattrapage » à faire.** Sa config Android était **déjà à niveau** — Gradle 8.11.1, AGP 8.9.0, `-Xmx4096M`, `enableJetifier=false`, aucun forçage d'`androidx.lifecycle`. Les dix crans de K1 ont amené **`tablet-app` jusqu'à `mymuseum`** ; le lot supposait la symétrie du problème, elle n'existait pas. Le réflexe du diff annoncé en K1 a donné la réponse en deux `cat`.<br>**Ce qui restait, et qui est fait** : les deux avertissements du build. NDK **27.0.12077973 → 28.2.13676358** (`speech_to_text` le réclame nommément) et Kotlin **2.1.0 → 2.3.10** (seuil 2.2.20 annoncé comme rupture ; c'est la version de `tablet-app`, donc un alignement). Six builds — trois avant, trois après. APK de **382 à 349 Mo**.<br>⚠️ **Un cran de plus existe, délibérément non pris** : Kotlin réglé, Flutter réclame Gradle 8.14.0 et AGP 8.11.1. C'est du **terrain neuf pour les deux repos** (`tablet-app` est à 8.11.1/8.9.1, mêmes avertissements latents) — le faire ici seul **désaligne** les deux apps. À faire d'un bloc sur les deux, ou pas du tout : c'est la dette d'outillage déjà parquée en V2 | Son code était déjà porté sur le nouveau contrat — **c'est ce qui aurait dû mettre la puce à l'oreille** : le repo qui a servi de modèle au portage K2 n'avait pas de raison d'être en retard d'outillage sur celui qui le copiait |
| **K7** | **`SectionArticle` sur le kiosk — ajouté le 2026-08-12**, en recomptant les types couverts. Son `case` est commenté « TODO » dans `main_view.getContent` et `Screens/Article/article_view.dart` fait 1 Ko : un article rend « Ce type n'est pas supporté ». **Contrairement à `SectionEvent` et `SectionParcours`, ce type existait dans Mongo** — c'est donc un vrai retard, pas un type né avec Postgres v3, et il sortira de la migration avec du contenu réel.<br>**Le code est une reprise de `mymuseum-visitapp/lib/Screens/Sections/Article/`**, pas une conception. **Le travail n'est pas là** : il est dans le **passage en paysage**. Les écrans de mymuseum sont pensés pour un téléphone tenu debout ; une borne est large, et un article rendu en colonne unique sur 1280 px de large est illisible. C'est le seul endroit du lot K où il y a une décision de mise en page à prendre, pas une copie à faire.<br>**Maquette faite et validée le 2026-08-12**`DOCS/claude design/kiosk-paysage-article-event.html`, une seule passe pour K3 et K7. Six règles communes (deux colonnes ancrage/flux, texte à 65 caractères, héros à 26 % au lieu de 52 %, plus de `showModalBottomSheet`, carte sans « Agrandir », accents dérivés de `configuration.primaryColor` selon le précédent K4) et quatre décisions tranchées : **fond clair** aux valeurs réelles de l'app (`kBackgroundColor` / `kBackgroundLight`), **pas de sélecteur de langue** (choisie en amont), **hors dates : aucune prose**, et le **retour automatique** sorti du périmètre (ligne suivante).<br>⚠️ **Deux vérifications faites dans le code plutôt que supposées.** Les **statistiques sont déjà acquises** : `section_page_detail.dart:60/72` émet `sectionView` et `sectionLeave` avec durée, et il enveloppe les treize types — l'article sera compté dès que son `case` entre dans le `switch`, sans une ligne à écrire. ⛔ **Et une erreur à ne pas rouvrir : j'avais écrit que `tablet-app` n'a aucun i18n. C'est faux.** `lib/Helpers/translations.dart` porte une table de **10 langues** (FR, EN, NL, DE, IT, ES, PL, CN, AR, UK), ~150 entrées, lue par `TranslationHelper.getFromLocale` à **19 endroits**, avec déjà une clé `back`. Ce n'est pas de l'ARB : chercher `l10n/` ou `AppLocalizations` ne le trouve pas — c'est une table à la main, le même pattern que mymuseum, décrit dans le `CLAUDE.md` du repo. **Conséquence** : un libellé traduit coûte une clé × 10 langues, pas un chantier. La règle « hors dates, aucune prose » reste défendable — rien à traduire vaut mieux que quelque chose à traduire — mais c'est désormais un **choix, pas une contrainte**, et le motif d'origine était faux. ⚠️ Corollaire pour l'implémentation : le bouton « Retour » passe par la clé `back`, il ne s'écrit pas en dur | **À traiter avec K3** : `SectionEvent` pose exactement la même question de paysage (héros, programme, carte), et la trancher deux fois donnerait deux mises en page différentes sur la même borne. Une seule passe de design pour les deux, même raisonnement d'économie que **L5** et **L15**.<br>**Trois états incomplets couverts par la maquette** : sans audio (le dock tombe, l'image reprend la hauteur), sans photo (le rail tombe, l'audio passe en tête de la colonne de lecture), texte seul (une colonne centrée, toujours plafonnée). ⚠️ **`audioIds` est une `List<TranslationDTO>`** : « sans audio » est un état **du visiteur**, pas de l'article. Règle actée le 2026-08-12 — **pas d'entrée pour la langue choisie, pas de lecteur du tout** : ni lecteur grisé, ni repli sur une autre langue. Et **`isContentTop` existe déjà** ; en paysage il n'y a plus de « dessus », donc **le drapeau devient le côté** : `isContentTop == true` → texte à gauche et rail média à droite, sinon média à gauche. Le rail change de bord, pas de contenu. À câbler sous peine de rendre sans effet un réglage déjà offert dans `manager-app` |
| **K8** | **Retour automatique à l'accueil après inactivité — ajouté le 2026-08-12**, sorti de la maquette K3/K7. Un visiteur part sans fermer l'écran ; le suivant trouve l'article du précédent. Délai proposé : **5 minutes**.<br>**À écrire une seule fois pour les treize types**, dans `section_page_detail` — il enveloppe déjà toutes les sections et porte déjà les crochets `initState`/`dispose`. Bénéfice de bord : le retour passant par le même `dispose`, il émet un `sectionLeave` **avec sa durée réelle** au lieu de laisser une consultation ouverte jusqu'au prochain visiteur. **La statistique devient juste, en plus de l'écran** | Ce n'est ni K3 ni K7 : c'est un comportement de borne qui vaut pour tous les types. À ne pas glisser dans le port des deux écrans, sinon il sera écrit deux fois puis une troisième |
| **K6** | **Publier les APK à jour**, puis vérifier le renouvellement du parc | **L19**. Les tablettes kiosk sont des appareils que tu gères — mise à jour maîtrisée. **Les téléphones des visiteurs, non** : aucune manœuvre technique ne force une mise à jour, d'où la stratégie de coexistence du lot I |
### Lot J — RGPD & conformité (2 à 3 jours) — juste avant le lot I
> **Placé en fin de backlog le 2026-08-11**, à la demande. C'est tenable pour une seule raison : la porte « rien en prod avant la fin du backlog ». La journalisation des questions ne tourne d'ici là que sur des bases de développement, donc aucun visiteur réel n'est concerné. **Si cette porte saute, ce lot remonte devant.**
Les CGU §8 ont été réécrites le 2026-08-11 (`cgu-myinfomate.md`) et le texte d'information visiteurs existe (`mention-information-visiteurs.md`). Ce qui reste :
| # | Quoi | Note |
|---|---|---|
| J1 | **Table d'agrégats de thèmes** | Les CGU promettent que les regroupements survivent à la purge. Or `ThemeId` est une colonne de `VisitorQuestion` : la purge du 90ᵉ jour l'emporte avec la ligne. Sans cette table, la promesse est vide et le client perd son historique au 91ᵉ jour |
| J2 | **Job de regroupement en thèmes** | Sans lui `ThemeId` reste nul, donc J1 ne contiendrait rien. Il remplit `topics` dans `GuideInsightsDTO` — forme déjà arrêtée par l'écran |
| J3 | **Interrupteur de collecte par instance** | ⚠️ Le client est **responsable de traitement** mais n'a aujourd'hui aucun moyen de refuser la collecte : elle est inconditionnelle dès que l'assistant est actif. Drapeau sur `Instance` + garde dans `RecordVisitorQuestion` |
| J4 | **Afficher la mention aux visiteurs** | Le texte existe, il n'est branché ni dans `visitapp-web` ni dans `mymuseum-visitapp`. Un lien depuis l'assistant suffit |
| J5 | **Faire relire le §8 des CGU** | Document contractuel, rédigé côté produit et non validé juridiquement. Priorité au §8.6 (sous-traitants) et au §8.5 (transfert hors UE) |
| J6 | **Vérifier les conditions réelles de Google** | Le §8.5 dit « peut impliquer un transfert hors UE » — prudent mais vague. Savoir si l'API Gemini utilisée offre une résidence européenne, et si un DPA est signé. La réponse réécrit le §8.5 |
| J7 | **Décider si un DPA séparé est requis** | L'article 28 exige un acte écrit. Le §8 en tient partiellement lieu ; une commune ou un musée subsidié en demandera un en annexe |
**Un point qui n'est pas un problème** : aucune demande d'effacement individuelle n'est exécutable, puisque rien n'est rattachable à une personne. C'est acceptable — la protection tient à l'absence d'identifiant plus la purge, pas à une procédure de droits qui ne pourrait pas s'appliquer.
### Lot I — Infra prod et bascule (2 à 3 jours + la fenêtre)
| # | Quoi | Lien |
|---|---|---|
| **I0** | **Rotation des secrets** : JWT signing key, clés Gemini/OpenWeather, connection strings, mot de passe MQTT, token Telegram → variables d'environnement, purge de l'historique Git, suppression de `RELEASE/`.<br>⚠️ **Inventaire élargi le 2026-08-12 : les apps Flutter en portent aussi.** Trouvé en réparant le build tablet-app : `SDK_REGISTRY_TOKEN=sk.…` — un token **MapBox secret** (préfixe `sk.`, pas `pk.`), au nom du compte perso, en clair dans `tablet-app/android/gradle.properties`, versionné. Il sert à télécharger le SDK MapBox au build, donc **le retirer casse le build** tant qu'il n'est pas passé en variable d'environnement — à faire dans la même passe, pas avant. **Passer les 3 repos Flutter au peigne** : l'inventaire d'origine ne couvrait que `manager-service`, on aurait rotationné la moitié des secrets en croyant le sujet clos | **L1** — déplacée du lot A le 2026-08-11. **Doit précéder I3** : la prod ne doit pas naître avec les clés de l'historique, sinon il faut rotationner deux fois. C'est le premier geste du lot I, pas une option |
| I1 | **`pg_dump` quotidien** en cron, en complément du snapshot OVH | **L3** — puis activer `Stats__RetentionDays=395` et vérifier que `visit-events-purge` ne supprime rien |
| I2 | Backup Mongo automatique en cron (dump + `docker cp` vers l'hôte) | Filet du rollback |
| I3 | **Un seul chantier compose/Traefik** : `Dockerfile.postgres` épinglé par digest, **port 5432 fermé**, entrée `app.myinfomate.be` + Dockerfile `visitapp-web` | **L7** — trois cartes, un fichier. **L1** : après la rotation des secrets |
| I4 | `dotnet ef database update` sur la base vide, puis vérifier `postgis` **et** `vector` présents | Une base en retard d'une migration fait planter l'API dès le login — c'est arrivé en local le 09/08 |
| **I9** | **Renseigner `Firebase:StorageBucket`** (`<project-id>.appspot.com`) dans la config de prod, puis **lancer le backfill C2** : `POST /api/Resource/backfill-storage?dryRun=true` d'abord, relire `Orphans`/`Unsized`, puis `dryRun=false` | ⚠️ **Deux conséquences si on l'oublie.** Le clé est vide par défaut : sans elle, `Delete` **ne supprime aucun blob** — il ne casse rien, il ne nettoie pas, et C6 (retrait du code client) deviendrait une perte de fonction. Et sans le backfill, le quota de C3 porte sur des `SizeBytes` à 0 pour tout l'existant : il laisse tout passer (**L10**).<br>⚠️ **Piège de build repéré le 2026-08-12** : `dotnet restore` échoue (401) si la source NuGet `git.dev-espaces-naturels.lu` est déclarée dans l'image — elle est sans rapport avec ce projet. À vérifier dans le Dockerfile avant de rebuilder, le nouveau `Google.Cloud.Storage.V1` étant le premier paquet ajouté depuis longtemps |
| I5 | **Plan de retour arrière écrit**, avant le jour J | « Un rollback qu'on improvise à 23 h n'est pas un rollback » |
| I6 | `pg_dump` avant, puis **rejouer instance par instance** (`instanceId=…`), en commençant par la plus petite | |
**Plans — décisions du 2026-08-11, à ne pas re-trancher.**
**1. Les lignes en base sont alignées sur la grille de tarifs** (migration `AlignSubscriptionPlansWithPricing`, appliquée). La base semait `Starter` / `Standard` / `Premium` / `Essentiel` : noms **et quotas** désalignés. Corrigé :
| Id | Nom | Stockage | Jetons IA | Stats | Rétention | Avancées |
|---|---|---|---|---|---|---|
| `plan-essentiel` | Essentiel | 1 GB | **0** (non inclus) | ✓ | 30 j | ✗ |
| `plan-pro` | Pro | 15 GB | **0** (non inclus) | ✓ | 30 j | ✗ |
| `plan-premium` | Premium | 50 GB | 20 M (~2 000 req) | ✓ | 395 j | ✓ |
| `plan-enterprise` | Enterprise | 0 = illimité | `long.MaxValue` | ✓ | 395 j | ✓ |
`plan-starter` **supprimé** (aucun équivalent commercial, et piège à 0 jeton). `plan-standard``plan-pro`, avec repointage SQL des `Instances` **avant** la suppression : EF avait scaffoldé les `DeleteData` en tête, ce qui aurait buté sur la clé étrangère ou laissé des instances sans plan, donc à quotas nuls.
⚠️ **Sémantique du `0`, asymétrique et volontaire** : pour le stockage `0` = illimité, pour les jetons `0` = pas d'IA. D'où la sentinelle `long.MaxValue` sur Enterprise — un `0` l'aurait privé d'assistant. Ne pas « harmoniser » sans repasser dans `CheckQuota`, `AiController` et `SectionIndexingInterceptor`.
⚠️ **Le plan ne porte que 5 champs.** « App native », « offline + beacons », « push », « traduction automatique » sont dans la grille commerciale mais **pas dans la table** : ils se règlent par instance. La base ne les applique pas, l'affectation le fait.
**2. Add-on IA sur Essentiel.** L'IA n'est pas incluse dans Essentiel ni Pro, mais se vend en add-on activé à la main après paiement. **Aucun code à écrire** : `ApplyPlanQuotas` ne recopie les quotas du plan que si le plan change, donc surcharger `Instance.AiTokensPerMonth` survit — le commentaire de la méthode le prévoit (« un client peut recevoir un geste commercial sans changer de plan »).
> ⚠️ **Piège à connaître** : le rattrapage d'indexation (`BackfillInstanceAsync`) est déclenché par `UpdateInstance`, en C#. **Activer l'add-on directement en SQL ne le déclenche pas** — le client paierait un guide qui ne connaît rien de son contenu déjà saisi. Après l'UPDATE, cliquer le bouton de relance SuperAdmin de l'écran Guide IA.
**3. Affectation des 4 clients existants** — pour la bascule, aucun client Essentiel :
| Instance Mongo | Plan | Conséquence |
|---|---|---|
| MyInfoMate instance | `plan-premium` | IA active |
| VisitNamur instance | `plan-premium` | IA active |
| MDLF instance | `plan-pro` | ⚠️ **pas d'IA** |
| Fort Saint-Héribert instance | `plan-pro` | ⚠️ **pas d'IA** |
> ⚠️ **À 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.
| 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** |
**Stratégie de bascule — coexistence sur deux serveurs (décidée le 2026-08-12).**
Plutôt qu'une bascule sèche avec fenêtre de maintenance : **un second serveur** porte la nouvelle prod Postgres, les **nouvelles versions des apps** pointent vers lui, et l'ancienne prod Mongo continue de servir les apps déjà installées. Pas de bascule DNS, pas de fenêtre, et un retour arrière qui consiste à ne rien faire.
C'est la bonne réponse au problème que **L19** pose et qu'aucune manœuvre technique ne résout : on ne force pas la mise à jour du téléphone d'un visiteur.
**Ce qui rend la coexistence saine — une seule source d'écriture :**
Le contenu n'est écrit que par le back-office. Dès que `manager-app` pointe sur la nouvelle prod, **l'ancienne devient figée**, en lecture seule de fait. Les vieilles apps voient un contenu gelé au jour J, ce qui est acceptable quelques semaines — et surtout, **les deux bases ne divergent jamais**. Si les deux restaient écrivables, il n'y aurait plus de source de vérité au bout d'une journée.
⚠️ **Ce qui ne marche pas : « recopier de la nouvelle prod vers l'ancienne ».** Il n'existe pas de chemin Postgres → Mongo. `MigrationController` va dans un seul sens, et écrire l'inverse coûterait le même travail — pour alimenter une base qu'on éteint. **L'ancienne prod se fige, elle ne se synchronise pas.** Si un contenu doit absolument arriver sur les vieilles apps, il se saisit à la main des deux côtés, ou on attend le renouvellement du parc.
**Conséquences à tenir :**
- **Le domaine** : les anciennes apps gardent `api.mymuseum.be`, les nouvelles reçoivent une nouvelle adresse. Aucun DNS ne bouge — c'est ce qui rend le rollback trivial.
- **Coût** : un second serveur OVH le temps de la transition. À arbitrer contre le risque d'une bascule sèche ; à ce stade du projet, ça les vaut.
- **Extinction** : décider d'une date de fin de l'ancienne prod, sinon elle survit trois ans. Critère proposé : quand le contenu gelé devient gênant pour un client, ou quand le parc s'est renouvelé.
- **Les tablettes kiosk ne sont pas concernées par l'attente** : ce sont des appareils gérés, mis à jour directement — elles passent sur la nouvelle prod le jour même.
- **I5 (plan de retour arrière) est à réécrire** : avec deux serveurs, le rollback n'est plus une restauration de dump mais « republier l'app qui pointe vers l'ancienne adresse ». Plus simple, mais tributaire des délais de validation des stores — ce qui devient le vrai risque à couvrir.
### Après la prod
Reprendre contact avec **Louise Smets — Musée d'Ixelles**, une fois la prod stabilisée. Premier temps d'écoute ; rien n'est su de son rôle exact.
---
## 3. Chemin critique et parallélisation
```
A (½-1j) ──► B (2-3j) ──► G (3-4j) ──► H (1 sem.) ──► I (2-3j + fenêtre)
│ │
│ └──► C1 ──► C2 ──► C3
│ C4 (C5 ✅)
├──► K1 ✅ ──► K5 ✅ ──► D0 (test device) ◄── débloqué ──► D1..D5
│ └──► K2 ──► K3, K4 ──► K6 (publier) ──► bascule (L19)
├──► D-bis : DB0 ──► DB1 ──┬──► DB2
│ ├──► DB3 ──► F (job de thèmes)
│ └──► DB4 ✅ (A tranchée et livrée)
├──► E
└──► F
J (RGPD) ──► I0 (secrets) ──► I3 ──► reste de I
```
- **Chemin critique : A → B → G → H → I.** Tout le reste s'y greffe.
- **Le lot K s'y greffe par la fin** : K2/K3/K4 sont parallélisables avec C, D, E et F, mais **K6 (publier les apps) précède la bascule** (**L19**). ~~Et **K5 précède D0**~~**K5 est livré le 2026-08-12, D0 n'est plus bloqué** : l'APK `mymuseum-visitapp` existe pour les trois flavors, le test §21 sur device peut être joué. C'est aujourd'hui **le prochain geste qui demande un appareil**, et le seul qui dise si les bugs offline D2-D5 se manifestent réellement (**L11**).
- ⚠️ **Une question à trancher avant K6, qui n'est pas technique** : le travail V1 des deux apps visiteur est committé sur des branches qui ne sont pas `master``Meta-Rayban-Test` pour `mymuseum-visitapp`, `AI-Assistant-test` pour `tablet-app`. Le §5bis décrit pourtant la première comme « un POC jamais mergé ». Publier des APK depuis une branche décrite comme un POC n'est pas neutre : clarifier avant K6, pas pendant.
- **C, D, D-bis, E, F sont parallélisables** entre eux, mais **C1 doit précéder G** (L5) et **B doit précéder G** (L2).
- **DB3 doit précéder le job de thèmes du lot F** (L16) : c'est le seul endroit du plan où le design commande le backend.
- **La boucle du graphe a disparu le 2026-08-11.** Elle venait de : I3 dépend de la rotation des secrets (dans le lot A), et H4 dépend de I3 (L12). En déplaçant la rotation en **I0**, la contrainte devient linéaire — `I0 → I3 → H4` — et il n'y a plus de cycle à contourner. **Contrepartie à ne pas rater** : H4 (test d'onboarding) exige `app.myinfomate.be` déployé, donc I3, donc I0. Concrètement, **la rotation des secrets doit être faite avant la fin du lot H**, pas au tout dernier moment. C'est le seul endroit où le report du 11/08 crée une échéance interne.
**⚠️ Révision du 2026-08-12 : +3 à 5 jours pour le lot K**, découvert en lançant le premier vrai build de `tablet-app`. Ce n'est pas du périmètre ajouté, c'est du travail qui était déjà dû et que personne n'avait vu : sans lui, la bascule dégrade les clients existants.
**Ordre de grandeur : 5 à 6 semaines**, dont ~1 semaine de tests, 3-4 jours sur `MigrationController` seul, 1,5 à 2 semaines sur le lot design et 2-3 jours sur le lot J (RGPD). **Les trois arbitrages structurants sont tranchés depuis le 2026-08-11**`ParcoursIds` et `QuestionType` au lot B, le flux SectionParcours au lot D-bis : le plan n'attend plus de décision, seulement du développement.
---
## 4. Arbitrages en attente
| Sujet | Options | Bloque |
|---|---|---|
| ~~`SectionEvent.ParcoursIds`~~ | ✅ **2026-08-11 — supprimer.** Champ mort, et structurellement incapable du cas jour/bloc | — |
| ~~`QuestionType`~~ | ✅ **2026-08-11 — ne pas toucher au schéma.** Sort du lot B ; reste une propreté front au lot E | — |
| ~~**Fournisseur de carte en web (W1)**~~ | ✅ **2026-08-11 — option a : le web reste sur Leaflet, quoi que le client configure.** Ni MapBox ni Google Maps JS : pas de coût récurrent ni de seconde librairie carto avant la prod. **Correction apportée à l'option** : le champ ne peut pas être retiré pour le web, il est porté par la section et partagé entre plateformes — il est donc conservé et **documenté dans l'interface** comme réglage mobile/tablette. Voir le lot E | — |
| ~~Flux de configuration SectionParcours~~ | ✅ **2026-08-11 — A, et livrée.** Fenêtre unique avec rail d'étapes, profondeur 2. B écartée : son seul gain réel était une colonne d'aperçu visiteur, déjà couverte par le §5 du Guide IA (**L18**). Le travail fait pour A reste transposable — le widget à panneaux ne dépend pas du `Dialog` qui l'entoure | — |
| CGU §5 face aux contrats déjà signés | Vérifier ce que les clients existants ont signé avant de considérer le sujet clos | Lot F (volet RGPD dans le même document) |
---
## 4bis. Reprendre ce plan dans une conversation neuve
**Ordre de lecture** — trois fichiers suffisent, dans cet ordre :
1. **Ce fichier** — les 9 lots, les 18 liens, le chemin critique. C'est le seul document qui dit *dans quel ordre*.
2. **[STATUS.md](STATUS.md) §1sexies** — le même découpage, relié au reste de l'état du projet.
3. **[kanban.html](kanban.html)** — où en est chaque chantier, carte par carte.
Les plans détaillés de `v2/` ne se lisent qu'au moment d'attaquer le lot concerné.
**Paralléliser sur plusieurs conversations — règle apprise le 2026-08-12.** Les lots se répartissent bien par repo, mais **chaque session ne doit committer que son repo**. Deux sessions ont débordé le même jour : l'une a committé `DOCS/` avec son travail `manager-app`, l'autre a emporté trois fichiers `manager-service` d'un chantier voisin dans son commit. Rien n'a été perdu, mais un `git add -A` en parallèle finit par emporter du travail à moitié fini. À écrire dans le prompt de chaque session : *« ne touche rien hors de `<repo>/`, et ne commit que celui-là »*.
**Amorce à copier telle quelle :**
> *« Lis `DOCS/v1-plan.md` en entier, puis `DOCS/STATUS.md` §1sexies. Le plan découpe la V1 en 9 lots (A à I plus D-bis) avec leurs dépendances ; le chemin critique est A → B → G → H → I. Dis-moi où en est chaque lot d'après les documents, puis attaque le lot que je te désigne. Ne relance jamais la génération de `manager_api_new` : il s'édite à la main. Vérifie l'état réel dans le code avant de croire un document — plusieurs points de STATUS.md se sont révélés périmés. »*
**Trois pièges vérifiés en vrai, à ne pas re-découvrir :**
- **`flutter analyze` global est inexploitable** (68 erreurs de fichiers modèle orphelins, hors graphe de compilation). Filtrer sur le dossier travaillé. Seuls `flutter build web`, `dotnet build`, `dotnet test` disent la vérité. **Cause exacte établie le 2026-08-11** : 139 `part` déclarés dans `api.dart` pour 153 fichiers dans `lib/model/` — cf. lot A. Le nettoyage du lot A supprime ce bruit.
- **La base locale peut avoir une migration de retard**, ce qui fait planter l'API dès le login sans que le compilateur ne dise rien.
- **Le fichier du kanban et la version publiée peuvent avoir divergé** — toujours `WebFetch` l'artifact et fusionner avant de republier, jamais `force`.
**Le prérequis « committer avant tout » est levé.** L'alerte de ce matin (`manager-service` 44 fichiers dont 9 non suivis, `manager-app` 12) est **traitée** : revérifié le 2026-08-11, les **9 repos** (DOCS compris) sont propres et synchronisés avec leur remote. Rien n'est plus en attente sur un seul disque. Ne pas re-planifier cette étape.
---
## 5. Ce qui reste hors V1 — ne pas y toucher
Reporté en V2 et confirmé ici : SectionForm, ressource 360°, AR image tracking (Mind AR), kiosk web, ingestion documentaire + OCR, TTS pré-généré, guides multiples et thématiques, talking head, génération de visites, envoi mensuel automatique du rapport de stats, XR (Ray-Ban / Quest), dashboard super-admin V2, facturation V2.
**Ajouté le 2026-08-12 : l'écran SuperAdmin d'activation de l'add-on IA et des canaux d'une instance.** Il rendrait l'add-on vendable en libre-service, mais la V1 n'en a pas besoin : les 4 clients de la bascule sont affectés à leur plan (§lot I, point 3), et doter une instance à la main reste un geste interne, fait une fois, sur une base qu'on contrôle. Le mode d'emploi SQL est dans **STATUS.md §1sexies « Add-on IA »** — trois gestes, dont le bouton de relance d'indexation qu'un `UPDATE` seul ne déclenche pas. À faire d'un bloc avec l'endpoint de mise à jour d'`ApplicationInstance` (canaux activables), qui reste lui au lot F : c'est le même écran, mais seule la partie canaux est V1.
Le POC Ray-Ban existe et est démontrable (branche `Meta-Rayban-Test`, jamais mergée) — c'est un actif commercial, pas un chantier V1. Ne pas vendre l'add-on avant la bascule TTS ElevenLabs → Gemini.