DOCS/todo-features.md
Thomas Fransolet c69cb15ba2 D1 corrigé : le bug offline nº1 était 283 lignes de code mort
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:57:23 +02:00

89 KiB
Raw Blame History

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 — 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 productionnpm run buildFailed 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
  • 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

  • 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
  • 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
  • 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)
  • 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

  • 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

  • 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
        • CarteFlutterMap (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
  • 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

  • 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)

  • 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.
  • 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

  • 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)

  • 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)

  • 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
  • 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)

  • 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 (QuestionDTOQuizQuestion), 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.

  • 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.dartguided_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

  • 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
  • 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)
  • 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 MapAnnotationGeometryEventAddressDTOGeometry pour exposer .coordinates

Notifications push

  • 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

  • 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 M7/W7
  • 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) — 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

  • 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
    • articleReadarticle_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 §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

  • 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 — gyroscope + drag interactif. À intégrer dans show_element_for_resource.dart ou cached_custom_resource.dart : si resource.type == Panorama360Panorama(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 + jsQRpas 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

  • 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

    • 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.

  • 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

  • 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 popupsmaquette du 2026-08-09 : claude.ai/code/artifact/c9d9a5c0
    • 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

  • 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

  • É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

  • UpdateGuidedPath() ne copie pas SectionGameId
    • Fixé dans SectionMapController.csexistingGuidedPath.SectionGameId = guidedPathDTO.sectionGameId ajouté

Plans & Quotas

Quota assistant IA

  • 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

  • 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

  • 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

  • Restreindre les stats selon le plan (Standard = 30 jours, Premium = illimité)

    • Modèle : deux nouveaux champs sur SubscriptionPlanStatsHistoryDays (int, 0 = illimité) et HasAdvancedStats (bool). Migration AddStatsHistoryToSubscriptionPlan.
    • Backend : StatsController.GetSummaryInclude(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.
  • 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

  • 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
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 testle 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.

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)

  • ⚠️ 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)
  • ⚠️ Création automatique de l'instance — implémenté, à tester

    • Controllers/OnboardingController.csPOST /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)

  • ⚠️ 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
  • ⚠️ 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/stripecheckout.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).

  • Templates HTML aux couleurs MyInfoMate (bandeau sombre #0a1222, CTA cyan #0df2df)

  • ⚠️ 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

  • ⚠️ Email 2 — Confirmation démarrage essai (J+0) — à tester

  • ⚠️ Email 3 — Check-in onboarding (J+7) — à tester (déclenché par Hangfire)

  • ⚠️ Email 4 — Rappel fin d'essai (J+10) — à tester (Hangfire)

  • ⚠️ Email 5 — Dernier jour d'essai (J+13/14) — à tester (Hangfire)

  • ⚠️ Email 6 — Paiement confirmé — à tester (webhook Stripe)

  • ⚠️ Email 7 — Paiement échoué — à tester (webhook Stripe)

  • ⚠️ Email 8 — Invitation nouvel utilisateur — nouveau, hors périmètre initial

  • ⚠️ Email 9 — Mot de passe oublié — nouveau, hors périmètre initial

  • ⚠️ Notifications adminfransolet.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. Inscriptionhttp://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.
  • 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)

  • 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
  • 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 → 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, qui touche déjà le bucket

Migrations EF Core — manager-service

  • dotnet ef migrations add AddIsSyncedVideoFieldsToEventAgenda + dotnet ef database update
    • Colonnes : IsSynced (bool), IdVideoYoutube (text), VideoLink (text), VideoResourceId (text FK)
  • Migrations AddStatsHistoryToSubscriptionPlan, FixPremiumStatsSettings, AddQuotaFieldsToInstance — appliquées
    • SubscriptionPlan : HasStats, StatsHistoryDays, HasAdvancedStats
    • Instance : StorageQuotaBytes, AiRequestsPerMonth, HasStats, StatsHistoryDays, HasAdvancedStats (copiés depuis le plan à la création)