Deux chantiers menés en parallèle, consignés dans la même passe parce que l'index de ce repo est partagé et que leurs modifications s'entrelacent dans STATUS.md, v1-plan.md et kanban.html. Lot K — les apps visiteur parlent l'ancien contrat. Découvert en lançant le premier vrai flutter build apk sur tablet-app depuis des mois, la commande que trois documents disaient « à reconfirmer ». Le diagnostic « un seul défaut, purement Gradle, peut-être réglé par 5a6701d » était doublement faux : six crans d'outillage Android (Gradle 7.5 → 8.11.1, AGP 7.2.0 → 8.9.1, Kotlin 2.0.10, option AGP supprimée, jcenter mort), corrigés le 12/08, puis 132 erreurs Dart que le Gradle masquait. Elles tiennent en trois causes — les cinq champs de ConfigurationDTO migrés vers AppConfigurationLinkDTO, GeoPointDTO.latitude/longitude devenus GeometryDTO geometry avec PostGIS, et les min() sur dynamic qui en découlent. mymuseum-visitapp a déjà fait ce portage : c'est une copie, pas une conception. Bloquant de la bascule (lien L19) : les tablettes en service chez MDLF et au Fort ne s'arrêteraient pas proprement le jour J, elles dégraderaient sans erreur visible. +3 à 5 jours de travail déjà dû. Périmètre kiosk tranché : SectionParcours exclu définitivement d'une borne fixe, SectionEvent à supporter. Leçon générale ajoutée au §1bis — un build jamais lancé ne vaut pas mieux qu'un build vert supposé, et « à reconfirmer par un vrai build » veut dire que ce n'est pas confirmé. Lot F — le volet manager-app est livré. Écran d'audit log sur GET /api/Audit réservé au SuperAdmin, et compteur « X / 5 utilisateurs » avec bouton d'ajout désactivé au plafond. Deux dettes serveur ouvertes par ce chantier et laissées ouvertes à dessein, le périmètre étant manager-app seul : le plafond de 5 n'est appliqué nulle part côté serveur — ce qui est livré est un garde-fou d'interface, un POST direct sur l'API passe toujours — et aucune section n'est journalisée, AuditedTypes.Contains exigeant l'égalité exacte de type alors que Section est abstraite, si bien que le journal couvre tout sauf le contenu, précisément ce que l'écran devait tracer. Chacune a sa carte au kanban et son entrée dans todo-features.md. Compteurs du kanban recomptés : Urgent 4, Migration v3 3, Bugs 5, À tester 6, Planifié 20, Bascule prod 7, Fait récemment 36. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 KiB
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-appet ce qui est réellement rendu dansmymuseum-visitapp/visitapp-websont 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-webne build pas en production —npm run build→Failed to type check, 7 erreurs TS sur l'indexation degeometry.coordinates(typéunknown,lib/api/types.ts:191) :components/sections/map/LeafletMap.tsx(5 erreurs) +components/sections/MapSection.tsx(2). Invisible ennext devcar 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,IHttpContextAccessordansDbContextFactory), utilisateur de test ajouté là où les contrôleurs ont gagné un contrôle d'autorisation (StatsController,AiController), tests passés enasync, test obsolèteCreateUser_NullPassword_Returns400réécrit (le comportement est devenu une invitation par email). Nouveau fichierSectionParcoursControllerTests.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()appelaitAuditLogs.Add()à l'intérieur de la boucle surChangeTracker.Entries()→InvalidOperationException: Collection was modifiedsur toute écriture d'entité auditée. 41 tests le reproduisaient. Personne ne l'avait vu parce que la suite ne compilait plus.
- ⚠️ Bug backend découvert au passage et corrigé :
- ❌ Client généré
manager_api_new/désynchronisé — dontmodel/game_types.dart:28qui contient encoreEscape = 2alors 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 parDateFrom.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— filtredateFrom >= DateTime.Today - Visitapp et tablet-app :
sectionAgendaApi.sectionAgendaGetUpcomingEvents()viafromDto() SectionAgendaApiajouté auClientdes deux apps
- Endpoint
-
✅ SectionAgenda ne montre pas les events du jour même
- Corrigé côté backend : filtre
dateFrom >= DateTime.Today(début du jour, pasDateTime.Now)
- Corrigé côté backend : filtre
-
✅ 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
- Champs ajoutés :
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 lesSectionWeatheravec une ville non-vide
Sections manquantes
-
✅ SectionEvent — page dédiée
- Aucun
case SectionType.Eventdanssection_page.dart SectionEventDTOporte :startDate,endDate,baseSectionMapId,globalMapAnnotations,programme(List<ProgrammeBlock>),parcoursIdsProgrammeBlock:id,title,description,startTime,endTime,mapAnnotations- Endpoints API côté SectionEvent :
sectionEventGetProgrammes,sectionEventGetGlobalMapAnnotations,sectionEventGetProgrammeBlockMapAnnotations - Structure prévue :
- Header : titre (
sectionEventDTO.title), datesstartDate…endDate, image de fond (imageSource) - TabBar 3 onglets :
- Programme — timeline verticale des
ProgrammeBlock, bloc actif mis en évidence (heure courante dansstartTime…endTime), tap → BottomSheet détail + annotations du bloc - Carte —
FlutterMap(même pattern queguided_path_map_progression_page.dart) chargé depuisbaseSectionMapId, couche annotations globales + couche annotations du bloc actif (couleur différente) - Parcours — liste des
GuidedPathliés au SectionEvent, tap →GuidedPathMapProgressionPageexistant
- Programme — timeline verticale des
- Bouton retour cerclé
kMainColorenPositioned(top: 35, left: 10)(convention app)
- Header : titre (
- Note traductions : titre/description viennent directement de
sectionEventDTO.title(List<TranslationDTO>) — utiliserTranslationHelper.get(sectionEventDTO.title, visitAppContext), pas de clé locale
- Aucun
-
✅ SectionEvent — mise en avant sur le home screen
- Si
applicationInstanceDTO.sectionEventId != null, afficher un bloc "à la une" en haut dehome_3.0.dartà la place de la photo principale - Contenu : image de l'event (
imageSource), titre, bouton générique pour ouvrir leSectionDetaildu SectionEvent - Le
sectionEventIdest surAppConfigurationLinkApplicationInstance(nested dansapplicationInstanceDTO.configurations[].applicationInstance.sectionEventId) - Tap →
SectionPageavec leSectionEventDTOcorrespondant
- Si
Game — Escape mode
- ✅ Escape game — liste des parcours dans
game_page.dart— ⚠️ obsolète (révisé 2026-08-05) :GameTypes.Escapea été retiré du backend (enum GameTypes { Puzzle, SlidingPuzzle }) par le refacto SectionParcours.game_page.dartne teste plus quePuzzle/SlidingPuzzle(lignes 136, 279, 491) et l'escape game passe désormais par SectionParcours (IsGameModesurGuidedPath). 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:28contient encoreEscape = 2
- Reliquat à nettoyer : le client généré
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/isHiddenInitiallycomme flags booléens simples
Types de cadenas / systèmes de validation (manquants)
- ✅ Digicode — fait le 2026-07-16, sans changement backend/modèle :
QuestionType.Simplereste 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
GameTypesglobaux ; les intégrer commeQuestionTypedans unGuidedSteppour 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
isHiddenInitiallyest un booléen fixe, pas conditionnel. - ❌ Compte à rebours global — timer sur toute la durée de l'escape (différent du
isStepTimerpar étape déjà implémenté). S'affiche en permanence en haut deGuidedPathContentProgressionPage.
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.dartgère désormaisGameTypes.SlidingPuzzle- Composant
SlidingPuzzlePieceavec 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/GameMessageFinont finalement été mis surGuidedPath(pas surSectionParcours) — 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?.Urll.81)ShowMap✅ Resté sur SectionParcours, absent deGuidedPath- Migrations
20260708143339_RemoveGuidedPathSectionMapRelation,20260629150749_MoveGameModeToGuidedPath
- Migrations
3. Nettoyage GuidedStep (backend)
-
❌ Corriger
GuidedStep.ImageUrl— toujours pas fait (vérifié 2026-07-15)GuidedStep.cs:39:ImageUrlreste unstringstocké brut, pas deImageResourceId- Remplacer par
ImageResourceId(FK Resource) +ImageUrl(computed depuis la ressource) - Cohérent avec toutes les autres sections du projet
- Migration :
FixGuidedStepImageToResource
-
✅ Exposer
ZoneRadiusMetersdans le manager-app (vérifié 2026-07-15)manager-app/lib/Screens/Configurations/Section/SubSection/Parcours/showNewOrUpdateGuidedStep.dart— champ présent (GuidedStep.cs:37côté backend)
4. Refactoring SectionGame (backend + manager-app)
- ✅ Retirer
GameTypes.Escapede SectionGame (vérifié 2026-07-15)SectionGame.cs:68-71:enum GameTypes { Puzzle, SlidingPuzzle }—Escapebien retiré- Aucun onglet "Escape Game" résiduel trouvé dans
game_config.dart - ⚠️ Pas vérifié en détail : que
GameMessageDebut/GameMessageFinsoient 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 dansResponses[0].label(réutilise le champ existant, multilingue viaTranslationAndResourceDTO), 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
QuestionTypeni d'ajouterExpectedAnswer:Responses[0].labelfait 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 lesGuidedStepd'un Parcours — uneSectionQuizclassique (quiz autonome, pas dans un parcours) utilise un éditeur différent et plus ancien (new_update_question_quizz.dart, modèleQuestionDTO) qui ne fait que du QCM, sans texte libre/puzzle/digicode.
- ❌ Unifier
SectionQuizsur le modèleQuizQuestion(au lieu du legacyQuestionDTO)- 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 dequizz_config.dart/new_update_question_quizz.dartpour utilisershowNewOrUpdateQuizQuestion.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
ParcoursIdsdeSectionEvent— toujours pas fait (vérifié 2026-07-15 :SectionEvent.cs:26toujours présent)- Champ
List<string> ParcoursIdsdocumenté comme inutilisé (remplacé parGuidedPath.SectionEventId) - Migration :
RemoveParcoursIdsFromSectionEvent
- Champ
7. Vérification SectionMap — champs potentiellement legacy
- ⚠️ Vérifier
MapResourceId/MapResourceMapMapType,MapTypeMapbox,MapMapProvider: garder — potentiellement utiles si on veut supporter d'autres providers de tuiles à l'avenirMapResourceId/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) etSubSection/Parcours/(parcours_config.dart,guided_path_editor.dart,guided_path_api.dart,progression_mode.dartetFields/:parcours_fields.dart,etape_fields.dart,question_fields.dart). Les troisshowNewOrUpdate…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éfauttrue - Picker "Carte de référence" (
BaseSectionMapId) —l.70-90, affiché sishowMap == trueet qu'au moins une SectionMap existe
- Toggle "Afficher la carte" (
- Toggle "Mode jeu" (
IsGameMode) +GameMessageDebut/GameMessageFin: sur le GuidedPath, pas sur la section (Fields/parcours_fields.dart) — écart assumé documenté au point 1 - Gestion des GuidedPaths :
parcours_config.dart→guided_path_editor.dart - ❌ Reste : les tooltips ℹ️ (1 seul
Tooltipdans tout le dossier Parcours, sur la case « bonne réponse » deFields/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
TriggerGeoPointIdn'existe pas dans le modèleGuidedStep. Le géodéclenchement d'une étape se configure via leGeometryInputContainer(position/zone sur mini-carte,showNewOrUpdateGuidedStep.dart:128-136) + toggleisGeoTriggered(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(+ dossierGuidedPath/) etvisitapp-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é à revaliderSectionParcoursSectioncomponent (visitapp-web) / page (mymuseum-visitapp)- Si
ShowMap = false→ mode contenu : liste d'étapes avec progression (actuelEscapeProgression) - Si
ShowMap = true→ mode carte : carte + pins des étapes + position live (actuelGuidedPathMapProgressionPage) - Si
BaseSectionMapIddéfini → charger les GeoPoints de la SectionMap comme fond de carte - Si
IsGameMode = true→ afficherGameMessageDebutau 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.dartsimapDTO.guidedPaths?.isNotEmpty GuidedPathListSheet— bottom sheet listant les parcours (triés parorder)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
- Bouton "Parcours" dans
-
✅ Vue liste (
isListViewEnabled)map_page.dart: simapDTO.isListViewEnabled == true, bouton toggle "Liste / Carte" en bas à droite- Vue liste :
ListViewsur les_geoPointsfiltrés (titre, description, miniature)
-
✅ Annotations carte globales + par bloc programme (SectionEvent)
globalMapAnnotationsaffichées dans la preview et dansEventMapFullPage(couleur principale)- Annotations du bloc actif affichées en orange par-dessus, preview et page plein écran
- Fix client généré :
MapAnnotation.geometrychangé deMapAnnotationGeometry→EventAddressDTOGeometrypour exposer.coordinates
Notifications push
- ✅ Réception et affichage des notifications push
- Backend + manager-app : ✅ complets
- Visitapp :
pushNotificationService.dartcomplet (foreground local notif, background, terminated) - Tap →
MaterialBannerdansconfiguration_page.dart - Préférences (subscribe/unsubscribe) dans
home_3.0.dart - ⚠️ Action requise :
google-services.json(Android) etGoogleService-Info.plist(iOS) à télécharger depuis la console Firebase (Project settings → ton app) et à placer dansandroid/app/etios/Runner/
Parcours guidés — UI de progression
-
✅ Affichage d'un parcours guidé / étape (contexte SectionMap)
GuidedPathMapProgressionPage: carte + bottom sheet draggable, position live, pins d'étapesGuidedStepTimer: countdown MM:SS (mode peek) + barre de progression colorée (mode étendu)- Quiz par étape :
QuestionsListWidgetréutilisé, conversionQuizQuestion → 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 franchissableisHiddenInitially: ⚠️ (révisé 2026-08-05) aucun consommateur dans aucune des apps visiteur, alors que le toggle est exposé dansshowNewOrUpdateGuidedStep.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 (aucunGeolocator, pas de_checkGeoZone), niisStepLocked, niisLinear, nihideNextStepsUntilComplete. Seuleguided_path_map_progression_page.dartles 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
GuidedPathContentProgressionPagebranchée dansgame_page.dart(caseGameTypes.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.resourceaccepte 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
showNewOrUpdateContentSlideret y ajouterResourceType.PDFcôté étape uniquement (ne pas l'ouvrir au Slider sans y réfléchir) ; (2) casPDFdansgetElementForResource(marker_view.dart) — il n'en a aucun aujourd'hui ; (3) cas PDF dansResourceViewer.tsxcô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
- 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 (
Statistiques
-
✅ Tracking des événements — ⚠️ (révisé 2026-08-07) « complet » est faux, voir les deux trous ci-dessous
- ✅
sectionViewetsectionLeave(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(+ArticleReadajouté 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
GuidedPathCompleteen fin d'enum (les valeurs sont persistées en int), puis appelertrack()dansGuidedPathContentProgressionPageetGuidedPathMapProgressionPage— 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
- Relevé le 2026-08-07 en construisant le rapport PDF : le sommaire annonçait « parcours terminés », rien ne le produit.
-
❌
VisitEventType.AssistantMessageexiste dans l'enum mais personne ne l'émet- Relevé le 2026-08-07 : 10 appels à
track()dansmymuseum-visitapp, aucun pour l'assistant. La valeur d'enum est là depuis le début, inutilisée - Un appel dans
AssistantChatSheet(etAssistantBubble.tsxcôté web) donne le volume d'usage du guide IA tout de suite - ⚠️ (révisé 2026-08-09) La table
VisitorQuestionexiste désormais (migrationAddVisitorQuestiondu 2026-08-09, entité +DbSet), mais rien n'y écrit : aucune insertion dansAiController. C'est l'étape suivante côté guide IA — voir v2/guide-ia-screen-plan.md §7.VisitorQuestionporte le texte des questions,AssistantMessageseulement 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
- Relevé le 2026-08-07 : 10 appels à
-
⚠️
DayStatDTOne ventile que Mobile et TabletTotal,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é parbeacon_scanner: ^0.0.4(^0.1.2 requiert Flutter ≥ 3.35.7)- Scanning iBeacon restauré dans
configuration_page.dart:BeaconScanner.instance.ranging(), filtre parminorId+accuracy, cooldown entre popups — ⚠️ (révisé 2026-08-05) ce champ n'existe pas dansGuidedStep.TriggerGeoPointIdGuidedStep.cs. Le géodéclenchement se fait viaGeometry+IsGeoTriggered+ZoneRadiusMeters, et uniquement dansGuidedPathMapProgressionPage- 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 dansguided_path_map_progression_page.dart:247, pas dans la page mode contenu - ⚠️ (révisé 2026-08-05)
Section.meterZoneGPSest configurable par section danssection_detail_screen.dartmais ignoré :configuration_page.dart:43utilise une constante en durint meterToBeacon = 100; - ✅ Clés de traduction
beaconFound/beaconFoundBodyajoutées danstranslations.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 flagisPanoramasurResourceDTO) — 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 dansshow_element_for_resource.dartoucached_custom_resource.dart: siresource.type == Panorama360→Panorama(image: NetworkImage(...))au lieu deImage.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
- Backend : ajouter
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èleArAnchor, 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 overlayModè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
ArAnchorsde la configuration dansAppConfigurationDTO(commebeaconSections) - Génération du
.mind— Option B : microservice Node.js séparé- Nouveau service Docker
mind-ar-compiler(~50 lignes Express) avec un endpointPOST /compile: reçoit l'image, appellemind-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 depuismarkerResourceId, appelle le microservice, stocke le.mindcomme ressource, metArAnchor.status = "ready"(ou"error") - Le backend .NET reste pur — zéro Node.js dans le container principal
- Nouveau service Docker
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
.mindfiles de tous lesArAnchors(comme lesbeaconSections) - 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 (viaar_flutter_plugin_updated)
ArAnchorstéléchargés et cachés localement dansdownloadConfiguration.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 :
jsQRtourne 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 →
postMessagevers 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.mindtéléchargés au boot de la configuration sont injectés depuis le stockage local viaJavascriptChannel. Zéro réseau requis au moment du scan. - 3D objects (optionnel, phase 2) :
ar_flutter_plugin_updatedpour poser des.glbsur le marqueur détecté
AI Assistant
- ✅ UI chat / assistant IA
AssistantChatSheetcomplète (bulles, cards, navigation vers section)- Ouverte depuis
home_3.0.dartetconfiguration_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()dansdownloadConfiguration.dartlignes 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 NULLexiste dans le schéma SQLite mais n'est jamais remplie (insert probablement silencieusement KO) - Seules les sections
ArticleetQuizsont persistées en DB locale (ligne 151) — les autres types ignorés cleanLocalResources()commentée (ligne 221) — nettoyage désactivé- Grosse classe
DownloadConfigurationentiè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
dataJSON sur leSectionDTO. Maintenant chaque type de section a son propre DTO (SectionArticleDTO,SectionQuizDTO, etc.) récupéré via un endpoint dédié. LeSectionDTOactuel ne contient que les métadonnées — il n'y a plus de champdata.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 (colonnedataou 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
- Le download des fichiers binaires (images, audio, vidéos, PDFs) fonctionne déjà —
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
SectionFormavecList<FormField>(types :text,rating,single_choice,multiple_choice) +FormResponsepour 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
- Backend : nouveau modèle
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/Userfiltré à 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:InstanceIdforcé à l'instance de l'appelant (l.133), pas celui envoyé par le client - ✅ Protection contre l'escalade de rôle :
PUT/DELETErefusés siuser.Role < GetCallerRole()(l.210,l.266)
Ce qui reste réellement à faire — révisé le 2026-08-12
- ✅ Manager-app : compteur « X / 5 utilisateurs » + bouton d'ajout désactivé au quota — livré le 2026-08-12 (
kMaxUsersPerInstancedansconstants.dart,users_screen.dart). Le compteur et le plafond ne s'appliquent pas au SuperAdmin : sonGET /api/Userrenvoie toutes les instances, compter cette liste contre un plafond par instance n'aurait aucun sens - ✅ Le rôle
SuperAdminest déjà masqué pour unInstanceAdmin— la question était ouverte, elle est fermée :_allowedRolesfiltre surr >= callerRole(users_screen.dart:40), un appelant de rôle 1 ne se voit proposer que 1/2/3. C'était fait, jamais consigné - ❌ Plafond de 5 utilisateurs par instance, côté serveur — aucun contrôle de comptage dans
UserController.CreateUser, pas de 422. ⚠️ Ce que manager-app vient de livrer est un garde-fou d'interface, pas une règle : unPOST /api/Userdirect sur l'API passe toujours, et rien n'empêche une instance d'avoir 12 utilisateurs. Le plafond n'est pas non plus dansSubscriptionPlan, qui ne porte que 5 champs (stockage, jetons IA, stats, rétention, stats avancées) — s'il devait varier par plan, c'est une colonne à ajouter, donc un changement de schéma après le gel du lot B - ⚠️ Retour d'erreur du POST, corrigé côté front dans la même passe :
invokeAPIne lève pas sur un code d'erreur et son résultat était ignoré — un e-mail déjà utilisé (409) ou un rôle refusé (403) laissait la liste inchangée sans aucun message. Le statut est désormais testé et le corps affiché. C'est ce qui rendra le futur 422 visible sans retouche front
Comportement attendu (spec d'origine)
- Un
InstanceAdminpeut 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ôleSuperAdminest réservé au SuperAdmin lui-même - Le
SuperAdmingarde son comportement actuel : aucune restriction
Backend
UserController: exposer les endpointsGET /api/UseretPOST /api/UserauxInstanceAdmin(actuellement SuperAdmin only ?)POST /api/Userdepuis unInstanceAdmin: valider queinstanceId== 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/Userdepuis unInstanceAdmin: 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 auxInstanceAdminavec vue filtrée à leur instance - Masquer le champ de sélection de rôle
SuperAdmindans le formulaire de création si l'appelant estInstanceAdmin - Afficher un compteur "X / 5 utilisateurs" + désactiver le bouton d'ajout si quota atteint
- ✅ Contrôleur sous policy
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) viaAuditLog+AuditController(SuperAdmin only, filtrable parinstanceId/entityType/userId/dates). Rien côté manager-app ne consomme cet endpoint (vérifié 2026-07-15). Chaque entité auditée porte désormais aussiDateCreation/DateUpdateauto-remplis par leDbContext.
-
✅ Backend — AuditLog fiable sur toutes les écritures
SaveChanges()(sync) etSaveChangesAsync()déclenchent tous les deux l'audit (avant : seulSaveChangesAsyncl'alimentait, les controllers en sync passaient sous le radar)DateCreation/DateUpdateauto-remplis viaIAuditableEntity+ChangeTracker, plus besoin de le faire à la main dans chaque mapper
-
✅ Manager-app — écran "Activité / Audit" (SuperAdmin uniquement) — livré le 2026-08-12
Screens/Audit/audit_screen.dart+audit_entry.dart, entrée de menumenuId: 13conditionnée àrole.value == 0(même règle que la policySuperAdminde l'endpoint)- Liste triée par date décroissante, filtres instance / type d'entité / utilisateur / plage de dates, pagination 50 par page, clic sur une ligne → détail avant/après en table champ / avant / après
manager_api_newn'a pas été touché. Le plan initial disait « nouveau modèle + service dansmanager_api_new/» ; l'écran passe par unhttp.getdirect avec le Bearer, comme_loadKnowledge/_loadInsights/_reindexdu Guide IA. Une lecture seule ne justifie pas d'étendre un client qui s'édite à la main — et le déclencheur de génération (openApiTest.dart) n'existe plus depuis le lot A- Deux détails qui ne se devinent pas : le contrôleur renvoie les entités brutes (pas de DTO), donc les champs sont ceux d'
AuditLogen camelCase ; ettoest pris à la fin de la journée choisie, sinon un filtre « jusqu'au 12/08 » exclut tout le 12/08 - Les valeurs multilingues (
List<TranslationDTO>) sont rendues enFR : …par ligne plutôt qu'en JSON brut — c'est le gros des colonnes modifiées sur une section ou une configuration
-
❌ Backend — aucune section n'est jamais journalisée (découvert le 2026-08-12 en câblant l'écran)
AuditedTypes.Contains(entry.Entity.GetType())(MyInfoMateDbContext.cs:126) exige une égalité de type exacte. OrSectionest abstraite (Section.cs:17) : le type runtime est toujoursSectionMap,SectionQuiz,SectionArticle… La lignetypeof(Section)d'AuditedTypesne matche donc rien- Conséquence :
Resource,Configuration,Device,User,Instancesont bien journalisés, le contenu ne l'est pas — exactement ce que l'usage cible voulait tracer (« qu'est-ce que ce client a fait durant telle période ? ») - Correctif attendu : tester
AuditedTypes.Any(t => t.IsInstanceOfType(entry.Entity))au lieu deContains. Vérifier au passage queEntityTypeporte alorsSectionMapet nonSection— l'écran affiche la valeur brute pour tout type qu'il ne connaît pas, donc il fonctionnera sans retouche, mais le filtre « Section » ne matchera plus rien : il faudra soit normaliser côté serveur, soit élargir la liste du front aux 13 sous-types - Le filtre « Section » est déjà dans l'écran : le jour où le backend corrige, le front n'a rien à changer. D'ici là ce filtre ne rend aucune ligne
Traduction automatique via IA
- ✅ 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é dansAssistantService), retourne{ [lang]: translatedText }. Logique séparée deChatAsync, 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)
- Backend : nouveau endpoint
SectionParcours — refonte du flux de configuration
- Sortir de l'empilement de popups — maquette 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.82de 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/jsonDecodeet 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 deComponents/(leur problème estsize.width * 0.2dansNumberInputContaineret desfontSizede label de 16 à 25 px selon le composant, pas leur conception). - Vérification : test-plan §19.13 cas 0.
- 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 (
GuidedStep — champs avancés non exposés
- ✅ Champs manquants dans
showNewOrUpdateGuidedStep.dart- Toggles
isHiddenInitially,isStepLocked,isStepTimerajoutés - Champs
timerSeconds+timerExpiredMessage(multilingue) affichés si timer activé triggerGeoPointIdexposé en saisie texte (ID numérique)
- Toggles
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)
- Section "Annotations" ajoutée dans
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=truequi 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 pasSectionGameId- Fixé dans
SectionMapController.cs—existingGuidedPath.SectionGameId = guidedPathDTO.sectionGameIdajouté
- Fixé dans
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+AiUsageMonthKeysurInstance, incrémenté dansAiController.cslignes 56–63) mais aucun plafond n'est vérifié - Backend : ajouter un champ
AiQuotasurInstance(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 / aiQuotadans 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")
- Le comptage existe déjà (
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 enAiTokensPerMonth/AiTokensThisMonth(long)AssistantService.cs:TranslateAsync/ChatAsynclisentresponse.Usage?.TotalTokenCountet le remontent via le nouveau champTokensUsedsurAiChatResponse/AiTranslateResponseAiController.cs: le hard-block est vérifié avant l'appel Gemini sur l'historique cumulé, puisAiTokensThisMonthest 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 viadotnet 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/InstanceDTOrenommés en conséquence,quota_bars_widget.dartetmain_screen.dartaffichent 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.SizeBytesest stocké à l'upload (ResourceController.csligne 272) mais aucune agrégation ni vérification n'existe- Backend : agréger
SizeBytessur 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
SubscriptionPlan—StatsHistoryDays(int, 0 = illimité) etHasAdvancedStats(bool). MigrationAddStatsHistoryToSubscriptionPlan. - Backend :
StatsController.GetSummary—Include(SubscriptionPlan), plafonnefromsiStatsHistoryDays > 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,_buildTablesRowaffiche un bloc "verrouillé Premium" à la place des tables si!hasAdvancedStats.
- Modèle : deux nouveaux champs sur
-
✅ 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 danstranslations.tsmais 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 HasAdvancedStatsreste 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'estInstance.StatsHistoryDaysque litStatsController. Sans cette reprise les clients actuels seraient restés à 30 jours. À appliquer viadotnet 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
- Pourquoi : la landing ne promettait aucune durée (
-
⚠️ Purge des
VisitEvent— codée, volontairement inactive (2026-08-09)VisitEventPurgeService+ job Hangfirevisit-events-purge(cron0 3 * * *), enregistrés- Ne supprime rien tant que
Stats:RetentionDaysn'est pas défini dans la configuration. Le job log et sort - ⛔ Ne l'activer qu'une fois le
pg_dumpquotidien 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=395dans le.env
-
⚠️
GetSummaryagrège en mémoire — à passer en SQL avant de monter en chargeeventsQuery.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)
MyInfoMateDbContextsèmeplan-starter,plan-standard,plan-premium,plan-essentiel. Il n'existe pas deplan-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 headerX-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,CreateApiKeyRequestmis à jour (backend + modèle Flutter), colonne "Expiration" ajoutée dans le tableau (rouge si expirée)
- Fixé : date picker ajouté dans
-
❌ 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
AddApiKeysvide — 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 leports:binding public du servicedb(ou le remplacer par127.0.0.1:5432:5432pour 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:5432tunnelé sur le port distant) — plus de connexion directe - Pourquoi : la base de données ne doit jamais être exposée publiquement, même avec un mot de passe fort
Suivi
✅ Terminé
| Feature | Backend | Manager-App | VisitApp |
|---|---|---|---|
| SectionEvent display (page dédiée) | ✅ | ✅ | ✅ |
| SectionEvent mise en avant home | ✅ | ✅ | ✅ |
| Escape game display | ✅ | ✅ | ✅ |
| Puzzle sliding display | ✅ | ✅ | ✅ |
| GuidedPath display | ✅ | ✅ | ✅ |
| GuidedStep avancé (timer, lock) | ✅ | ✅ | ✅ |
| Annotations bloc programme | ✅ | ✅ | ✅ |
| Vue liste (isListViewEnabled) | ✅ | ✅ | ✅ |
| Annotations carte globales + bloc (event) | ✅ | ✅ | ✅ |
| Push notifications | ✅ | ✅ | ✅ |
| Stats tracking complet | ✅ | N/A | ✅ |
| Bug SectionGameId update | ✅ | N/A | N/A |
| SectionAgenda sync + hang service | ✅ | ✅ | ✅ |
| SectionAgenda events du jour | ✅ | N/A | ✅ |
| EventAgenda infos complètes + vidéo | ✅ | ✅ | ✅ |
| SectionWeather hang service | ✅ | N/A | N/A |
| Traduction automatique IA | ✅ | ✅ | N/A |
| Quota IA (hard block + warning 80%) | ✅ | ✅ | N/A |
| Quota stockage par plan | ✅ | ✅ | N/A |
| Stats filtrées par plan (30j / illimité) | ✅ | ✅ | N/A |
| AuditLog fiable (sync+async) + DateUpdate auto | ✅ | N/A | N/A |
| SectionParcours (modèle + controller + TPH) | ✅ | ⚠️ non revérifié | ⚠️ non revérifié |
| Nettoyage GuidedPath (FK, image, ShowMap déplacé) | ✅ | ⚠️ non revérifié | N/A |
| Refacto SectionGame (retrait GameTypes.Escape) | ✅ | ✅ | N/A |
| Digicode (pavé numérique, détection auto) + confirmation texte libre insensible casse/accents | N/A | N/A | ✅ (2026-07-16) |
⚠️ Partiel — à tester / compléter
| Feature | Backend | Manager-App | VisitApp |
|---|---|---|---|
| Beacons / geofencing | ✅ | ⚠️ | ✅ |
| AI Assistant | ✅ | ⚠️ | ✅ |
| Mode offline configurations sélectives | ⚠️ | N/A | ⚠️ |
| Repasse UI globale | N/A | ⚠️ | ⚠️ |
❌ À faire
| Feature | Backend | Manager-App | Landing | VisitApp-Web |
|---|---|---|---|---|
| Mode offline — réécriture complète | ❌ | N/A | N/A | ❌ |
| Audit log API Keys | ❌ | N/A | N/A | N/A |
| Audit log — écran consultation SuperAdmin | ⚠️ ✅ sauf les sections, jamais journalisées (2026-08-12) | ✅ 2026-08-12 | N/A | N/A |
| Rate limiting API Keys | ❌ | N/A | N/A | N/A |
| Gestion users par instance admin (max 5, pas de SuperAdmin) | ⚠️ scoping ✅, plafond 5 ❌ (revérifié 2026-08-12) | ✅ compteur « X / 5 » + bouton désactivé (2026-08-12) — garde-fou d'interface seulement | N/A | N/A |
| Infra — Port PostgreSQL fermé (accès via tunnel SSH uniquement) | ❌ | N/A | N/A | N/A |
| GuidedPath — Image de couverture (ImageResourceId backend) | ✅ | ✅ (révisé 2026-08-05) | N/A | ✅ via imageUrl |
| GuidedPath/SectionParcours — Flag ShowMap backend | ✅ (sur SectionParcours) | ✅ section_parcours_config.dart:63 (révisé 2026-08-05) |
N/A | ✅ ParcoursSection.tsx:101 |
| GuidedPath — Picker SectionMap liée (UI) | N/A | ✅ section_parcours_config.dart:70-90 (révisé 2026-08-05) |
N/A | N/A |
| — | 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 buildsurvisitapp-web→ échoue (Failed to type check, 7 erreurs TS dansmap/LeafletMap.tsxetMapSection.tsx). Compilation Turbopack OK, échec au type-check — c'est pour ça quenext devne le montre pas.dotnet test→ le projet de tests ne compile pas (3 ×CS7036).dotnet builddu projet principal passe, mais la suite de tests non.flutter analyzesurmymuseum-visitapp→ 906 issues dont 8 erreurs (4 ×kElevenLabs*non défini dansServices/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.dartettest/widget_test.dartcassé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 | €99–179+/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_URLintroduite (.env.local+.env.production)
-
⚠️ 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 premierUserenInstanceAdmin(mot de passe hashé scrypt viaProfileLogic) - 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'emailsServices/TrialLifecycleService.cs, job Hangfire quotidientrial-lifecycle-daily: emails J+7/J+10/J+14 puisIsActive = falsesiTrialEndsAtdépassé sans abonnementApiKeyAuthenticationHandlerrefuse les instancesIsActive = 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) dansAiController, cumulé sur les 14 jours
-
⚠️ Conversion vers plan payant — implémenté, à tester
POST /api/onboarding/checkout-session(policyAppReadAccess) → URL Stripe Checkout hébergée- Déclenché depuis
manager-app/lib/Screens/Billing/subscription_screen.dart(url_launcherviawindow.open) - Webhook
POST /api/webhooks/stripe→checkout.session.completedetinvoice.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 admin →
fransolet.thomas@gmail.com(nouveau compte, conversion, paiement échoué) -
❌ Créer l'alias
onboarding@myinfomate.bechez 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 unreply_todansResendEmailService.
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-servicelancé surlocalhost:5000myinfomate-landing:npm run dev(port 3000)- Webhook Stripe en local :
stripe loginpuisstripe listen --forward-to localhost:5000/api/webhooks/stripe, et coller lewhsec_de la CLI (≠ celui du dashboard) dansappsettings.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,StripeCustomerIdrempli) etUsers(1 ligneInstanceAdmin,Passwordhashé — 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.completedreçu et forwardé en 200 - En base :
IsTrialActive = f,StripeSubscriptionIdrempli - 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
TrialEndsAtdans le passé en base, déclenchertrial-lifecycle-dailydepuis/hangfire - →
IsActive = f, et les appels visiteur avec laPublicApiKeysont 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
AiTokensThisMonthau-delà du quota → le message affiché doit être « Quota IA mensuel dépassé » (et non l'ancienne erreur générique) - Forcer
TrialAiTokensUsed >= 300000sur une instance en essai → « Quota IA de la période d'essai dépassé » - Vérifier que
/chatet/translatedé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
- Prompts disponibles dans
-
⚠️ Loader / Splash screen brandé par instance (backend existant, rendu manquant)
- Backend : ✅
LoaderImageUrlexiste déjà surApplicationInstance,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
loaderImageUrldansApplicationInstanceDTOetConfigurationDTO, mais pas de composant splash screen qui l'utilise → à implémenter - mymuseum-visitapp : ⚠️
splash_screen.dartexiste 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)
- Backend : ✅
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.tsxn'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 :
statisticsServiceinitialisé soushasStats == true✓ - manager-app : menu item gardé par
hasStats == true(main_screen.dart ligne 65) ✓ - manager-app :
_load()dansstatistics_screen.dartgarde ajoutée — early return sihasStats != true✓ hasAdvancedStatsetstatsHistoryDaysgérés correctement dansStatisticsScreen✓
- visitapp + tablet-app :
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
- visitapp-web :
-
❌ 4.1.2 Nom, rôle, valeur — widgets interactifs muets (Flutter)
- ~40 fichiers de
mymuseum-visitapputilisentGestureDetector/InkWell/onTapsur desContainer,Stackou images sansSemantics: TalkBack/VoiceOver n'annonce rien ou une zone muette - 18
IconButtonsanstooltipdans les deux apps Flutter - Chantier principal : envelopper chaque zone tapable dans
Semantics(button: true, label: ...)
- ~40 fichiers de
-
❌ 2.4.7 Visibilité du focus (web)
- 3 fichiers appliquent
outline-nonesans style de focus de remplacement - Aucun
focus-visiblesur la majorité des contrôles
- 3 fichiers appliquent
-
❌ 2.1.1 Clavier (web)
- 0
tabIndex, 0onKeyDown— les interactions montées surdiv/onClick(carte, pièces de puzzle, cartes de sections) sont inatteignables au clavier
- 0
-
❌ 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.titleest encore"Create Next App"
-
❌ 1.3.1 / 2.4.1 Structure et navigation (web)
- 0
<nav>, 0 skip-link, 0sr-only - Hiérarchie de titres à vérifier (
<h1>présent dans 4 fichiers seulement)
- 0
-
❌ 1.4.3 Contraste — audit complet à faire
- Palette non auditée. Suspects : textes clairs sur images de fond (
#F4ECD8sur photo, overlays àopacity: 0.65), 2 occurrences detext-[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)
- Palette non auditée. Suspects : textes clairs sur images de fond (
-
❌ 2.3.3 Animations —
prefers-reduced-motionnon géré (web)
Points déjà conformes (à ne pas casser)
- ✅ 1.4.4 Redimensionnement du texte
- Web : tailles en
clamp()/ rem, aucunpxfigé sur les polices - Flutter : aucun override de
textScaler/MediaQuery→ le zoom texte système est respecté par défaut
- Web : tailles en
- ✅ 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-webd'abord (le plus proche de la conformité, et c'est la cible du Plan Essentiel self-service donc le plus exposé), puismymuseum-visitapp, puistablet-appen dernier (kiosk : pas de lecteur d'écran utilisateur, mais le contraste et la taille des cibles tactiles restent pertinents) - Prérequis produit : champ
imageAltTexttraduisible 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.tsxou la section features deHomeClient.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
- Ajouter un bloc/argument dans
-
❌ Déclaration d'accessibilité
- Page dédiée sur myinfomate-landing (à côté de
mentions-legalesetconfidentialite) - 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
- Page dédiée sur myinfomate-landing (à côté de
-
❌ Support commercial — mentionner l'accessibilité dans
DOCS/offre-commerciale.md(⚠️ ce fichier est obsolète sur les tarifs) et danscompetitor-analysis.mdcomme 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.jsondepuis Firebase Console → Project settings → App Android → placer dansmymuseum-visitapp/android/app/ - Télécharger
GoogleService-Info.plist→ App iOS → placer dansmymuseum-visitapp/ios/Runner/
Firebase Storage — CORS pour le logo du rapport PDF
gsutil cors setsur le bucket, pour autoriser la lecture des octets d'image depuismanager.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)
- Colonnes :
- Migrations
AddStatsHistoryToSubscriptionPlan,FixPremiumStatsSettings,AddQuotaFieldsToInstance— appliquéesSubscriptionPlan:HasStats,StatsHistoryDays,HasAdvancedStatsInstance:StorageQuotaBytes,AiRequestsPerMonth,HasStats,StatsHistoryDays,HasAdvancedStats(copiés depuis le plan à la création)