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