# 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\), `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\) — 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("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 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** : deux dossiers coexistent — `SubSection/SectionParcours/section_parcours_config.dart` (la section elle-même) et `SubSection/Parcours/` (`parcours_config.dart`, `showNewOrUpdateGuidedPath.dart`, `showNewOrUpdateGuidedStep.dart`, `showNewOrUpdateQuizQuestion.dart`). 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 (`showNewOrUpdateGuidedPath.dart:176-180`) — écart assumé documenté au point 1 - Gestion des GuidedPaths : `parcours_config.dart` - ❌ Reste : les **tooltips ℹ️** (1 seul `Tooltip` dans tout le dossier Parcours, dans `showNewOrUpdateQuizQuestion.dart:237`) — 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 107–129 - 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 334–466) — 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` (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 - ❌ **Plafond de 5 utilisateurs par instance** — aucun contrôle de comptage dans `UserController`, pas de 422 - ❌ **Manager-app** : compteur "X / 5 utilisateurs" + désactivation du bouton d'ajout au quota - ❓ À vérifier : le champ de rôle `SuperAdmin` est-il masqué dans le formulaire manager-app pour un `InstanceAdmin` ? #### 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 - [ ] ❌ **Manager-app — écran "Activité / Audit" (SuperAdmin uniquement)** - Nouveau modèle + service dans `manager_api_new/` en lien avec `AuditController` (`GET /api/Audit`) — rien n'existe encore côté client généré, à ajouter à la main (pas de régénération OpenAPI, cf. politique du projet) - Écran liste : date, instance (nom, pas juste l'ID), type d'entité, action (Create/Update/Delete), utilisateur — tri par date décroissante - Filtres : par instance (picker), par type d'entité, par utilisateur, par plage de dates - Tap sur une ligne → détail avant/après (`OldValues`/`NewValues` formatés, pas le JSON brut) - **Usage cible** : le SuperAdmin (Thomas) veut pouvoir répondre à "qu'est-ce que ce client a fait durant telle période ?" sans aller chercher en base - Accès : nouvelle entrée de menu visible uniquement si `role == SuperAdmin` --- ### 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` — 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 56–63) 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 | ✅ | ❌ | 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 ❌** (révisé 2026-08-05) | ⚠️ compteur "X / 5" ❌ | 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 | €99–179+/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 `