DOCS/todo-features.md
Thomas Fransolet d6501252a8 Docs du 12/08 : lot K découvert, lot F livré côté manager-app
Deux chantiers menés en parallèle, consignés dans la même passe parce que
l'index de ce repo est partagé et que leurs modifications s'entrelacent
dans STATUS.md, v1-plan.md et kanban.html.

Lot K — les apps visiteur parlent l'ancien contrat. Découvert en lançant le
premier vrai flutter build apk sur tablet-app depuis des mois, la commande
que trois documents disaient « à reconfirmer ». Le diagnostic « un seul
défaut, purement Gradle, peut-être réglé par 5a6701d » était doublement
faux : six crans d'outillage Android (Gradle 7.5 → 8.11.1, AGP 7.2.0 →
8.9.1, Kotlin 2.0.10, option AGP supprimée, jcenter mort), corrigés le
12/08, puis 132 erreurs Dart que le Gradle masquait. Elles tiennent en trois
causes — les cinq champs de ConfigurationDTO migrés vers
AppConfigurationLinkDTO, GeoPointDTO.latitude/longitude devenus
GeometryDTO geometry avec PostGIS, et les min() sur dynamic qui en
découlent. mymuseum-visitapp a déjà fait ce portage : c'est une copie, pas
une conception. Bloquant de la bascule (lien L19) : les tablettes en service
chez MDLF et au Fort ne s'arrêteraient pas proprement le jour J, elles
dégraderaient sans erreur visible. +3 à 5 jours de travail déjà dû.

Périmètre kiosk tranché : SectionParcours exclu définitivement d'une borne
fixe, SectionEvent à supporter. Leçon générale ajoutée au §1bis — un build
jamais lancé ne vaut pas mieux qu'un build vert supposé, et « à reconfirmer
par un vrai build » veut dire que ce n'est pas confirmé.

Lot F — le volet manager-app est livré. Écran d'audit log sur GET /api/Audit
réservé au SuperAdmin, et compteur « X / 5 utilisateurs » avec bouton d'ajout
désactivé au plafond. Deux dettes serveur ouvertes par ce chantier et
laissées ouvertes à dessein, le périmètre étant manager-app seul : le
plafond de 5 n'est appliqué nulle part côté serveur — ce qui est livré est
un garde-fou d'interface, un POST direct sur l'API passe toujours — et
aucune section n'est journalisée, AuditedTypes.Contains exigeant l'égalité
exacte de type alors que Section est abstraite, si bien que le journal
couvre tout sauf le contenu, précisément ce que l'écran devait tracer.
Chacune a sa carte au kanban et son entrée dans todo-features.md.

Compteurs du kanban recomptés : Urgent 4, Migration v3 3, Bugs 5, À tester 6,
Planifié 20, Bascule prod 7, Fait récemment 36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 11:04:49 +02:00

1146 lines
92 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

# TODO — Features manquantes / à implémenter
> Légende : ❌ Non implémenté · ⚠️ Partiel · ✅ Fait
>
> **Révision du 2026-08-05** : ce fichier a été confronté au code réel. Les écarts entre ce qui est configurable dans `manager-app` et ce qui est réellement rendu dans `mymuseum-visitapp` / `visitapp-web` sont consolidés dans **[parity-manager-visitapp.md](parity-manager-visitapp.md)** — ne pas dupliquer ici, cette section renvoie vers le rapport. Les statuts corrigés lors de cette révision sont marqués `(révisé 2026-08-05)`.
---
## ⚠️ Bloquants déploiement (mesurés le 2026-08-05)
- [ ]**`visitapp-web` ne build pas en production** — `npm run build``Failed to type check`, 7 erreurs TS sur l'indexation de `geometry.coordinates` (typé `unknown`, `lib/api/types.ts:191`) : `components/sections/map/LeafletMap.tsx` (5 erreurs) + `components/sections/MapSection.tsx` (2). Invisible en `next dev` car Turbopack ne type-checke pas. Détail + fix suggéré : [parity-manager-visitapp.md §6](parity-manager-visitapp.md)
- [x]**Suite de tests backend réparée et étendue** (2026-08-06) — **124/124**. Fakes remis à niveau (`FakeEmailService`, `FakeConfiguration`, `IHttpContextAccessor` dans `DbContextFactory`), utilisateur de test ajouté là où les contrôleurs ont gagné un contrôle d'autorisation (`StatsController`, `AiController`), tests passés en `async`, test obsolète `CreateUser_NullPassword_Returns400` réécrit (le comportement est devenu une invitation par email). Nouveau fichier `SectionParcoursControllerTests.cs` (8 tests sur les règles de progression) + `manager-app/test/progression_mode_test.dart` (12 tests sur le mapping des 3 questions).
- ⚠️ **Bug backend découvert au passage et corrigé** : `MyInfoMateDbContext.BuildAuditEntries()` appelait `AuditLogs.Add()` **à l'intérieur** de la boucle sur `ChangeTracker.Entries()``InvalidOperationException: Collection was modified` sur toute écriture d'entité auditée. 41 tests le reproduisaient. Personne ne l'avait vu parce que la suite ne compilait plus.
- [ ]**Client généré `manager_api_new/` désynchronisé** — dont `model/game_types.dart:28` qui contient encore `Escape = 2` alors que le backend ne l'expose plus. Cf. `security/audit-manager-app.md` (point Critique)
---
## mymuseum-visitapp
### SectionAgenda
- [x]**Synchro Hangfire des events depuis la ressource externe (agenda.php)**
- `AgendaSyncService` — recurring job Hangfire 1×/jour (`agenda-sync-daily`)
- Upsert avec `IsSynced = true`, match par `DateFrom.Date`, translations par langue
- Déclenché aussi sur modification d'une `SectionAgenda` (`isOnlineAgenda = true`)
- Events manuels (`IsSynced = false`) coexistent avec les synchros
- [x]**Visitapp + Tablet-app — passer à l'endpoint backend au lieu de l'URL externe**
- Endpoint `GET /api/SectionAgenda/{id}/events/upcoming` — filtre `dateFrom >= DateTime.Today`
- Visitapp et tablet-app : `sectionAgendaApi.sectionAgendaGetUpcomingEvents()` via `fromDto()`
- `SectionAgendaApi` ajouté au `Client` des deux apps
- [x]**SectionAgenda ne montre pas les events du jour même**
- Corrigé côté backend : filtre `dateFrom >= DateTime.Today` (début du jour, pas `DateTime.Now`)
- [x]**EventAgenda — infos complètes + vidéo dans le popup**
- Champs ajoutés : `IsSynced`, `IdVideoYoutube`, `VideoLink`, `VideoResourceId` / `VideoResource`
- `fromDto()` factory dans les deux apps (visitapp + tablet-app)
- Popup : YouTube, lien vidéo direct, vidéo ressource interne — tous affichés via `VideoViewer`
- Manager-app : pickers image + vidéo ressource, champs YouTube ID + lien direct dans le formulaire
### SectionWeather
- [x]**Hang service Hangfire pour la mise à jour météo (2×/jour)**
- `WeatherSyncService` — recurring jobs Hangfire à **6h00** (`weather-sync-morning`) et **13h00** (`weather-sync-afternoon`)
- Logique extraite du bloc inline de `SectionController.GetFromConfigurationDetail` (supprimé)
- `SyncAllAsync()` boucle toutes les `SectionWeather` avec une ville non-vide
### Sections manquantes
- [x]**SectionEvent — page dédiée**
- Aucun `case SectionType.Event` dans `section_page.dart`
- `SectionEventDTO` porte : `startDate`, `endDate`, `baseSectionMapId`, `globalMapAnnotations`, `programme` (List\<ProgrammeBlock\>), `parcoursIds`
- `ProgrammeBlock` : `id`, `title`, `description`, `startTime`, `endTime`, `mapAnnotations`
- Endpoints API côté SectionEvent : `sectionEventGetProgrammes`, `sectionEventGetGlobalMapAnnotations`, `sectionEventGetProgrammeBlockMapAnnotations`
- **Structure prévue** :
- Header : titre (`sectionEventDTO.title`), dates `startDate…endDate`, image de fond (`imageSource`)
- TabBar 3 onglets :
- **Programme** — timeline verticale des `ProgrammeBlock`, bloc actif mis en évidence (heure courante dans `startTime…endTime`), tap → BottomSheet détail + annotations du bloc
- **Carte** — `FlutterMap` (même pattern que `guided_path_map_progression_page.dart`) chargé depuis `baseSectionMapId`, couche annotations globales + couche annotations du bloc actif (couleur différente)
- **Parcours** — liste des `GuidedPath` liés au SectionEvent, tap → `GuidedPathMapProgressionPage` existant
- Bouton retour cerclé `kMainColor` en `Positioned(top: 35, left: 10)` (convention app)
- **Note traductions** : titre/description viennent directement de `sectionEventDTO.title` (List\<TranslationDTO\>) — utiliser `TranslationHelper.get(sectionEventDTO.title, visitAppContext)`, pas de clé locale
- [x]**SectionEvent — mise en avant sur le home screen**
- Si `applicationInstanceDTO.sectionEventId != null`, afficher un bloc "à la une" en haut de `home_3.0.dart` à la place de la photo principale
- Contenu : image de l'event (`imageSource`), titre, bouton générique pour ouvrir le `SectionDetail` du SectionEvent
- Le `sectionEventId` est sur `AppConfigurationLinkApplicationInstance` (nested dans `applicationInstanceDTO.configurations[].applicationInstance.sectionEventId`)
- Tap → `SectionPage` avec le `SectionEventDTO` correspondant
### Game — Escape mode
- [x]**Escape game — liste des parcours dans `game_page.dart`** — ⚠️ **obsolète (révisé 2026-08-05)** : `GameTypes.Escape` a été retiré du backend (`enum GameTypes { Puzzle, SlidingPuzzle }`) par le refacto SectionParcours. `game_page.dart` ne teste plus que `Puzzle` / `SlidingPuzzle` (lignes 136, 279, 491) et l'escape game passe désormais par **SectionParcours** (`IsGameMode` sur `GuidedPath`). Cette entrée est conservée pour l'historique — le comportement décrit ci-dessous n'existe plus.
- Reliquat à nettoyer : le client généré `manager-app/manager_api_new/lib/model/game_types.dart:28` contient encore `Escape = 2`
### Game — Escape mode (nice to have / futures mécaniques)
> Inspiré de Genially et de l'écosystème escape game pédagogique. À intégrer progressivement dans le CMS manager-app + visitapp/tablet-app.
>
> **Ce qui existe déjà dans le code :**
> - Validation par quiz QCM (Simple/MultipleChoice) sur chaque `GuidedStep`
> - Puzzle jigsaw draggable (`GameTypes.Puzzle`) et puzzle sliding (`GameTypes.SlidingPuzzle`) comme types de jeu global — mais pas comme mécanisme de validation d'une étape
> - Timer par étape (`isStepTimer` + `timerSeconds`) avec couleur dégradée vert/orange/rouge
> - Géolocalisation par étape (`geometry` + `zoneRadiusMeters`)
> - `isStepLocked` / `isHiddenInitially` comme flags booléens simples
#### Types de cadenas / systèmes de validation (manquants)
- [x]**Digicode** — fait le 2026-07-16, sans changement backend/modèle : `QuestionType.Simple` reste le type stocké, mais si la réponse attendue est purement numérique, le front affiche un pavé de boutons 0-9 (+ backspace) façon cadenas physique au lieu du champ texte. `mymuseum-visitapp/lib/Screens/Sections/GuidedPath/guided_step_challenge.dart` (`_isDigicode` + `_DigicodeField`), `visitapp-web/src/components/sections/ParcoursSection.tsx` (`isDigicodeAnswer` + `DigicodePad`). Manager-app : rien à faire, la détection est automatique côté visiteur selon le contenu de la réponse saisie.
- [x]**Mot de passe texte libre** — déjà fait avant le 2026-07-16 (contrairement à ce que disait cette section) : `QuestionType.Simple` = texte libre, comparaison insensible à la casse **et aux accents** (`_normalize()` / `normalizeAnswer()`, mêmes fonctions dans les deux apps). Vérifié 2026-07-16. Le nom "Simple" dans le code/l'UI manager-app est trompeur ("Simple (texte attendu)") mais c'est bien du texte libre, pas un QCM.
- [ ]**Directionnel** — séquence de directions (↑↓←→) à saisir dans l'ordre. Inspiré des cadenas directionnels physiques.
- [ ]**Séquence colorée** — appuyer sur des cases colorées dans le bon ordre.
- [ ]**Drag & drop positionnel par étape** — repositionner des objets dans les bons emplacements pour valider l'étape (différent du puzzle jigsaw global qui est un jeu à part entière).
#### Types de puzzles / énigmes (manquants)
- [ ]**Puzzle jigsaw / sliding comme validation d'étape** — actuellement ces puzzles sont des `GameTypes` globaux ; les intégrer comme `QuestionType` dans un `GuidedStep` pour qu'un puzzle complété = étape validée.
- [ ]**Objets cachés** — une image dans laquelle le visiteur doit trouver et taper des zones précises (hotspots invisibles dans le décor). Variante : "Où est Charlie ?".
- [ ]**Spot the difference** — deux images côte à côte, le visiteur identifie les différences.
- [ ]**Memory / matching** — retourner des paires de cartes. Révèle un code une fois toutes les paires trouvées.
#### Mécanique de progression avancée (manquant)
- [ ]**Variables d'état globales** — actuellement la progression est purement linéaire (étape N → N+1 dans un `GuidedPath`). Des variables permettraient : "l'indice C n'apparaît que si les étapes A et B sont complétées", ou des branches non-linéaires dans le parcours.
- [ ]**Visibilité conditionnelle d'éléments dans une étape** — un objet / indice / cadenas dans le contenu d'une étape n'apparaît que si une condition préalable est remplie. Aujourd'hui `isHiddenInitially` est un booléen fixe, pas conditionnel.
- [ ]**Compte à rebours global** — timer sur toute la durée de l'escape (différent du `isStepTimer` par étape déjà implémenté). S'affiche en permanence en haut de `GuidedPathContentProgressionPage`.
#### Immersion narrative (manquant)
- [ ]**Mode "prop" / faux document** — affichage plein écran immersif d'une ressource image (journal de bord, lettre, carte, billet). Différent d'une image classique dans l'étape : overlay sans chrome, avec animation d'ouverture.
---
### Game — Puzzle sliding
- [x]**Puzzle sliding — UI visuelle**
- `game_page.dart` gère désormais `GameTypes.SlidingPuzzle`
- Composant `SlidingPuzzlePiece` avec coins arrondis, ombres et animation de glissement
- Algorithme de mélange solvable + aide visuelle (numéros sur les tuiles)
- Backend + manager-app : ✅ complets
### Refactoring majeur — SectionParcours + Questions unifiées
> **Contexte de la décision**
> Après analyse UX et revue de la DB, on a décidé de refactoriser l'architecture des parcours pour qu'elle soit cohérente du point de vue du client qui configure son contenu. La décision centrale : tout ce qui est "guider des visiteurs" passe par un seul type de section — **SectionParcours**. Le type Escape est retiré de SectionGame. Les types de questions sont unifiés entre SectionQuiz et GuidedStep.
---
#### Vision UX finale — 4 types de sections pour le client
| Section | Ce que le client pense | Carte | Questions |
|---|---|---|---|
| **SectionMap** | "Je crée une carte interactive de mon lieu" | Toujours | Non |
| **SectionParcours** | "Je guide mes visiteurs" | Optionnelle (`ShowMap`) | Optionnelles |
| **SectionGame** | "Je crée un puzzle image" (Puzzle / SlidingPuzzle uniquement) | Non | Non |
| **SectionEvent** | "Je crée un événement temporel" | Optionnelle | Non |
**SectionParcours couvre tous les cas de guidage :**
- Visite de façades sans question (ShowMap=true, isLinear=true, pas de questions)
- Parcours thématique libre (ShowMap=true, isLinear=false)
- Parcours pédagogique / quiz (ShowMap=true ou false + questions QCM/TextLibre)
- Escape game indoor (ShowMap=false, IsGameMode=true)
- Chasse au trésor / escape outdoor (ShowMap=true, IsGameMode=true, BaseSectionMapId)
**La distinction SectionParcours vs SectionGame :** un jeu = mécanique de jeu (puzzle image à reconstituer). Un parcours = séquence d'étapes avec contenu, même si elles ont des énigmes et une notion de victoire.
---
#### 1. Nouveau modèle `SectionParcours` (backend)
- [x]**Créer le modèle `SectionParcours : Section`** (vérifié 2026-07-15)
- `Data/SubSection/SectionParcours.cs` : `ShowMap`, `BaseSectionMapId`, `GuidedPaths`
- Migration `20260612145724_AddSectionParcours`, contrôleur dédié `Controllers/SectionParcoursController.cs`
- Discriminateur TPH ajouté : `MyInfoMateDbContext.cs:213` `.HasValue<SectionParcours>("Parcours")`
- ⚠️ Écart vs plan initial : `IsGameMode`/`GameMessageDebut`/`GameMessageFin` ont finalement été mis sur `GuidedPath` (pas sur `SectionParcours`) — voir point 2, choix de design assumé (jeu par parcours, pas par section)
---
#### 2. Nettoyage `GuidedPath` (backend)
- [x]**Modifier le modèle `GuidedPath`** (vérifié 2026-07-15)
| Champ | Statut réel |
|---|---|
| `SectionMapId` | ✅ Supprimé |
| `SectionGameId` | ✅ Supprimé |
| `SectionEventId` | ✅ Gardé |
| `SectionParcoursId` | ✅ Ajouté (`GuidedPath.cs:38-40`) |
| `ImageResourceId` / `ImageUrl` (computed) | ✅ Ajoutés (`GuidedPath.cs:49`, `imageUrl = ImageResource?.Url` l.81) |
| `ShowMap` | ✅ Resté sur `SectionParcours`, absent de `GuidedPath` |
- Migrations `20260708143339_RemoveGuidedPathSectionMapRelation`, `20260629150749_MoveGameModeToGuidedPath`
---
#### 3. Nettoyage `GuidedStep` (backend)
- [ ]**Corriger `GuidedStep.ImageUrl`** — toujours pas fait (vérifié 2026-07-15)
- `GuidedStep.cs:39` : `ImageUrl` reste un `string` stocké brut, pas de `ImageResourceId`
- Remplacer par `ImageResourceId` (FK Resource) + `ImageUrl` (computed depuis la ressource)
- Cohérent avec toutes les autres sections du projet
- Migration : `FixGuidedStepImageToResource`
- [x]**Exposer `ZoneRadiusMeters` dans le manager-app** (vérifié 2026-07-15)
- `manager-app/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart` — champ présent (`GuidedStep.cs:37` côté backend)
---
#### 4. Refactoring `SectionGame` (backend + manager-app)
- [x]**Retirer `GameTypes.Escape` de SectionGame** (vérifié 2026-07-15)
- `SectionGame.cs:68-71` : `enum GameTypes { Puzzle, SlidingPuzzle }``Escape` bien retiré
- Aucun onglet "Escape Game" résiduel trouvé dans `game_config.dart`
- ⚠️ Pas vérifié en détail : que `GameMessageDebut`/`GameMessageFin` soient bien visibles pour Puzzle dans l'UI manager-app
---
#### 5. Unification des types de questions (`QuizQuestion`)
> **Correction 2026-07-16** : ce plan (nouvel enum + champ `ExpectedAnswer`) était basé sur une prémisse fausse. Vérification du code réel :
> - `QuestionType.Simple` (valeur 0) **est déjà** du texte libre, pas un "QCM une réponse" — la réponse attendue est stockée dans `Responses[0].label` (réutilise le champ existant, multilingue via `TranslationAndResourceDTO`), comparaison insensible casse/accents (`_normalize()` / `normalizeAnswer()`). Fait et vérifié dans les deux apps visiteur.
> - **Digicode** : fait le 2026-07-16 en pur front (pavé 0-9 auto-détecté si la réponse est numérique), sans toucher à l'enum ni ajouter `ExpectedAnswer` — voir section "Types de cadenas" plus haut.
> - Donc **pas besoin** d'étendre `QuestionType` ni d'ajouter `ExpectedAnswer` : `Responses[0].label` fait déjà le travail, et il a l'avantage d'être traduisible.
> - Le vrai gap restant (voir ❌ ci-dessous) : `showNewOrUpdateQuizQuestion.dart` (l'éditeur riche Simple/MultipleChoice/Puzzle) **n'est branché que sur les `GuidedStep` d'un Parcours** — une `SectionQuiz` classique (quiz autonome, pas dans un parcours) utilise un éditeur différent et plus ancien (`new_update_question_quizz.dart`, modèle `QuestionDTO`) qui ne fait que du QCM, sans texte libre/puzzle/digicode.
- [ ]**Unifier `SectionQuiz` sur le modèle `QuizQuestion`** (au lieu du legacy `QuestionDTO`)
- Permettrait texte libre/digicode/puzzle dans un quiz classique, pas seulement dans un Parcours
- Chantier plus lourd que prévu initialement : migration des données existantes (`QuestionDTO``QuizQuestion`), réécriture de `quizz_config.dart`/`new_update_question_quizz.dart` pour utiliser `showNewOrUpdateQuizQuestion.dart`, vérification du rendu dans les 2 apps visiteur pour le contexte SectionQuiz (actuellement seul le contexte GuidedStep est vérifié)
- **Pas fait**, priorité à discuter — le cas d'usage escape game/chasse au trésor est déjà couvert via Parcours, ce chantier n'est utile que si on veut les mêmes types de défi dans un quiz "simple"
---
#### 6. Nettoyage `SectionEvent` (backend)
- [ ]**Supprimer `ParcoursIds` de `SectionEvent`** — toujours pas fait (vérifié 2026-07-15 : `SectionEvent.cs:26` toujours présent)
- Champ `List<string> ParcoursIds` documenté comme inutilisé (remplacé par `GuidedPath.SectionEventId`)
- Migration : `RemoveParcoursIdsFromSectionEvent`
---
#### 7. Vérification `SectionMap` — champs potentiellement legacy
- [ ] ⚠️ **Vérifier `MapResourceId` / `MapResource`**
- `MapMapType`, `MapTypeMapbox`, `MapMapProvider` : **garder** — potentiellement utiles si on veut supporter d'autres providers de tuiles à l'avenir
- `MapResourceId` / `MapResource` — rôle flou (icône de carte ?), vérifier l'usage réel dans les apps avant de décider
---
#### 8. Manager-app — nouvelle section SectionParcours
> ✅ **Statut réel vérifié le 2026-08-05, structure mise à jour le 2026-08-11 (DB4)** : deux dossiers coexistent — `SubSection/SectionParcours/section_parcours_config.dart` (la section elle-même) et `SubSection/Parcours/` (`parcours_config.dart`, `guided_path_editor.dart`, `guided_path_api.dart`, `progression_mode.dart` et `Fields/` : `parcours_fields.dart`, `etape_fields.dart`, `question_fields.dart`). Les trois `showNewOrUpdate…` empilés ont été supprimés. Tout est branché sauf les tooltips.
- [x]**Ajouter "Parcours" dans la liste des types de section** (vérifié 2026-08-05)
- `SectionParcours/section_parcours_config.dart` :
- Toggle **"Afficher la carte"** (`ShowMap`) — `l.63`, défaut `true`
- Picker **"Carte de référence"** (`BaseSectionMapId`) — `l.70-90`, affiché si `showMap == true` et qu'au moins une SectionMap existe
- Toggle **"Mode jeu"** (`IsGameMode`) + `GameMessageDebut`/`GameMessageFin` : sur le **GuidedPath**, pas sur la section (`Fields/parcours_fields.dart`) — écart assumé documenté au point 1
- Gestion des GuidedPaths : `parcours_config.dart``guided_path_editor.dart`
- ❌ Reste : les **tooltips ** (1 seul `Tooltip` dans tout le dossier Parcours, sur la case « bonne réponse » de `Fields/question_fields.dart`) — voir section dédiée ci-dessous
- [ ]**Tooltips dans le manager-app**
- Sur le choix du type de section (SectionMap vs SectionParcours vs SectionGame vs SectionEvent) : exemples concrets
- Sur `ShowMap` : "Activez si votre parcours se déroule en extérieur ou si vous voulez montrer la position des étapes"
- Sur `IsGameMode` : "Activez pour un escape game ou une chasse au trésor — ajoute un écran d'introduction et un message de victoire"
- Sur `BaseSectionMapId` : "Associez une carte existante pour afficher ses points comme repères visuels pendant le parcours"
- Sur `IsGeoTriggered` : "Le visiteur doit se trouver dans la zone pour débloquer l'étape"
- Sur `ZoneRadiusMeters` : "Distance en mètres à partir de laquelle l'étape se déclenche (ex: 30m)"
> ⚠️ **Tâche supprimée le 2026-08-05 — "TriggerGeoPointId — picker intuitif"** : le champ `TriggerGeoPointId` n'existe pas dans le modèle `GuidedStep`. Le géodéclenchement d'une étape se configure via le `GeometryInputContainer` (position/zone sur mini-carte, `showNewOrUpdateGuidedStep.dart:128-136`) + toggle `isGeoTriggered` (`l.148`) + `zoneRadiusMeters` (`l.174`). Rien à faire côté picker.
---
#### 9. Visitapp-web + mymuseum-visitapp — SectionParcours
> ⚠️ Statut réel au 2026-07-15 : le dispatcher existe bien dans les deux apps — `mymuseum-visitapp/lib/Screens/Sections/Parcours/parcours_page.dart` (+ dossier `GuidedPath/`) et `visitapp-web/src/components/sections/ParcoursSection.tsx`. Pas revérifié en détail si tous les sous-comportements (ShowMap true/false, IsGameMode, use case carnaval) sont couverts — sous-tâches ci-dessous à revalider.
- [ ] ⚠️ **Nouveau case `'Parcours'` dans le dispatcher de sections** — dispatcher présent, comportement détaillé à revalider
- `SectionParcoursSection` component (visitapp-web) / page (mymuseum-visitapp)
- Si `ShowMap = false` → mode contenu : liste d'étapes avec progression (actuel `EscapeProgression`)
- Si `ShowMap = true` → mode carte : carte + pins des étapes + position live (actuel `GuidedPathMapProgressionPage`)
- Si `BaseSectionMapId` défini → charger les GeoPoints de la SectionMap comme fond de carte
- Si `IsGameMode = true` → afficher `GameMessageDebut` au démarrage, `GameMessageFin` à la fin
- **Escape game géolocalisé sans carte visuelle** (bug noté) → résolu par ShowMap=true + mini-carte dans les étapes
- [ ]**Use case test complet : carnaval / chasse au trésor**
- Créer depuis le manager-app : SectionMap (carte du lieu) + SectionParcours (ShowMap=true, IsGameMode=true, BaseSectionMapId=la carte) avec 3 étapes géolocalisées + questions TextLibre
- Valider le flow visiteur dans visitapp-web et mymuseum-visitapp
- Objectif : simuler exactement ce qu'un client ferait pour son premier parcours
### SectionMap / Parcours
- [x]**Affichage des GuidedPaths sur la carte**
- Bouton "Parcours" dans `map_page.dart` si `mapDTO.guidedPaths?.isNotEmpty`
- `GuidedPathListSheet` — bottom sheet listant les parcours (triés par `order`)
- `GuidedPathMapProgressionPage` — carte plein écran (flutter_map) avec pins d'étapes, position live utilisateur, bottom sheet draggable pour le détail de l'étape courante
- Pins : ✓ complétée · ◉ courante (animation pulsante) · ○ suivante · 🔒 verrouillée
- `hideNextStepsUntilComplete` : pins futurs masqués, badge progression `✓ ✓ ◉ · ·` à la place
- [x]**Vue liste** (`isListViewEnabled`)
- `map_page.dart` : si `mapDTO.isListViewEnabled == true`, bouton toggle "Liste / Carte" en bas à droite
- Vue liste : `ListView` sur les `_geoPoints` filtrés (titre, description, miniature)
- [x]**Annotations carte globales + par bloc programme** (SectionEvent)
- `globalMapAnnotations` affichées dans la preview et dans `EventMapFullPage` (couleur principale)
- Annotations du bloc actif affichées en orange par-dessus, preview et page plein écran
- Fix client généré : `MapAnnotation.geometry` changé de `MapAnnotationGeometry``EventAddressDTOGeometry` pour exposer `.coordinates`
### Notifications push
- [x]**Réception et affichage des notifications push**
- Backend + manager-app : ✅ complets
- Visitapp : `pushNotificationService.dart` complet (foreground local notif, background, terminated)
- Tap → `MaterialBanner` dans `configuration_page.dart`
- Préférences (subscribe/unsubscribe) dans `home_3.0.dart`
- ⚠️ **Action requise** : `google-services.json` (Android) et `GoogleService-Info.plist` (iOS) à télécharger depuis la console Firebase (Project settings → ton app) et à placer dans `android/app/` et `ios/Runner/`
### Parcours guidés — UI de progression
- [x]**Affichage d'un parcours guidé / étape (contexte SectionMap)**
- `GuidedPathMapProgressionPage` : carte + bottom sheet draggable, position live, pins d'étapes
- `GuidedStepTimer` : countdown MM:SS (mode peek) + barre de progression colorée (mode étendu)
- Quiz par étape : `QuestionsListWidget` réutilisé, conversion `QuizQuestion → QuestionSubDTO`
- `requireSuccessToAdvance` : bouton Suivant désactivé si quiz raté
- `isLinear` : navigation séquentielle forcée (pas de bouton Précédent)
- `isStepLocked` : étape grisée, non franchissable
- `isHiddenInitially` : ⚠️ **(révisé 2026-08-05)** aucun consommateur dans aucune des apps visiteur, alors que le toggle **est** exposé dans `showNewOrUpdateGuidedStep.dart:203` → toggle sans effet, à trancher (retirer l'UI ou l'implémenter)
- Géodéclenchement via `geolocator` : stream de position, calcul distance → `zoneRadiusMeters`, indicateur "Approchez-vous (Xm)" / "Vous êtes dans la zone ✓" — ⚠️ **uniquement en mode carte** (voir ci-dessous)
- `GuidedPathContentProgressionPage` : mode contenu (escape game) — ⚠️ **(révisé 2026-08-05)** ce n'est **pas** "la même logique" : cette page n'implémente **ni** le géodéclenchement (aucun `Geolocator`, pas de `_checkGeoZone`), **ni** `isStepLocked`, **ni** `isLinear`, **ni** `hideNextStepsUntilComplete`. Seule `guided_path_map_progression_page.dart` les gère. Même trou côté `visitapp-web` (`ProgressView`, mode sans carte). Voir [parity-manager-visitapp.md](parity-manager-visitapp.md) M7/W7
- [x]**Parcours guidés dans Escape game**
- `GuidedPathContentProgressionPage` branchée dans `game_page.dart` (case `GameTypes.Escape`)
- SectionEvent : parcours déjà branchés via `GuidedPathMapProgressionPage` (contexte carte)
- [ ] **PDF en média d'étape***(arbitré le 2026-08-09 : c'est l'alternative retenue au lieu de faire de SectionParcours un conteneur de sections)*
- L'idée écartée : permettre d'insérer n'importe quelle section dans un point de parcours. Rejetée — la moitié des types (Agenda, Météo, Event) n'a pas de sens à cet endroit, et le lien « réutiliser une section existante » existe déjà là où il est cohérent (`baseSectionMapId` → SectionMap). Le vrai manque tient en un type de ressource.
- `ContentDTO.resource` accepte déjà Image / ImageUrl / Video / VideoUrl / Audio ([new_update_image_slider.dart:67](../manager-app/lib/Screens/Configurations/Section/SubSection/Slider/new_update_image_slider.dart#L67)) — **PDF est le seul absent**
- À faire : (1) rendre la liste des types paramétrable dans `showNewOrUpdateContentSlider` et y ajouter `ResourceType.PDF` **côté étape uniquement** (ne pas l'ouvrir au Slider sans y réfléchir) ; (2) cas `PDF` dans `getElementForResource` (`marker_view.dart`) — il n'en a aucun aujourd'hui ; (3) cas PDF dans `ResourceViewer.tsx` côté web, qui retombe sinon sur le rendu image
- Sans (2) et (3), un PDF configuré s'afficherait comme une image cassée — exactement le bug corrigé pour la vidéo le 2026-08-09
### Statistiques
- [x]**Tracking des événements** — ⚠️ **(révisé 2026-08-07)** « complet » est faux, voir les deux trous ci-dessous
-`sectionView` et `sectionLeave` (avec durée) — `section_page.dart`
-`quizComplete` (score + totalQuestions) — `quizz_page.dart`
-`gameComplete` (gameType + durée) — `game_page.dart`
-`mapPoiTap` (geoPointId + titre) — `google_map_view.dart`
-`articleRead``article_page.dart` (+ `ArticleRead` ajouté au backend enum)
-`menuItemTap`, `agendaEventTap`, `qrScan` — déjà présents
- [ ]**Achèvement d'un parcours guidé — non tracé, et aucune valeur d'enum pour le faire**
- Relevé le 2026-08-07 en construisant le rapport PDF : le sommaire annonçait « parcours terminés », rien ne le produit. `VisitEventType` (`VisitEvent.cs:48-60`) n'a **aucune** valeur d'achèvement de parcours
- À faire : ajouter `GuidedPathComplete` **en fin d'enum** (les valeurs sont persistées en int), puis appeler `track()` dans `GuidedPathContentProgressionPage` **et** `GuidedPathMapProgressionPage` — les deux, sinon seuls les parcours carte comptent. Idem côté `visitapp-web` (`ProgressView`)
- Métadonnées utiles : `parcoursId`, nombre d'étapes, durée totale
- Débloque la puce « parcours terminés » du rapport PDF, retirée en attendant
- [ ]**`VisitEventType.AssistantMessage` existe dans l'enum mais personne ne l'émet**
- Relevé le 2026-08-07 : 10 appels à `track()` dans `mymuseum-visitapp`, aucun pour l'assistant. La valeur d'enum est là depuis le début, inutilisée
- Un appel dans `AssistantChatSheet` (et `AssistantBubble.tsx` côté web) donne **le volume d'usage du guide IA tout de suite**
- ⚠️ **(révisé 2026-08-09)** La table `VisitorQuestion` **existe désormais** (migration `AddVisitorQuestion` du 2026-08-09, entité + `DbSet`), mais **rien n'y écrit** : aucune insertion dans `AiController`. C'est l'étape suivante côté guide IA — voir [v2/guide-ia-screen-plan.md](v2/guide-ia-screen-plan.md) §7. `VisitorQuestion` porte le *texte* des questions, `AssistantMessage` seulement le volume : les deux sont complémentaires
- C'est aussi ce qui alimenterait la puce « questions au guide IA » du rapport PDF, retirée en attendant
- [ ] ⚠️ **`DayStatDTO` ne ventile que Mobile et Tablet**
- `Total`, `Mobile`, `Tablet` — Web, VR et Voice n'ont pas leur ventilation quotidienne (`StatsController.cs:169-180`)
- Sans effet aujourd'hui (la courbe de l'écran est mono-série par décision de conception), mais bloquant si on veut un jour une ventilation par canal dans le temps
### Beacons / géofencing
- [x]**Beacon discovery + geofencing GuidedStep**
- `flutter_beacon` (abandonné) remplacé par `beacon_scanner: ^0.0.4` (^0.1.2 requiert Flutter ≥ 3.35.7)
- Scanning iBeacon restauré dans `configuration_page.dart` : `BeaconScanner.instance.ranging()`, filtre par `minorId` + `accuracy`, cooldown entre popups
- ~~`GuidedStep.TriggerGeoPointId`~~ — ⚠️ **(révisé 2026-08-05)** ce champ **n'existe pas** dans `GuidedStep.cs`. Le géodéclenchement se fait via `Geometry` + `IsGeoTriggered` + `ZoneRadiusMeters`, et uniquement dans `GuidedPathMapProgressionPage`
- Notification locale beacon : `PushNotificationService.showBeaconDiscoveryNotification()` appelée dans `_onBeaconFound`
- Notification locale entrée zone geo : `PushNotificationService.showGeoZoneNotification()` dans `_checkGeoZone` — ⚠️ **(révisé 2026-08-05)** présent uniquement dans `guided_path_map_progression_page.dart:247`, pas dans la page mode contenu
- ⚠️ **(révisé 2026-08-05)** `Section.meterZoneGPS` est configurable par section dans `section_detail_screen.dart` mais **ignoré** : `configuration_page.dart:43` utilise une constante en dur `int meterToBeacon = 100;`
- ✅ Clés de traduction `beaconFound` / `beaconFoundBody` ajoutées dans `translations.dart` (10 langues)
### Ressource 360° — Images panoramiques — 📦 **V2**
> **Reporté en V2 le 2026-08-07.** La spec ci-dessous reste valable telle quelle : elle est additive (une valeur d'enum + un branchement dans l'affichage des ressources), rien en V1 n'en dépend.
- [ ]**Nouveau type de ressource : image 360° (équirectangulaire)**
- **Backend** : ajouter `ResourceType.Panorama360` à l'enum existant (ou un flag `isPanorama` sur `ResourceDTO`) — pas de nouveau modèle nécessaire, c'est juste une image stockée différemment
- **Manager-app** : upload d'une image 360° dans le gestionnaire de ressources, prévisualisation (miniature plate avec icône 360°)
- **Visitapp** : affichage via le package [`panorama`](https://pub.dev/packages/panorama) — gyroscope + drag interactif. À intégrer dans `show_element_for_resource.dart` ou `cached_custom_resource.dart` : si `resource.type == Panorama360``Panorama(image: NetworkImage(...))` au lieu de `Image.network`. S'affiche donc partout où une ressource est affichée (Article, Slider, GuidedStep, etc.) sans modification des sections
- **Tablet-app** : même intégration
### AR — Réalité augmentée (image tracking) — 📦 **V2**
> **Reporté en V2 le 2026-08-07.** C'est le plus lourd des trois chantiers reportés — nouveau service Docker (`mind-ar-compiler`), nouveau modèle `ArAnchor`, et remplacement du scanner visiteur par la WebView unifiée. Fort effet démo, aucune urgence : ni la migration Postgres ni la mise en prod n'en dépendent. La spec ci-dessous est complète et reprenable telle quelle.
- [ ]**Affichage AR sur marqueur physique ou image d'œuvre**
#### Vision globale
Deux triggers, un seul modèle de données, un seul overlay :
- **Mode QR** : le QR code existant est scanné → si la section liée a du contenu AR → AR Camera View avec overlay. Zéro nouveau QR à imprimer, rétrocompatible avec les installations existantes.
- **Mode image** : le visiteur pointe sa caméra vers l'œuvre elle-même (ou un panneau) → reconnaissance → overlay AR. Zéro installation physique.
```
Mode QR : scan QR → sectionId → section.hasArContent ? AR overlay : navigate (actuel)
Mode image : caméra → Mind AR détecte l'œuvre → sectionId → AR overlay
```
#### Modèle de données — ArAnchor (nouveau, proche de BeaconSection)
```
ArAnchor {
id
sectionId // section à afficher en overlay
configurationId // scope par instance
markerResourceId // image de référence uploadée dans manager-app
mindFileUrl // fichier .mind généré côté backend (Mind AR descriptor)
orderInConfig
}
```
#### Backend
- Nouveau modèle `ArAnchor` + endpoints CRUD
- Exposer la liste des `ArAnchors` de la configuration dans `AppConfigurationDTO` (comme `beaconSections`)
- **Génération du `.mind` — Option B : microservice Node.js séparé**
- Nouveau service Docker `mind-ar-compiler` (~50 lignes Express) avec un endpoint `POST /compile` : reçoit l'image, appelle `mind-ar-compiler` (npm, MIT, gratuit), retourne le fichier `.mind`
- Ajouté comme service dans `docker-compose.yml` — aucun coût supplémentaire, scalable indépendamment
- Hangfire job déclenché à la création/update d'un `ArAnchor` : télécharge l'image depuis `markerResourceId`, appelle le microservice, stocke le `.mind` comme ressource, met `ArAnchor.status = "ready"` (ou `"error"`)
- Le backend .NET reste pur — zéro Node.js dans le container principal
#### Manager-app
- Nouvelle page "AR" dans les settings de configuration (même pattern que la page beacons)
- Upload de l'image marqueur (photo de l'œuvre, ou image quelconque) + association à une Section existante
- Prévisualisation de l'image marqueur + statut de génération du `.mind` (en cours / prêt / erreur)
#### Visitapp
- Au boot de la configuration, télécharger les `.mind` files de tous les `ArAnchors` (comme les `beaconSections`)
- **ScannerDialog** : remplacer le scanner natif actuel par la WebView AR unifiée — même bouton, même geste pour le visiteur
- Quand un marqueur est reconnu (ou QR scanné avec `hasArContent`) → `ArCameraOverlayPage` :
- Caméra en plein écran
- Overlay ancré sur le marqueur : titre de la section, image, courte description, bouton "Ouvrir" → navigate vers `SectionPage`
- Optionnel : si la section a une ressource `.glb` → afficher le modèle 3D posé sur le marqueur (via `ar_flutter_plugin_updated`)
- `ArAnchors` téléchargés et cachés localement dans `downloadConfiguration.dart` (mode offline compatible)
#### Différenciation vs beacons / QR
| | Beacon | QR | AR image |
|---|---|---|---|
| Geste visiteur | Aucun (passif) | Chercher + scanner | Pointer vers l'œuvre |
| Précision | Zone (~15m) | Exacte | Exacte |
| Installation physique | Batterie à changer | Autocollant QR | Rien si image de l'œuvre |
| "Wow factor" | Faible | Moyen | Élevé |
#### Stack technique
- **Image tracking + QR** : WebView unique (Android WKWebView / iOS WKWebView / Web browser) avec `mind-ar.js` + `jsQR` — **pas de WebXR**, fonctionne sur tous les devices depuis ~2019
- **Un seul bouton scanner** — transparent pour le visiteur :
- `jsQR` tourne en priorité (très léger, <100ms pour détecter un QR)
- Mind AR démarre seulement si aucun QR détecté après ~1-2 secondes → **CPU soulagé**, jamais les deux en parallèle
- Quand l'un ou l'autre détecte quelque chose → `postMessage` vers Flutter → même traitement dans les deux cas
- **Offline** : la WebView est chargée depuis un asset Flutter bundlé (pas d'URL distante) — `assets/ar_scanner/index.html` + `mind-ar.js` + `jsqr.js`. Les fichiers `.mind` téléchargés au boot de la configuration sont injectés depuis le stockage local via `JavascriptChannel`. Zéro réseau requis au moment du scan.
- **3D objects** (optionnel, phase 2) : `ar_flutter_plugin_updated` pour poser des `.glb` sur le marqueur détecté
### AI Assistant
- [x] ✅ **UI chat / assistant IA**
- `AssistantChatSheet` complète (bulles, cards, navigation vers section)
- Ouverte depuis `home_3.0.dart` et `configuration_page.dart`
### Mode offline — Configurations sélectives
- [ ] ❌ **Téléchargement complet des ressources pour les configurations marquées offline**
#### État actuel (investigué)
- Le download des **fichiers binaires** (images, audio, vidéos, PDFs) fonctionne déjà — `downloadResource()` dans `downloadConfiguration.dart` lignes 107129
- La DB SQLite stocke uniquement les **métadonnées** de section (id, title, description, imageId, type, order...) — jamais le contenu réel
- La colonne `data TEXT NOT NULL` existe dans le schéma SQLite mais n'est jamais remplie (insert probablement silencieusement KO)
- Seules les sections `Article` et `Quiz` sont persistées en DB locale (ligne 151) — les autres types ignorés
- `cleanLocalResources()` commentée (ligne 221) — nettoyage désactivé
- Grosse classe `DownloadConfiguration` entièrement commentée (lignes 334466) — code mort
#### Pourquoi l'ancienne approche ne tient plus
Avant, le contenu de chaque section était sérialisé dans un champ `data` JSON sur le `SectionDTO`. Maintenant chaque type de section a son propre DTO (`SectionArticleDTO`, `SectionQuizDTO`, etc.) récupéré via un endpoint dédié. Le `SectionDTO` actuel ne contient que les métadonnées — il n'y a plus de champ `data`.
#### Ce qu'il faudrait faire (réécriture)
- Au moment du download, pour chaque section, appeler l'endpoint spécifique (`/api/SectionArticle/{id}`, `/api/SectionQuiz/{id}`...) et stocker le JSON du DTO dans la DB (colonne `data` ou table dédiée par type)
- Dans chaque page de section, lire le JSON local en priorité si offline
- Étendre `sectionsToKeep` à tous les types de section (pas seulement Article/Quiz)
- Réactiver `cleanLocalResources()` pour nettoyer les fichiers obsolètes
- **Périmètre** : seulement les configurations explicitement marquées offline — pas de mode offline global
### SectionForm — Formulaires personnalisés — 📦 **V2**
> **Reporté en V2 le 2026-08-07.** Nouveau type de section + table de réponses : purement additif, aucun impact sur le schéma existant, donc rien n'oblige à le faire passer avec la migration Postgres.
- [ ] ❌ **Nouveau type de section : formulaire configurable**
- **Backend** : nouveau modèle `SectionForm` avec `List<FormField>` (types : `text`, `rating`, `single_choice`, `multiple_choice`) + `FormResponse` pour stocker les réponses (visiteur anonyme, timestamp, configurationId)
- **Manager-app** : constructeur de formulaire (ajout/réordonnancement de champs, labels multilingues, choix pour les champs à options) + page de résultats avec agrégats (moyenne pour les ratings, répartition pour les choix, liste pour les textes libres)
- **Visitapp + Tablet-app** : `form_page.dart` — affichage des champs selon le type, soumission via API, confirmation de réception (pas de compte requis, réponse anonyme)
- **Use cases** : feedback post-visite, enquête de satisfaction, inscription atelier, sondage exposition
---
## manager-app
### Gestion des utilisateurs par l'admin d'instance
- [ ] ⚠️ **Un admin d'instance peut gérer les utilisateurs de son instance (max 5, hors superAdmin)** — **partiellement fait (révisé 2026-08-05)**
#### Ce qui est déjà en place (vérifié dans `Controllers/UserController.cs`)
- ✅ Contrôleur sous policy `InstanceAdmin` (`l.20`) — plus SuperAdmin-only
- ✅ `GET /api/User` filtré à l'instance de l'appelant si non-SuperAdmin (`l.69`)
- ✅ `GET /api/User/{id}`, `PUT`, `DELETE` : rejet si l'utilisateur ciblé appartient à une autre instance (`l.94`, `l.207`, `l.263`)
- ✅ `POST` : `InstanceId` **forcé** à l'instance de l'appelant (`l.133`), pas celui envoyé par le client
- ✅ Protection contre l'escalade de rôle : `PUT`/`DELETE` refusés si `user.Role < GetCallerRole()` (`l.210`, `l.266`)
#### Ce qui reste réellement à faire — révisé le 2026-08-12
- ✅ **Manager-app** : compteur « X / 5 utilisateurs » + bouton d'ajout désactivé au quota — livré le 2026-08-12 (`kMaxUsersPerInstance` dans `constants.dart`, `users_screen.dart`). Le compteur et le plafond **ne s'appliquent pas au SuperAdmin** : son `GET /api/User` renvoie toutes les instances, compter cette liste contre un plafond *par instance* n'aurait aucun sens
- ✅ **Le rôle `SuperAdmin` est déjà masqué** pour un `InstanceAdmin` — la question était ouverte, elle est fermée : `_allowedRoles` filtre sur `r >= callerRole` (`users_screen.dart:40`), un appelant de rôle 1 ne se voit proposer que 1/2/3. C'était fait, jamais consigné
- ❌ **Plafond de 5 utilisateurs par instance, côté serveur** — aucun contrôle de comptage dans `UserController.CreateUser`, pas de 422. ⚠️ **Ce que manager-app vient de livrer est un garde-fou d'interface, pas une règle** : un `POST /api/User` direct sur l'API passe toujours, et rien n'empêche une instance d'avoir 12 utilisateurs. Le plafond n'est pas non plus dans `SubscriptionPlan`, qui ne porte que 5 champs (stockage, jetons IA, stats, rétention, stats avancées) — s'il devait varier par plan, c'est une colonne à ajouter, donc un changement de schéma **après** le gel du lot B
- ⚠️ **Retour d'erreur du POST, corrigé côté front dans la même passe** : `invokeAPI` ne lève pas sur un code d'erreur et son résultat était ignoré — un e-mail déjà utilisé (409) ou un rôle refusé (403) laissait la liste inchangée **sans aucun message**. Le statut est désormais testé et le corps affiché. C'est ce qui rendra le futur 422 visible sans retouche front
#### Comportement attendu (spec d'origine)
- Un `InstanceAdmin` peut voir, ajouter et supprimer des utilisateurs **de son instance uniquement**
- Maximum **5 utilisateurs** par instance (admin compris)
- L'admin d'instance **ne peut pas créer de `SuperAdmin`** — le rôle `SuperAdmin` est réservé au SuperAdmin lui-même
- Le `SuperAdmin` garde son comportement actuel : aucune restriction
#### Backend
- `UserController` : exposer les endpoints `GET /api/User` et `POST /api/User` aux `InstanceAdmin` (actuellement SuperAdmin only ?)
- `POST /api/User` depuis un `InstanceAdmin` : valider que `instanceId` == instance de l'appelant, que le rôle demandé != `SuperAdmin`, et que le nombre d'utilisateurs de l'instance < 5 (HTTP 422 sinon)
- `GET /api/User` depuis un `InstanceAdmin` : filtrer les résultats à l'instance de l'appelant uniquement
#### Manager-app
- Page utilisateurs (`Users/`) : actuellement visible pour les SuperAdmin uniquement — ouvrir l'accès aux `InstanceAdmin` avec vue filtrée à leur instance
- Masquer le champ de sélection de rôle `SuperAdmin` dans le formulaire de création si l'appelant est `InstanceAdmin`
- Afficher un compteur "X / 5 utilisateurs" + désactiver le bouton d'ajout si quota atteint
---
### Audit log — écran de consultation (SuperAdmin)
> Contexte : le backend journalise déjà `Section`, `Resource`, `Configuration`, `Device`, `User`, `Instance` (create/update/delete, qui/quand/avant/après) via `AuditLog` + `AuditController` (SuperAdmin only, filtrable par `instanceId`/`entityType`/`userId`/dates). Rien côté manager-app ne consomme cet endpoint (vérifié 2026-07-15). Chaque entité auditée porte désormais aussi `DateCreation`/`DateUpdate` auto-remplis par le `DbContext`.
- [x] ✅ **Backend — AuditLog fiable sur toutes les écritures**
- `SaveChanges()` (sync) et `SaveChangesAsync()` déclenchent tous les deux l'audit (avant : seul `SaveChangesAsync` l'alimentait, les controllers en sync passaient sous le radar)
- `DateCreation`/`DateUpdate` auto-remplis via `IAuditableEntity` + `ChangeTracker`, plus besoin de le faire à la main dans chaque mapper
- [x] ✅ **Manager-app — écran "Activité / Audit" (SuperAdmin uniquement)** — livré le 2026-08-12
- `Screens/Audit/audit_screen.dart` + `audit_entry.dart`, entrée de menu `menuId: 13` conditionnée à `role.value == 0` (même règle que la policy `SuperAdmin` de l'endpoint)
- Liste triée par date décroissante, filtres instance / type d'entité / utilisateur / plage de dates, pagination 50 par page, clic sur une ligne → détail avant/après en table champ / avant / après
- **`manager_api_new` n'a pas été touché.** Le plan initial disait « nouveau modèle + service dans `manager_api_new/` » ; l'écran passe par un `http.get` direct avec le Bearer, comme `_loadKnowledge` / `_loadInsights` / `_reindex` du Guide IA. Une lecture seule ne justifie pas d'étendre un client qui s'édite à la main — et le déclencheur de génération (`openApiTest.dart`) n'existe plus depuis le lot A
- Deux détails qui ne se devinent pas : le contrôleur renvoie les entités **brutes** (pas de DTO), donc les champs sont ceux d'`AuditLog` en camelCase ; et `to` est pris à **la fin de la journée** choisie, sinon un filtre « jusqu'au 12/08 » exclut tout le 12/08
- Les valeurs multilingues (`List<TranslationDTO>`) sont rendues en `FR : …` par ligne plutôt qu'en JSON brut — c'est le gros des colonnes modifiées sur une section ou une configuration
- [ ] ❌ **Backend — aucune section n'est jamais journalisée** *(découvert le 2026-08-12 en câblant l'écran)*
- `AuditedTypes.Contains(entry.Entity.GetType())` (`MyInfoMateDbContext.cs:126`) exige une **égalité de type exacte**. Or `Section` est **abstraite** (`Section.cs:17`) : le type runtime est toujours `SectionMap`, `SectionQuiz`, `SectionArticle`… La ligne `typeof(Section)` d'`AuditedTypes` ne matche donc **rien**
- Conséquence : `Resource`, `Configuration`, `Device`, `User`, `Instance` sont bien journalisés, **le contenu ne l'est pas** — exactement ce que l'usage cible voulait tracer (« qu'est-ce que ce client a fait durant telle période ? »)
- Correctif attendu : tester `AuditedTypes.Any(t => t.IsInstanceOfType(entry.Entity))` au lieu de `Contains`. Vérifier au passage que `EntityType` porte alors `SectionMap` et non `Section` — l'écran affiche la valeur brute pour tout type qu'il ne connaît pas, donc il fonctionnera sans retouche, mais le filtre « Section » ne matchera plus rien : il faudra soit normaliser côté serveur, soit élargir la liste du front aux 13 sous-types
- Le filtre « Section » **est déjà dans l'écran** : le jour où le backend corrige, le front n'a rien à changer. D'ici là ce filtre ne rend aucune ligne
---
### Traduction automatique via IA
- [x] ✅ **Proposer une traduction automatique lors de la saisie de contenu multilingue**
- **Backend** : nouveau endpoint `POST /api/AI/translate` — reçoit `{ text, sourceLanguage, targetLanguages[] }`, appelle `_chatClient` (Gemini déjà instancié dans `AssistantService`), retourne `{ [lang]: translatedText }`. Logique séparée de `ChatAsync`, pas de modification du service existant.
- **Manager-app** : dans tous les champs multilingues, ajouter un bouton "Traduire" qui appelle ce endpoint à partir de la langue de référence remplie et injecte les traductions dans les autres champs — modifiables avant sauvegarde
- **UX suggérée** : bouton global "Traduire toutes les langues" en bas du formulaire, ou bouton discret par langue non remplie
- **Périmètre** : partout où il y a des `List<TranslationDTO>` — sections, programme blocks, guided steps, map points, etc.
- **Condition** : bouton visible uniquement si l'assistant IA est activé sur l'instance (`instanceDTO`)
### SectionParcours — refonte du flux de configuration
- [ ] **Sortir de l'empilement de popups** — *maquette du 2026-08-09 : [claude.ai/code/artifact/c9d9a5c0](https://claude.ai/code/artifact/c9d9a5c0-1ba4-4e50-a522-28a0cf2ecd15)*
- La refonte du 2026-08-06 a traité le **vocabulaire** (3 questions au lieu de 9 booléens), pas la **forme**. Configurer une question sur une étape et l'écrire en NL = **5 surfaces empilées** : écran de config → popup Parcours (`0.82` de largeur) → popup Étape (`0.75`) → popup Question (`0.65`) → popup de traduction. Le seul repère de profondeur est la largeur qui rétrécit.
- Trois problèmes distincts, à ne pas confondre : (1) **empilement** — rien ne dit où on est ; (2) **sauvegarde en cascade** — chaque popup travaille sur une copie `jsonEncode/jsonDecode` et ne remonte qu'à la fermeture, fermer la fenêtre du parcours jette les questions saisies dessous sans avertir ; (3) **perte de contexte** — la liste des étapes disparaît pendant qu'on en édite une.
- **Deux options maquettées**, à trancher avant tout code :
- **A (recommandée)** — une seule popup, rail d'étapes à gauche toujours visible, détail à droite, question dépliée sur place. Profondeur max 2. Garde le geste que le client connaît des 12 autres types de section.
- **B** — écran plein cadre à 3 colonnes (parcours / étapes / détail). Profondeur max 1, libère une colonne pour un aperçu visiteur en direct, mais fait du parcours le seul type de section à ne pas se configurer comme les autres.
- **Ordre d'exécution** : (1) trancher A ou B ; (2) **la sauvegarde au fil de l'eau d'abord** — indépendante du choix, et seul point qui fait perdre du travail au client aujourd'hui ; (3) extraire les blocs de champs des trois `show…` en widgets autonomes (`ParcoursFields` / `EtapeFields` / `QuestionFields`) — sert A et B à l'identique ; (4) le contenant seulement ensuite.
- Ne change pas : la popup de traduction (un niveau justifié, langues verticales, pas d'onglets — voir `MultiStringInputContainer`), les composants de champ de `Components/` (leur problème est `size.width * 0.2` dans `NumberInputContainer` et des `fontSize` de label de 16 à 25 px selon le composant, pas leur conception).
- Vérification : test-plan §19.13 cas 0.
### GuidedStep — champs avancés non exposés
- [x] ✅ **Champs manquants dans `showNewOrUpdateGuidedStep.dart`**
- Toggles `isHiddenInitially`, `isStepLocked`, `isStepTimer` ajoutés
- Champs `timerSeconds` + `timerExpiredMessage` (multilingue) affichés si timer activé
- `triggerGeoPointId` exposé en saisie texte (ID numérique)
### SectionEvent — annotations par bloc
- [x] ✅ **Édition des annotations par ProgrammeBlock**
- Section "Annotations" ajoutée dans `showNewOrUpdateProgrammeBlock.dart`
- Ajout/édition/suppression via `showNewOrUpdateMapAnnotation` (label, géométrie, couleur, icône)
---
### Mode preview — rendu en conditions réelles
- [ ] ❌ **Prévisualiser le contenu dans le manager-app sans quitter l'interface**
#### Contexte
Actuellement, un admin doit publier sa configuration et ouvrir l'app visiteur ou la tablette pour voir le rendu réel. L'objectif est d'avoir un aperçu fidèle directement dans le manager-app, par cible de déploiement.
#### Cibles de preview
| Cible | Description | Particularités |
|---|---|---|
| **App mobile** (`mymuseum-visitapp`) | Rendu sur un device personnel du visiteur | Navigation gestuelles, home screen, sections |
| **App web** (`visitapp-web`) | Rendu dans un navigateur | Layout desktop/tablette, même contenu |
| **Kiosk** (`tablet-app`) | Rendu sur tablette fixe | Sélection via pincode, UI plein écran kiosk |
| **Assistant / Persona** | Simulation de la conversation IA | Réponses du persona, navigation contextuelle |
#### UX envisagée
- Bouton "Preview" dans le header du manager-app, disponible depuis n'importe quelle page de configuration
- Panel latéral ou dialog avec 4 onglets (une par cible)
- Chaque onglet affiche un **iframe** ou un **composant Flutter embarqué** simulant le rendu de la configuration courante
- Sélecteur de section pour prévisualiser une section spécifique directement (sans naviguer depuis le home)
- Sélecteur de langue (si plusieurs langues configurées)
- Option "Mode responsive" pour les cibles web/mobile : switcher entre viewports (mobile 375px, tablette 768px, desktop)
#### Backend
- Pas de changement nécessaire : le preview consomme les mêmes endpoints que les apps (configuration live ou éventuellement un flag `?preview=true` qui ignore les filtres de publication)
- Optionnel : endpoint `GET /api/Configuration/{id}/preview-token` — token JWT court-lived (15 min) en lecture seule pour l'iframe, évite d'exposer la clé API admin dans l'URL
#### Cible "Assistant / Persona"
- Simulation de la conversation avec le persona IA configuré sur l'instance
- Affiche le chat dans les mêmes conditions que `AssistantChatSheet` (visitapp)
- Permet de tester les réponses, la navigation contextuelle (cards sections), le ton du persona
- Identique fonctionnellement à l'app mobile mais accessible directement depuis le manager-app sans device externe
---
## Backend (manager-service)
### Bug — GuidedPath update
- [x] ✅ **`UpdateGuidedPath()` ne copie pas `SectionGameId`**
- Fixé dans `SectionMapController.cs` — `existingGuidedPath.SectionGameId = guidedPathDTO.sectionGameId` ajouté
---
## Plans & Quotas
### Quota assistant IA
- [x] ✅ **Enforcer le plafond de requêtes IA par plan (500 req/mois Standard, 2 000 Premium)**
- Le comptage existe déjà (`AiRequestsThisMonth` + `AiUsageMonthKey` sur `Instance`, incrémenté dans `AiController.cs` lignes 5663) mais aucun plafond n'est vérifié
- **Backend** : ajouter un champ `AiQuota` sur `Instance` (ou le déduire du plan), vérifier avant l'appel Gemini, retourner HTTP 429 avec message générique si dépassé — pas d'erreur technique brute exposée au visiteur
- **Manager-app** : exposer `aiRequestsThisMonth / aiQuota` dans le DTO d'instance, afficher un warning à partir de 80% du quota dans le dashboard ou la page settings — l'admin peut décider d'upgrader avant la coupure
- **Comportement souhaité** : hard block à 100% (coûts Gemini non prévisibles sinon), warning visible à 80%, aucun message d'erreur de quota côté visiteur (juste "assistant indisponible")
### Quota IA — comptage en tokens plutôt qu'en requêtes
- [x] ✅ **Le quota IA compte désormais les tokens réellement consommés, plus des requêtes brutes**
- `SubscriptionPlan.cs`/`Instance.cs` : `AiRequestsPerMonth`/`AiRequestsThisMonth` (int) renommés en `AiTokensPerMonth`/`AiTokensThisMonth` (long)
- `AssistantService.cs` : `TranslateAsync`/`ChatAsync` lisent `response.Usage?.TotalTokenCount` et le remontent via le nouveau champ `TokensUsed` sur `AiChatResponse`/`AiTranslateResponse`
- `AiController.cs` : le hard-block est vérifié *avant* l'appel Gemini sur l'historique cumulé, puis `AiTokensThisMonth` est incrémenté *après* l'appel avec le coût réel (léger dépassement ponctuel possible, comme avant)
- Migration EF `AiQuotaTokensInsteadOfRequests` (drop/recreate des colonnes en bigint) — **à appliquer** via `dotnet ef database update`
- Valeurs seed estimées à 5M tokens/mois (Standard) et 20M (Premium), sur la base d'une hypothèse de ~10k tokens/requête — **à ajuster** une fois l'usage réel observé (éditable via l'écran admin des plans, pas de redéploiement nécessaire)
- **Manager-app** : `InstanceQuotaDTO`/`SubscriptionPlanDTO`/`InstanceDTO` renommés en conséquence, `quota_bars_widget.dart` et `main_screen.dart` affichent les tokens formatés (K/M) au lieu d'un compteur de requêtes
- **Décision** : comptage tokens uniquement (pas de double limite requêtes+tokens) — plus simple, un seul champ à faire évoluer par plan
### Quota stockage
- [x] ✅ **Enforcer le quota de stockage par plan (2 GB Starter, 10 GB Standard, 50 GB Premium)**
- `resource.SizeBytes` est stocké à l'upload (`ResourceController.cs` ligne 272) mais aucune agrégation ni vérification n'existe
- **Backend** : agréger `SizeBytes` sur toutes les ressources de l'instance, bloquer l'upload si le quota est dépassé (HTTP 413 avec message explicite)
- **Manager-app** : afficher la consommation courante vs quota dans la page ressources ou settings
### Stats filtrées par plan
- [x] ✅ **Restreindre les stats selon le plan (Standard = 30 jours, Premium = illimité)**
- **Modèle** : deux nouveaux champs sur `SubscriptionPlan` — `StatsHistoryDays` (int, 0 = illimité) et `HasAdvancedStats` (bool). Migration `AddStatsHistoryToSubscriptionPlan`.
- **Backend** : `StatsController.GetSummary` — `Include(SubscriptionPlan)`, plafonne `from` si `StatsHistoryDays > 0`, vide les champs avancés si `!HasAdvancedStats` (languageDistribution, topPois, topAgendaEvents, quizStats, gameStats, topArticles, topMenuItems, qrScans).
- **Manager-app** : segment 90j désactivé si `statsHistoryDays <= 30`, `_buildTablesRow` affiche un bloc "verrouillé Premium" à la place des tables si `!hasAdvancedStats`.
- [x] ✅ **L'axe « durée d'historique » est supprimé — 13 mois pour tous** *(décidé et appliqué le 2026-08-09)*
- **Pourquoi** : la landing ne promettait aucune durée (`statsBasic: "30 jours"` existe dans `translations.ts` mais **n'est affiché nulle part** — les cartes vendent la *profondeur*, « Visiteurs / jour » vs « Parcours, temps, clics »). Et un dossier de subside est annuel : plafonné à 30 jours, le rapport PDF ne part pas à une commune, donc la feature était morte pour Essentiel et Pro
- **13 mois** (`MyInfoMateDbContext.StatsRetentionDays = 395`) : le rapport annuel passe, et la comparaison avec le même mois de l'année précédente devient possible
- **`HasAdvancedStats` reste le seul différenciateur stats** (POI, quiz, jeux, articles, QR, langues)
- Migration `UnifyStatsRetentionTo13Months` — met à jour les 3 plans **et reprend les instances existantes en SQL** : les quotas sont copiés du plan vers l'instance à la création, et c'est `Instance.StatsHistoryDays` que lit `StatsController`. Sans cette reprise les clients actuels seraient restés à 30 jours. **À appliquer** via `dotnet ef database update`
- Conséquence côté écran : la période « Année » est désormais activée pour tout le monde, mais **n'affiche aucune variation** — comparer 365 jours exigerait 730 jours d'historique, et le garde-fou de l'écran la masque plutôt que d'afficher un chiffre faux
- [ ] ⚠️ **Purge des `VisitEvent` — codée, volontairement inactive** *(2026-08-09)*
- `VisitEventPurgeService` + job Hangfire `visit-events-purge` (cron `0 3 * * *`), enregistrés
- **Ne supprime rien tant que `Stats:RetentionDays` n'est pas défini** dans la configuration. Le job log et sort
- ⛔ **Ne l'activer qu'une fois le `pg_dump` quotidien en place** — c'est une suppression définitive, et le snapshot OVH seul ne permet pas de restaurer finement une table. Voir la ligne pg_dump plus bas
- Une fois prêt : `Stats__RetentionDays=395` dans le `.env`
- [ ] ⚠️ **`GetSummary` agrège en mémoire — à passer en SQL avant de monter en charge**
- `eventsQuery.ToList()` (`StatsController.cs:116`) charge **tous** les événements de la période avant d'agréger. Acceptable au volume actuel, intenable sur 13 mois d'une grosse instance
- Devenu plus pressant depuis que la fenêtre est passée de 30 jours à 13 mois. À faire avant de vendre le rapport annuel à un gros site
- [ ] ⚠️ **Le seed des plans ne correspond plus à la landing** *(relevé le 2026-08-07)*
- `MyInfoMateDbContext` sème `plan-starter`, `plan-standard`, `plan-premium`, `plan-essentiel`. **Il n'existe pas de `plan-pro`**, alors que Pro à 99 € est le plan « recommandé » de la landing
- Seul Essentiel est câblé à l'onboarding et à Stripe (`OnboardingController.EssentielPlanId`, `StripeSettings.EssentielPriceId`). Pro et Premium sont vendus « contactez-nous », donc provisionnés à la main — aujourd'hui forcément sur une ligne au mauvais nom
- À nettoyer avec la migration v3, pas au moment de facturer le premier client Pro
---
## Sécurité — API Keys
> Le système est **fonctionnel** : visitapp et tablet-app s'authentifient déjà via clé API. Les points ci-dessous sont des améliorations.
### Ce qui fonctionne déjà ✅
- `ApiKeyAuthenticationHandler` — validation via header `X-Api-Key` (SHA-256)
- Bootstrap PIN → clé API automatique pour visitapp (`AppType.VisitApp`) et tablet-app (`AppType.TabletApp`)
- Révocation (IsActive = false) depuis le manager-app
- Isolation par instance (clé scopée à une instance)
- manager-app utilise JWT (par design, login utilisateur)
### Améliorations manquantes
- [x] ✅ **Pas d'UI pour setter une date d'expiration** à la création d'une clé
- Fixé : date picker ajouté dans `api_keys_screen.dart`, `CreateApiKeyRequest` mis à jour (backend + modèle Flutter), colonne "Expiration" ajoutée dans le tableau (rouge si expirée)
- [ ] ❌ **Pas d'audit log** des appels effectués avec une clé API
- On ne sait pas quelle clé a fait quelle requête, ni combien de fois
- [ ] ❌ **Pas de rate limiting** par clé API
- [ ] ❌ **Clés PIN-bootstrap stockées en clair** en base
- Acceptable pour le cas kiosque/visitapp, mais à noter comme surface d'attaque potentielle
- [ ] ❌ **Migration `AddApiKeys` vide** — la création du schéma se fait via EF code-first, pas de SQL explicite dans la migration
---
## Infrastructure — Sécurité réseau
### Accès base de données PostgreSQL
- [ ] ❌ **Fermer le port PostgreSQL exposé publiquement via Traefik / Docker**
- Actuellement le port Postgres est accessible depuis l'extérieur — à restreindre à la machine uniquement
- **Action** : dans `docker-compose.yml`, supprimer le `ports:` binding public du service `db` (ou le remplacer par `127.0.0.1:5432:5432` pour rester accessible en local uniquement)
- Traefik ne doit pas router vers le service Postgres — vérifier qu'aucune règle Traefik ne l'expose
- **Accès DBeaver** : se connecter via tunnel SSH PuTTY (`localhost:5432` tunnelé sur le port distant) — plus de connexion directe
- **Pourquoi** : la base de données ne doit jamais être exposée publiquement, même avec un mot de passe fort
---
## Suivi
### ✅ Terminé
| Feature | Backend | Manager-App | VisitApp |
|---|---|---|---|
| SectionEvent display (page dédiée) | ✅ | ✅ | ✅ |
| SectionEvent mise en avant home | ✅ | ✅ | ✅ |
| Escape game display | ✅ | ✅ | ✅ |
| Puzzle sliding display | ✅ | ✅ | ✅ |
| GuidedPath display | ✅ | ✅ | ✅ |
| GuidedStep avancé (timer, lock) | ✅ | ✅ | ✅ |
| Annotations bloc programme | ✅ | ✅ | ✅ |
| Vue liste (isListViewEnabled) | ✅ | ✅ | ✅ |
| Annotations carte globales + bloc (event) | ✅ | ✅ | ✅ |
| Push notifications | ✅ | ✅ | ✅ |
| Stats tracking complet | ✅ | N/A | ✅ |
| Bug SectionGameId update | ✅ | N/A | N/A |
| SectionAgenda sync + hang service | ✅ | ✅ | ✅ |
| SectionAgenda events du jour | ✅ | N/A | ✅ |
| EventAgenda infos complètes + vidéo | ✅ | ✅ | ✅ |
| SectionWeather hang service | ✅ | N/A | N/A |
| Traduction automatique IA | ✅ | ✅ | N/A |
| Quota IA (hard block + warning 80%) | ✅ | ✅ | N/A |
| Quota stockage par plan | ✅ | ✅ | N/A |
| Stats filtrées par plan (30j / illimité) | ✅ | ✅ | N/A |
| AuditLog fiable (sync+async) + DateUpdate auto | ✅ | N/A | N/A |
| SectionParcours (modèle + controller + TPH) | ✅ | ⚠️ non revérifié | ⚠️ non revérifié |
| Nettoyage GuidedPath (FK, image, ShowMap déplacé) | ✅ | ⚠️ non revérifié | N/A |
| Refacto SectionGame (retrait GameTypes.Escape) | ✅ | ✅ | N/A |
| Digicode (pavé numérique, détection auto) + confirmation texte libre insensible casse/accents | N/A | N/A | ✅ (2026-07-16) |
### ⚠️ Partiel — à tester / compléter
| Feature | Backend | Manager-App | VisitApp |
|---|---|---|---|
| Beacons / geofencing | ✅ | ⚠️ | ✅ |
| AI Assistant | ✅ | ⚠️ | ✅ |
| Mode offline configurations sélectives | ⚠️ | N/A | ⚠️ |
| Repasse UI globale | N/A | ⚠️ | ⚠️ |
### ❌ À faire
| Feature | Backend | Manager-App | Landing | VisitApp-Web |
|---|---|---|---|---|
| Mode offline — réécriture complète | ❌ | N/A | N/A | ❌ |
| Audit log API Keys | ❌ | N/A | N/A | N/A |
| Audit log — écran consultation SuperAdmin | ⚠️ ✅ sauf les sections, **jamais journalisées** (2026-08-12) | ✅ 2026-08-12 | N/A | N/A |
| Rate limiting API Keys | ❌ | N/A | N/A | N/A |
| Gestion users par instance admin (max 5, pas de SuperAdmin) | ⚠️ scoping ✅, **plafond 5 ❌** (revérifié 2026-08-12) | ✅ compteur « X / 5 » + bouton désactivé (2026-08-12) — garde-fou d'interface seulement | N/A | N/A |
| **Infra** — Port PostgreSQL fermé (accès via tunnel SSH uniquement) | ❌ | N/A | N/A | N/A |
| **GuidedPath** — Image de couverture (ImageResourceId backend) | ✅ | ✅ (révisé 2026-08-05) | N/A | ✅ via `imageUrl` |
| **GuidedPath/SectionParcours** — Flag ShowMap backend | ✅ (sur SectionParcours) | ✅ `section_parcours_config.dart:63` (révisé 2026-08-05) | N/A | ✅ `ParcoursSection.tsx:101` |
| **GuidedPath** — Picker SectionMap liée (UI) | N/A | ✅ `section_parcours_config.dart:70-90` (révisé 2026-08-05) | N/A | N/A |
| ~~**GuidedPath** — TriggerGeoPointId picker~~ | — | **tâche supprimée** : le champ n'existe pas (révisé 2026-08-05) | — | — |
| **GuidedPath** — Tooltips dans les formulaires Parcours | N/A | ❌ (1 seul `Tooltip` dans tout le dossier) | N/A | N/A |
| **GuidedPath** — Use case test carnaval/parcours au trésor | N/A | ❌ pas de trace trouvée | N/A | N/A |
| **Parité** — écarts manager-app → apps visiteur (7 mobile + 10 web) | N/A | N/A | N/A | → [parity-manager-visitapp.md](parity-manager-visitapp.md) |
| **Onboarding** — Stripe Customer Portal (auto-gestion) | ❌ | ❌ | N/A | N/A |
| **Super-admin** — Dashboard clients + usage + désactivation (V2) | N/A | ❌ | N/A | N/A |
| **Facturation** — Page factures dans manager-app (V2) | ❌ | ❌ | N/A | N/A |
### 📦 V2 — spécifié, hors périmètre V1 (décidé le 2026-08-07)
> Ces trois chantiers restent décrits en détail plus haut dans ce fichier. Ils sont sortis de la table « À faire » parce qu'aucun n'est un prérequis de la migration Postgres ni de la mise en prod — pas parce qu'ils sont abandonnés.
| Feature | Backend | Manager-App | VisitApp | VisitApp-Web |
|---|---|---|---|---|
| SectionForm (formulaires) | ❌ | ❌ | ❌ | ❌ |
| Ressource 360° (panorama) | ❌ | ❌ | ❌ | ❌ |
| AR image tracking (Mind AR) | ❌ | ❌ | ❌ | N/A |
### 🧪 Implémenté le 2026-08-05 — code écrit et compilé, **jamais exécuté**
> Tout l'onboarding self-service a été implémenté d'un bloc et les migrations sont appliquées en base
> locale, mais **aucun parcours n'a été exécuté**. Voir la checklist de test plus bas.
>
> ⚠️ **Correction du 2026-08-05 (audit de parité)** — l'affirmation "les 4 repos compilent" est fausse,
> mesuré en relançant les commandes :
> - `npm run build` sur `visitapp-web` → **échoue** (`Failed to type check`, 7 erreurs TS dans
> `map/LeafletMap.tsx` et `MapSection.tsx`). Compilation Turbopack OK, échec au type-check —
> c'est pour ça que `next dev` ne le montre pas.
> - `dotnet test` → **le projet de tests ne compile pas** (3 × `CS7036`). `dotnet build` du projet
> principal passe, mais la suite de tests non.
> - `flutter analyze` sur `mymuseum-visitapp` → 906 issues dont **8 erreurs** (4 × `kElevenLabs*`
> non défini dans `Services/wake_word_service.dart` + `Services/glasses_qr_scanner_service.dart`,
> qui sont du code orphelin non importé donc sans impact sur le build ; `api/openApiTest.dart` et
> `test/widget_test.dart` cassés).
>
> Détail et fix suggéré : [parity-manager-visitapp.md §6](parity-manager-visitapp.md).
| Feature | Backend | Manager-App | Landing | VisitApp-Web |
|---|---|---|---|---|
| **Onboarding** — Inscription + création instance automatique | ⚠️ à tester | N/A | ⚠️ à tester | N/A |
| **Onboarding** — Essai gratuit 14j sans carte, watermark "Aperçu" | ⚠️ à tester | ⚠️ à tester | N/A | ⚠️ à tester |
| **Onboarding** — Stripe (customer, Checkout, webhook) + Stripe Tax + VIES TVA | ⚠️ à tester | ⚠️ à tester | ⚠️ à tester | N/A |
| **Onboarding** — Écran Abonnement + badge d'essai (plan Essentiel uniquement) | N/A | ⚠️ à tester | N/A | N/A |
| **Auth** — Mot de passe oublié / définir mot de passe (token partagé) | ⚠️ à tester | ⚠️ à tester | N/A | N/A |
| **Auth** — Invitation utilisateur par email (plus de mot de passe en clair) | ⚠️ à tester | ⚠️ à tester | N/A | N/A |
| **Emails** — Templates Resend aux couleurs MyInfoMate | ✅ (envoi réel validé 2026-08-05) | N/A | N/A | N/A |
| **Emails** — Flux complet 9 templates (bienvenue, relances, paiement, invite, reset) | ⚠️ à tester | N/A | N/A | N/A |
| **Quota IA** — Plafond cumulé période d'essai (`TrialAiTokensUsed`) | ⚠️ à tester | N/A | N/A | N/A |
| **Quota IA** — Message de quota réel + conso affichée sous « Traduire via IA » | ✅ | ⚠️ à tester | N/A | N/A |
---
## Onboarding & Acquisition — Plan Essentiel (self-service web)
> Objectif : un lieu culturel arrive sur myinfomate-landing, choisit le plan Essentiel (€39/mois), crée son compte, et accède à son manager-app sans aucune intervention manuelle.
### Architecture technique
- **Stripe** → paiement récurrent (carte ou SEPA Direct Debit)
- **Stripe Tax** → calcul TVA automatique (0% intracommunautaire si numéro TVA valide, 21% sinon)
- **VIES UE** → validation numéro de TVA en temps réel à l'inscription (API publique gratuite)
- **Resend** → envoi des emails transactionnels (délivrabilité pro, SPF/DKIM/DMARC, free tier 3 000 emails/mois)
- **manager-service** → logique email centralisée via `IEmailService` + intégration Stripe webhooks
- **Accountable** → création manuelle des factures Peppol après chaque paiement (V1 manuel, automatisation via API en V2)
### Plans concernés
| Plan | Prix | Canal | Onboarding |
|---|---|---|---|
| **Essentiel** | €39/mois HTVA | Web only | ✅ Self-service automatisé |
| Pro / Premium / Enterprise | €99179+/mois | Web + App native | ❌ Contact commercial → demo → manuel |
### Flux d'inscription (Essentiel)
- [x] ⚠️ **Page d'inscription sur myinfomate-landing** — implémenté, à tester
- `src/app/[lang]/signup/` (`page.tsx` + `SignupClient.tsx`), 4 langues, CTA du plan Essentiel branché dessus
- Vérif live du slug (debounce 600 ms) via `GET /api/onboarding/check-slug/{slug}`
- Validation TVA live via `POST /api/onboarding/validate-vat` → 0% intracommunautaire / 21% belge
- `NEXT_PUBLIC_MANAGER_SERVICE_URL` introduite (`.env.local` + `.env.production`)
- [x] ⚠️ **Création automatique de l'instance** — implémenté, à tester
- `Controllers/OnboardingController.cs` → `POST /api/onboarding/register` (`[AllowAnonymous]`)
- Crée l'`Instance` (plan Essentiel, `IsTrialActive = true`, `TrialEndsAt = now + 14 jours`, `PublicApiKey`)
**et** le premier `User` en `InstanceAdmin` (mot de passe hashé scrypt via `ProfileLogic`)
- Crée le customer Stripe, stocke les infos de facturation, envoie les emails 1 et 2 + notif admin
### Essai gratuit (14 jours, sans carte)
- [x] ⚠️ **Gestion de l'état d'essai** — implémenté, à tester
- `Instance.IsTrialActive`, `TrialEndsAt`, `TrialAiTokensUsed`, `IsActive` + flags d'envoi d'emails
- `Services/TrialLifecycleService.cs`, job Hangfire quotidien `trial-lifecycle-daily` : emails J+7/J+10/J+14
puis `IsActive = false` si `TrialEndsAt` dépassé sans abonnement
- `ApiKeyAuthenticationHandler` refuse les instances `IsActive = false`
- Watermark « Aperçu » : `visitapp-web/src/components/ui/TrialWatermark.tsx`, monté dans le layout `[slug]`
- Quota IA d'essai : `TrialAiTokensCap = 300_000` (~30 req) dans `AiController`, cumulé sur les 14 jours
- [x] ⚠️ **Conversion vers plan payant** — implémenté, à tester
- `POST /api/onboarding/checkout-session` (policy `AppReadAccess`) → URL Stripe Checkout hébergée
- Déclenché depuis `manager-app/lib/Screens/Billing/subscription_screen.dart` (`url_launcher` via `window.open`)
- Webhook `POST /api/webhooks/stripe` → `checkout.session.completed` et `invoice.payment_failed`
### Emails transactionnels (Resend + templates aux couleurs MyInfoMate)
Implémenté dans `Services/ResendEmailService.cs` (interface `IEmailService`), coquille HTML partagée
dans `EmailTemplates/EmailLayout.cs`. Config : section `Resend` de `appsettings.json`.
Domaine `myinfomate.be` vérifié chez Resend (DKIM + SPF sur `send` + DMARC), **envoi réel validé
le 2026-08-05** (arrivé en boîte de réception, pas en spam).
- [x] ✅ **Templates HTML aux couleurs MyInfoMate** (bandeau sombre `#0a1222`, CTA cyan `#0df2df`)
- [x] ⚠️ **Email 1 — Bienvenue** — pas de lien "définir mot de passe" : décision du 2026-08-05, le
mot de passe est saisi directement dans le formulaire d'inscription (conforme à la maquette validée),
donc l'email contient un simple lien de connexion
- [x] ⚠️ **Email 2 — Confirmation démarrage essai (J+0)** — à tester
- [x] ⚠️ **Email 3 — Check-in onboarding (J+7)** — à tester (déclenché par Hangfire)
- [x] ⚠️ **Email 4 — Rappel fin d'essai (J+10)** — à tester (Hangfire)
- [x] ⚠️ **Email 5 — Dernier jour d'essai (J+13/14)** — à tester (Hangfire)
- [x] ⚠️ **Email 6 — Paiement confirmé** — à tester (webhook Stripe)
- [x] ⚠️ **Email 7 — Paiement échoué** — à tester (webhook Stripe)
- [x] ⚠️ **Email 8 — Invitation nouvel utilisateur** — nouveau, hors périmètre initial
- [x] ⚠️ **Email 9 — Mot de passe oublié** — nouveau, hors périmètre initial
- [x] ⚠️ **Notifications admin** → `fransolet.thomas@gmail.com` (nouveau compte, conversion, paiement échoué)
- [ ] ❌ **Créer l'alias `onboarding@myinfomate.be`** chez OVH (hébergement mail déjà actif sur le
domaine) — sinon les réponses à l'email de check-in J+7, qui dit « Répondez simplement à cet
e-mail », partent dans le vide. Alternative : ajouter un `reply_to` dans `ResendEmailService`.
### Facturation (V1 manuel)
- [ ] ❌ **Stripe Tax configuré** sur le produit "Essentiel" avec les règles TVA UE
- [ ] ❌ **Processus manuel** : à chaque paiement Stripe confirmé (webhook), Thomas reçoit une notification → crée la facture dans Accountable → l'envoie au client par email (+ Peppol si client belge/FR)
- [ ] ❌ **Stripe Customer Portal** activé → le client peut gérer son abonnement (annuler, changer carte) sans intervention
### 🧪 Checklist de test end-to-end (à faire — rien n'a encore été exécuté)
> Implémenté le 2026-08-05. Les 4 repos compilent et les migrations sont appliquées en base locale,
> mais **aucun parcours n'a tourné**. Ordre conseillé : on part de l'inscription, puis on remonte.
**Prérequis**
- [ ] Postgres local démarré + migrations à jour (`ASPNETCORE_ENVIRONMENT=Development dotnet ef database update`)
- [ ] `manager-service` lancé sur `localhost:5000`
- [ ] `myinfomate-landing` : `npm run dev` (port 3000)
- [ ] Webhook Stripe en local : `stripe login` puis `stripe listen --forward-to localhost:5000/api/webhooks/stripe`,
et coller le `whsec_` **de la CLI** (≠ celui du dashboard) dans `appsettings.Development.json`
**1. Inscription** — `http://localhost:3000/fr/signup`
- [ ] Slug : taper un slug déjà pris → croix rouge ; un slug libre → coche verte
- [ ] TVA : `BE0123456789` → « TVA belge 21% » ; un n° FR valide → « intracommunautaire 0% » ;
format invalide → erreur ; pays Suisse → hors UE 0%
- [ ] VIES indisponible (couper le réseau) → doit afficher le message de repli, **pas** bloquer l'inscription
- [ ] Soumettre → écran de succès avec URL visiteur, date de fin d'essai (J+14), email
- [ ] En base : `Instances` (1 ligne, `IsTrialActive = t`, `TrialEndsAt` ≈ J+14, `SubscriptionPlanId = plan-essentiel`,
`StripeCustomerId` rempli) **et** `Users` (1 ligne `InstanceAdmin`, `Password` hashé — jamais en clair)
- [ ] Emails 1 et 2 reçus + notif admin reçue
- [ ] Réessayer avec le même slug → 409 ; avec le même email → 409
**2. Connexion + écran Abonnement** — manager-app
- [ ] Se connecter avec le compte créé
- [ ] Badge d'essai visible dans la sidebar avec le bon décompte de jours
- [ ] Entrée de menu « Abonnement » présente (elle ne doit **pas** apparaître sur une instance non-Essentiel)
- [ ] L'écran affiche le statut d'essai + la liste de ce qui est inclus
**3. Conversion payante**
- [ ] Clic sur « Passer à un abonnement payant » → ouverture de Stripe Checkout (page hébergée Stripe)
- [ ] Payer avec la carte de test `4242 4242 4242 4242`
- [ ] La CLI Stripe montre `checkout.session.completed` reçu et forwardé en 200
- [ ] En base : `IsTrialActive = f`, `StripeSubscriptionId` rempli
- [ ] Email « paiement confirmé » reçu + notif admin
- [ ] Au reload de manager-app : badge d'essai disparu, écran Abonnement en « actif »
- [ ] `stripe trigger invoice.payment_failed` → email d'échec reçu
**4. Watermark visiteur** — visitapp-web
- [ ] Instance en essai → bandeau « Aperçu » visible sur `localhost:3000/{slug}`
- [ ] Après conversion → bandeau disparu
**5. Auth (mot de passe oublié / invitation)**
- [ ] `/forgot-password` → email reçu, le lien ouvre `/set-password?token=…`
- [ ] Définir un nouveau mot de passe → reconnexion OK
- [ ] Token réutilisé une 2ᵉ fois → refusé ; token expiré (>48 h) → refusé
- [ ] Écran Users → créer un utilisateur **sans** mot de passe → email d'invitation reçu, le lien fonctionne
**6. Cycle de vie de l'essai (Hangfire)**
- [ ] Forcer `TrialEndsAt` dans le passé en base, déclencher `trial-lifecycle-daily` depuis `/hangfire`
- [ ] → `IsActive = f`, et les appels visiteur avec la `PublicApiKey` sont refusés
- [ ] Forcer `DateCreation` à J-7 / J-10 / J-13 → vérifier les emails de relance correspondants
- [ ] Relancer le job deux fois le même jour → **pas** de double envoi (flags `Trial*EmailSent`)
**7. Quota IA** — nécessite `IsAssistant = true` **en base sur l'instance de test** (false par défaut)
- [ ] Conso affichée sous le bouton « Traduire via IA », passe en orange à 80 % puis rouge à 95 %
- [ ] Forcer `AiTokensThisMonth` au-delà du quota → le message affiché doit être
« Quota IA mensuel dépassé » (et **non** l'ancienne erreur générique)
- [ ] Forcer `TrialAiTokensUsed >= 300000` sur une instance en essai → « Quota IA de la période d'essai dépassé »
- [ ] Vérifier que `/chat` et `/translate` décrémentent bien le **même** compteur (quota partagé, décision actée)
---
### Dashboard super-admin (V2)
> Section réservée SuperAdmin dans le manager-app. À faire après le lancement.
- [ ] ❌ **Liste des instances** avec filtres (actif, essai, expiré, désactivé)
- Infos : nom, email contact, slug, plan, date création, date fin essai / renouvellement
- Bouton **désactiver l'instance** (soft-delete ou flag `IsActive = false`)
- [ ] ❌ **Usage par instance** : nombre d'appels API (30 derniers jours), requêtes IA consommées, stockage utilisé + évolution
- [ ] ❌ **KPIs globaux** : total clients actifs, en essai, churned, MRR estimé
---
## Design & UI
### Écrans visiteur — refonte SectionParcours
- [ ] ❌ **Designer les écrans visiteur pour les 2 modes visuels**
- Prompts disponibles dans `DOCS/design-prompts.md` (8 prompts pour Google Stitch / Claude Design)
- **Mode Visite** (`IsGameMode=false`) — clair, culturel, informatif
- **Mode Jeu** (`IsGameMode=true`) — sombre, dramatique, immersif
- Theming dynamique : tous les composants utilisent les CSS variables de l'instance (`--color-primary`, `--color-secondary`, etc.)
- Composants à designer : écran principal, variante carte, défis (QCM / TextLibre / Digicode / Puzzle), sélection parcours, écran de fin, écran de démarrage mode jeu
- [ ] ⚠️ **Loader / Splash screen brandé par instance** (backend existant, rendu manquant)
- **Backend** : ✅ `LoaderImageUrl` existe déjà sur `ApplicationInstance`, `Configuration`, `AppConfigurationLink`
- **Manager-app** : ✅ UI déjà présente dans `configuration_detail_screen.dart`, `app_configuration_link_screen.dart`, `change_device_info_modal.dart`
- **visitapp-web** : ⚠️ champ `loaderImageUrl` dans `ApplicationInstanceDTO` et `ConfigurationDTO`, mais pas de composant splash screen qui l'utilise → à implémenter
- **mymuseum-visitapp** : ⚠️ `splash_screen.dart` existe mais utilise des assets statiques (`kSplashLogoAsset`, `kLoaderAsset`) au lieu de l'URL dynamique du backend → à migrer
- **Rendu des deux apps** : splash screen (~1.5s) avec logo de l'instance + animation de chargement en `--color-primary` — 3 variantes : logo + nom / nom seul / fallback MyInfoMate
- Note : prompt dédié dans `design-prompts.md` (Prompt 5)
### Repasse UI globale
- [ ] ⚠️ **Révision globale de l'UI** — une passe de polish sur l'ensemble des apps une fois les features complètes
- **manager-app** : cohérence des formulaires (espacements, tailles de texte, styles des boutons), responsive desktop/tablet
- **visitapp** : cohérence des popups (agenda, map, article), transitions entre écrans, états de chargement
- **tablet-app** : adaptation kiosk (tailles tactiles, lisibilité à distance), cohérence avec la visitapp
---
## Bugs & vérifications
- [ ] ❌ **visitapp-web — Escape game géolocalisé : pas de carte pour guider le visiteur**
- `EscapeProgression.tsx` n'affiche qu'un indicateur textuel ("À Xm de la zone") sans repère visuel directionnel
- Comportement Flutter identique (`GuidedPathContentProgressionPage`) — c'est intentionnel dans Flutter (mode centré contenu), mais sur web la lisibilité est moindre
- **Solution envisagée** : mini-carte Leaflet repliable (~200px) dans la card de l'étape courante, visible uniquement si `zoneRadiusMeters > 0` — affiche le pin cible + position visiteur. react-leaflet déjà présent dans le projet.
- [x] ✅ **`hasStats` — vérifier l'usage dans visitapp et l'écran stats manager-app**
- visitapp + tablet-app : `statisticsService` initialisé sous `hasStats == true` ✓
- manager-app : menu item gardé par `hasStats == true` (main_screen.dart ligne 65) ✓
- manager-app : `_load()` dans `statistics_screen.dart` garde ajoutée — early return si `hasStats != true` ✓
- `hasAdvancedStats` et `statsHistoryDays` gérés correctement dans `StatisticsScreen` ✓
---
## Accessibilité — Conformité WCAG 2.1 AA
> Audit du 2026-08-05 : **aucune des trois apps visiteur n'est conforme**. L'accessibilité n'a jamais été traitée.
> Enjeu réglementaire : European Accessibility Act applicable depuis juin 2025 — les institutions culturelles publiques sont généralement soumises à des obligations d'accessibilité dans leurs marchés publics. Peut devenir bloquant commercialement, ou au contraire un argument de vente (aucun concurrent du comparatif ne met l'accessibilité en avant).
### État des lieux mesuré
| Indicateur | mymuseum-visitapp | tablet-app | visitapp-web |
|---|---|---|---|
| Fichiers UI | 140 `.dart` | 78 `.dart` | 41 `.tsx` |
| `Semantics(` / `role=` | 0 | 0 | 0 |
| `semanticLabel` / `aria-label` | 0 | 1 | 16 (sur ~19 boutons icône) |
| `Tooltip` | 0 | 1 | — |
| Indicateur de focus | n/a | n/a | 1 `focus-visible`, 3 `outline-none` |
| `alt` descriptif | n/a | n/a | quasi tous vides (`alt=""`) |
| `lang` dynamique | n/a | n/a | ❌ `lang="en"` codé en dur |
### Échecs identifiés
- [ ] ❌ **1.1.1 Contenu non textuel — images de contenu sans alternative**
- visitapp-web : `alt=""` sur les images d'étapes de parcours, les visuels de questions/réponses de quiz (`QuestionPuzzle`, `GameSection`), les photos de points de carte (`MapSection`), les images de sections
- Une question de QCM illustrée devient totalement inaccessible
- Nécessite un champ `imageAltText` (traduisible) côté backend + manager-app, sinon on ne peut pas décrire correctement le contenu métier
- [ ] ❌ **4.1.2 Nom, rôle, valeur — widgets interactifs muets (Flutter)**
- ~40 fichiers de `mymuseum-visitapp` utilisent `GestureDetector` / `InkWell` / `onTap` sur des `Container`, `Stack` ou images sans `Semantics` : TalkBack/VoiceOver n'annonce rien ou une zone muette
- 18 `IconButton` sans `tooltip` dans les deux apps Flutter
- Chantier principal : envelopper chaque zone tapable dans `Semantics(button: true, label: ...)`
- [ ] ❌ **2.4.7 Visibilité du focus (web)**
- 3 fichiers appliquent `outline-none` sans style de focus de remplacement
- Aucun `focus-visible` sur la majorité des contrôles
- [ ] ❌ **2.1.1 Clavier (web)**
- 0 `tabIndex`, 0 `onKeyDown` — les interactions montées sur `div`/`onClick` (carte, pièces de puzzle, cartes de sections) sont inatteignables au clavier
- [ ] ❌ **3.1.1 Langue de la page (web)**
- `layout.tsx` : `lang="en"` en dur alors que l'app est multilingue → doit refléter la langue courante
- Bonus au passage : `metadata.title` est encore `"Create Next App"`
- [ ] ❌ **1.3.1 / 2.4.1 Structure et navigation (web)**
- 0 `<nav>`, 0 skip-link, 0 `sr-only`
- Hiérarchie de titres à vérifier (`<h1>` présent dans 4 fichiers seulement)
- [ ] ❌ **1.4.3 Contraste — audit complet à faire**
- Palette non auditée. Suspects : textes clairs sur images de fond (`#F4ECD8` sur photo, overlays à `opacity: 0.65`), 2 occurrences de `text-[10px]` sous le seuil de lisibilité
- Le contenu venant du CMS, il faut aussi garantir un contraste minimum quelle que soit l'image uploadée par le client (scrim / dégradé systématique)
- [ ] ❌ **2.3.3 Animations — `prefers-reduced-motion` non géré (web)**
### Points déjà conformes (à ne pas casser)
- [x] ✅ **1.4.4 Redimensionnement du texte**
- Web : tailles en `clamp()` / rem, aucun `px` figé sur les polices
- Flutter : aucun override de `textScaler` / `MediaQuery` → le zoom texte système est respecté par défaut
- [x] ✅ Aucun blocage du zoom (pas de `userScalable=no` / `maximumScale`)
### Estimation & stratégie
- Chantier estimé à **plusieurs jours par app** (~220 fichiers d'UI au total), plus l'audit de contraste sur toute la palette et des tests réels au lecteur d'écran (TalkBack, VoiceOver, NVDA)
- **Priorisation suggérée** : `visitapp-web` d'abord (le plus proche de la conformité, et c'est la cible du Plan Essentiel self-service donc le plus exposé), puis `mymuseum-visitapp`, puis `tablet-app` en dernier (kiosk : pas de lecteur d'écran utilisateur, mais le contraste et la taille des cibles tactiles restent pertinents)
- **Prérequis produit** : champ `imageAltText` traduisible sur les ressources — sans lui, l'accessibilité des images de contenu est structurellement impossible
### Une fois conforme — communication commerciale
> ⚠️ **À ne faire qu'après validation réelle de la conformité** (audit + tests lecteur d'écran). Annoncer une conformité WCAG non atteinte est un risque juridique, pas juste une exagération marketing.
- [ ] ❌ **myinfomate-landing — mettre l'accessibilité en avant**
- Ajouter un bloc/argument dans `ModulesSection.tsx` ou la section features de `HomeClient.tsx`
- Textes à ajouter dans `src/data/translations.ts` (toutes les langues du site)
- Angle de vente : conformité **WCAG 2.1 AA** + **European Accessibility Act** → dédouane l'institution culturelle dans ses obligations légales et ses marchés publics. Aucun concurrent du comparatif (Smartify, SmartGuide, STQRY, PandaSuite, Orpheo) ne communique sur l'accessibilité — c'est un différenciateur net.
- Cibler aussi les pages segment (`[segment]/SegmentPageClient.tsx`) : le secteur public y est le plus sensible
- [ ] ❌ **Déclaration d'accessibilité**
- Page dédiée sur myinfomate-landing (à côté de `mentions-legales` et `confidentialite`)
- Contenu attendu : niveau de conformité, date de l'audit, méthode d'évaluation, contenus non conformes éventuels, moyen de contact pour signaler un problème
- C'est une obligation formelle dès qu'on revendique la conformité, et les acheteurs publics la demandent
- [ ] ❌ **Support commercial** — mentionner l'accessibilité dans `DOCS/offre-commerciale.md` (⚠️ ce fichier est obsolète sur les tarifs) et dans `competitor-analysis.md` comme argument différenciant
- [ ] ❌ **unov-landing** — mention plus légère côté société (engagement accessibilité), à voir si pertinent
---
## Décisions techniques actées
### Onboarding & paiement (Plan Essentiel web)
| Décision | Choix retenu | Raison |
|---|---|---|
| Paiement | **Stripe** (carte + SEPA Direct Debit) | Meilleur DX, self-service total, webhooks fiables |
| TVA | **Stripe Tax** + validation **VIES UE** à l'inscription | 0% intracommunautaire si N° TVA valide, 21% sinon |
| Essai gratuit | **Sans carte, 14 jours**, watermark "Preview" permanent sur toutes les vues visiteur, quota IA ~20-30 requêtes sur toute la durée (pas par jour) | Institutions culturelles n'ont pas toujours de carte pro, plus de signups — watermark + quota IA limitent l'abus du trial en prod gratuite |
| Conversion | Email J+10 + J+14 avec lien Stripe Checkout | Conversion douce, pas de carte piégée |
| Facturation/Peppol | **Accountable manuel** (V1) | Volume faible au départ, automatisation API Accountable en V2 |
| Envoi email | **Resend** (free tier 3 000 emails/mois) | Délivrabilité pro (SPF/DKIM/DMARC), DX excellent, intégration C# simple |
| Logique email | **manager-service** via `IEmailService` | Centralisé, réutilisable pour toutes les features futures |
| Templates email | **HTML Razor** dans manager-service, couleurs MyInfoMate | Pas de dépendance externe pour les templates |
| Auto-gestion client | **Stripe Customer Portal** | Annulation, changement carte sans intervention |
| Plans automatisables | **Essentiel uniquement** (€39/mois, web-only) | Pro/Premium/Enterprise = app native = setup physique = contact commercial |
| Page factures manager-app | **V2** (V1 = email uniquement depuis Accountable) | Pas de valeur immédiate, complexité inutile au départ |
| Dashboard super-admin | **V2** dans manager-app (SuperAdmin only) | Priorité au produit d'abord |
| Assistant IA sur Essentiel/Pro | **Add-on payant** (~39-49€/mois, quota réduit vs Premium), pas inclus dans le prix de base | Évite de cannibaliser l'argument de montée en gamme vers Premium (179€ = native app + IA 2000 req + traduction + stats illimitées), tout en ouvrant l'IA aux petits budgets web-only comme levier d'upsell (2026-07-16) |
---
## Actions manuelles à ne pas oublier
Ces éléments **ne peuvent pas être générés par code** et doivent être faits manuellement.
### Firebase — visitapp
- [ ] Télécharger `google-services.json` depuis [Firebase Console](https://console.firebase.google.com) → Project settings → App Android → placer dans `mymuseum-visitapp/android/app/`
- [ ] Télécharger `GoogleService-Info.plist` → App iOS → placer dans `mymuseum-visitapp/ios/Runner/`
### Firebase Storage — CORS pour le logo du rapport PDF
- [ ] `gsutil cors set` sur le bucket, pour autoriser la lecture des octets d'image depuis `manager.myinfomate.be`
- Le rapport PDF met le logo du client en page de garde : il le lit **par requête HTTP depuis le navigateur**. Sans en-tête CORS, le navigateur refuse les octets et la page de garde retombe sur le seul nom de l'instance
- Dégradation silencieuse et volontaire : le rapport se génère quand même. **Donc si le logo manque à l'ouverture du PDF, la cause est ici** — pas dans le code
- À traiter avec [v2/media-storage-plan.md](v2/media-storage-plan.md), qui touche déjà le bucket
### Migrations EF Core — manager-service
- [x] `dotnet ef migrations add AddIsSyncedVideoFieldsToEventAgenda` + `dotnet ef database update`
- Colonnes : `IsSynced` (bool), `IdVideoYoutube` (text), `VideoLink` (text), `VideoResourceId` (text FK)
- [x] Migrations `AddStatsHistoryToSubscriptionPlan`, `FixPremiumStatsSettings`, `AddQuotaFieldsToInstance` — appliquées
- `SubscriptionPlan` : `HasStats`, `StatsHistoryDays`, `HasAdvancedStats`
- `Instance` : `StorageQuotaBytes`, `AiRequestsPerMonth`, `HasStats`, `StatsHistoryDays`, `HasAdvancedStats` (copiés depuis le plan à la création)