Securite
- RequireAppKey : filtre qui exige une cle d'API valide ou un utilisateur du
manager. Pose sur les routes de contenu consommees par les apps visiteur
(Configuration, Section, Resource, SectionMap, SectionEvent, SectionAgenda,
SectionParcours, SectionQuiz, ApplicationInstance). Un [Authorize] d'action
ne peut pas assouplir celui de la classe ; seul [AllowAnonymous] le
court-circuite, et le filtre redevient le controle d'acces. Il ferme
l'enumeration par identifiant, pas la confidentialite : la cle s'obtient
par le slug ou le pincode.
- Instance/slug/{slug} rendait le pinCode, l'adresse de facturation, la TVA et
les quotas a qui lit l'URL du site visiteur. StripCommercialFields est
desormais applique sur slug et byPin ; isTrialActive reste expose pour le
filigrane d'essai.
- Device.Create et Device/{id}/detail repondaient 403 a toute tablette depuis
a452f4a (13/03) : la classe exige InstanceAdmin, une cle ne porte que
AppRead. Ouverts a la cle, le cloisonnement par instance etait deja ecrit.
Canal VR
- Device.AppType (defaut Tablet, backfill a 1) ; Create resout
l'ApplicationInstance sur ce type au lieu de Tablet en dur.
- Get filtre optionnellement par appType ; DeviceDetailDTO expose appVersion
et lastSeen.
- PUT Device/{id}/heartbeat : batterie, version, connexion. Volontairement
etroit, une app ne peut ni se renommer ni changer d'instance.
- ApiKeyAppType.VrApp en fin d'enum.
Contenu immersif
- ResourceType : Image360 (11), Video360 (12), Model3D (13), en fin d'enum.
- Section Scene3D : un modele GLB et ses points d'interet, en objet manipule
ou en decor habite. Les points sont des GeoPoint, avec une LocalTransform
en jsonb (convention glTF) ; CRUD dans SectionScene3DController.
- Instance.HasImmersiveContent : l'add-on ajoute 100 Go au quota de stockage,
repose apres un changement de plan et retire a la desactivation.
- ImmersiveBackground (owned) sur Configuration et ApplicationInstance, avec
une image de repli pour les canaux qui ne rendent pas l'immersif.
Export de configuration
- exportVersion (1) et generatedAt : le JSON devient un contrat, lu tel quel
par l'app Unity.
- Section.ToDTO() n'etant pas virtuelle, l'export ne portait aucun champ
specifique de sous-type. Passe par SectionFactory.ToDTO, et charge les
points des Map et des Scene3D.
- Le fond immersif et son repli partent avec les ressources, URL resolues.
Migrations : AddAppTypeToDevice, AddScene3DSectionAndImmersiveAddon,
AddImmersiveBackground.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ApplicationInstance porte désormais AppName (traductions, jsonb),
IsQRCodeEnabled (défaut true) et les liens App Store / Play Store.
Une ligne existe par (instance, type d'app) : chaque réglage vaut
donc séparément pour le mobile et pour le web.
Les liens stores ne sont modifiables que par un SuperAdmin — c'est
Unov qui publie les apps —, contrôlé côté API et pas seulement masqué
dans l'UI.
La liste anonyme des apps expose le slug web et le nom de l'instance :
la page /download de visitapp-web n'a que l'instanceId d'un QR imprimé.
Réserve les slugs download, demo et api : un segment statique de
visitapp-web l'emporte sur [slug], l'instance serait inaccessible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Table QuestionThemeMonthly (instance, mois, thème, compteur). C'est elle qui rend
tenable le §8.4 des CGU : le regroupement vivait dans ThemeId, colonne de la ligne
VisitorQuestion, donc la purge du 90e jour l'emportait avec la question et le
client perdait tout au 91e. Elle ne porte que des compteurs — aucune donnée
personnelle, ce qui est précisément ce qui l'autorise à survivre. Insights lit
désormais les thèmes dans cette table, pas dans les questions de la fenêtre.
Liste fixe de 8 thèmes, pas de thèmes découverts par l'IA : des libellés
régénérés à chaque passage donneraient « Horaires » en janvier et « Questions
d'horaires » en février, deux lignes distinctes et une courbe qui ne veut rien
dire — alors que la table existe pour porter cet historique.
Le job tourne à 2 h, la purge à 3 h 30 : une question purgée avant d'avoir été
classée ne compte dans aucun agrégat et rien ne peut la rattraper. Le plafond de
500 par passage ne perd rien, il retarde — les plus anciennes d'abord, un passage
par jour, avertissement si le retard dépasse un passage. Les jetons ne sont pas
décomptés du quota client : il n'a pas demandé ces appels. Un lot en échec n'est
pas marqué « Autre » pour s'en débarrasser, ce serait une perte définitive
maquillée en résultat ; et la relecture se fait par numéro, jamais par position,
pour qu'une ligne manquante ne décale pas les suivantes.
Instance.IsVisitorQuestionCollectionEnabled (défaut true) + garde dans Chat : le
client est responsable de traitement, la collecte était inconditionnelle.
Ajout d'une fabrique design-time : EF construisait tout l'hôte pour trouver le
contexte, et l'hôte ouvre une connexion au démarrage — générer une migration
exigeait donc une base joignable, impossible sur une machine sans Postgres ni
Docker.
dotnet test : 211 passés, 15 sautés, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
VoiceSessions : nombre de sessions ayant utilisé le vocal au moins une fois, lu
dans le JSON de Metadata. Volontairement hors d'AppTypeDistribution — le vocal
n'est pas une plateforme mais un mode d'interaction : un visiteur passe des
lunettes à l'écran dans une même session, or la distribution compte une entrée
par session tranchée sur l'événement le plus ancien. Le chiffre aurait dépendu de
l'ordre dans lequel le visiteur a touché ses appareils. Colonne Metadata déjà
existante : aucune migration, le gel du lot B tient.
Le test InMemory ne prouve rien ici — le provider évalue le filtre côté client,
donc une requête intraduisible passerait au vert. Un test Postgres l'accompagne.
IsAutoTriggered devient IsVisitorQuestion (défaut true). Le nom d'hier ne
couvrait qu'un des deux cas : le vrai sens n'est pas « déclenché
automatiquement » mais « ce tour n'est pas une question de visiteur », ce qui
vaut aussi pour l'aperçu de conversation, où c'est le gestionnaire qui teste sa
personnalité. Renommé pendant qu'un seul commit en dépendait. Le défaut à true
est délibéré : une app visiteur publiée qui n'envoie pas le champ continue de
journaliser, et un test le fixe.
dotnet test : 204 passés, 14 sautés, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RecordVisitorQuestion écrivait Question = request.Message. Or le prompt d'un
déclenchement automatique est une consigne que le système s'écrit à lui-même
(« Tu es un guide audio de musée. Le visiteur vient d'entrer dans la zone X… ») :
chaque passage devant une œuvre aurait ajouté une fausse question de visiteur
dans l'onglet Guide IA, l'écran fait pour montrer ce que les humains demandent.
Et HasAnswer se déduisant des sources, un prompt d'accueil sans citation aurait
gonflé le bloc ambre des questions sans réponse. Même famille que
WeatherSyncService noyant le journal d'audit.
AiChatRequest.IsAutoTriggered, et Chat qui saute la journalisation.
Tranché : ne rien journaliser, plutôt que marquer d'un drapeau. Une ligne
VisitorQuestion sert au rapport de trous de contenu et aux thèmes du lot J ; un
prompt machine n'alimente ni l'un ni l'autre, et le garder marqué obligerait
chaque futur agrégat à penser à l'exclure — la classe de panne qu'on venait de
fermer sur AuditedTypes.
Mais les jetons restent comptés : ces tours coûtent de l'argent réel au quota du
client, et un audioguide proactif peut en consommer beaucoup. Les 2 tests fixent
la paire — pas de ligne journalisée, compteur incrémenté — parce que c'est la
combinaison qui compte, pas chaque moitié isolément.
dotnet test : 199 passés, 14 sautés, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Customer Portal : StripeService.CreateBillingPortalSessionAsync +
POST /api/onboarding/billing-portal, calqué sur checkout-session.
Une garde absente de l'énoncé : StripeCustomerId peut être nul, car
CreateCustomerAsync n'est appelée que par l'inscription self-service.
Les 4 instances venues de Mongo n'ont jamais vu Stripe — sans la garde,
elles recevaient une erreur d'API Stripe illisible côté front. 409,
distinct du 404 d'instance inconnue.
Ratio jetons -> questions : InstanceQuotaDTO.aiTokensPerQuestion, mesuré
sur les VisitorQuestion.TokensUsed de l'instance, seuil de 20 questions
avant de faire foi — une seule réponse citant un long article doublerait
la moyenne. En dessous, repli sur l'hypothèse de la grille tarifaire
(10 000, pas 1 000 : le /1000 de manager-app était faux d'un facteur 10).
Trois autres points du lot F étaient déjà faits et n'attendaient qu'une
vérification : rate limiting, endpoint ApplicationInstance, quotas seed.
6 tests. dotnet test : 197 passés, 14 sautés, 0 échec.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le client ne pouvait pas savoir qu'une ressource avait change : il ne
recevait que dateCreation. La colonne existait deja sur l'entite, elle
n'etait simplement pas serialisee.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
LOT B — une seule migration EF (LotB_FreezeSchema) :
- SectionMap.MapResourceId → IconResourceId. L'écart (g) de la bascule tombe
avec. Il fallait renommer aussi la propriété de navigation MapResource :
la convention EF l'appariait au FK, la laisser aurait fabriqué un FK
fantôme. Elle n'était utilisée nulle part ailleurs.
- SectionEvent.ParcoursIds supprimé (champ, DTO, SectionFactory, et une
initialisation dans un montage de test).
- Instance.IsImageWatermark remplace le `instanceId == "633ee379…"` en dur
de ResourceController.
EF a généré un RenameColumn, pas un drop+add : les icônes déjà configurées
survivent. L'avertissement de perte de données ne porte que sur le DropColumn
de ParcoursIds, ce qui est l'intention.
Non fait, et c'était une erreur de doc : « supprimer SectionEvent.IconResourceId ».
Ce champ n'existe pas — la ligne visée appartient à la classe imbriquée
MapAnnotation, partagée par SectionEvent, SectionAgenda et SectionMap, lue par
cinq contrôleurs et par GetReferencedResourceIds. La supprimer aurait cassé
les icônes d'annotation des trois types et la collecte offline.
SÉCURITÉ (lot A, même repo) :
- AuthenticationController.Authenticate : un bloc #if DEBUG écrasait l'email
et le mot de passe reçus par un compte de test, donc toute compilation en
Debug authentifiait n'importe quelle saisie. Retiré.
- EnableSensitiveDataLogging (qui écrit les valeurs des paramètres dans les
logs) passe sous #if DEBUG, l'idiome déjà employé dans Startup.cs pour le
CORS et Hangfire. Le Dockerfile publiant en -c Release, c'est un verrou réel.
LOT C1 :
- Calculateur StoragePath/SizeBytes extrait dans Helpers/ResourceStorage.cs,
avec 13 tests fixant l'invariant des types URL. Il ferme le lien L5 : le
backfill (C2) et l'écart (e) de la migration appelleront le même code.
- L'extraction a révélé la divergence qu'elle devait empêcher : des deux
chemins de création de ResourceController, le chemin multipart écrivait
SizeBytes mais laissait StoragePath nul.
dotnet build Debug et Release verts, dotnet test 143/143 (130 + 13).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ingestion et indexation
- IIngestionService/IngestionService : chargement des collections filles par
sous-type, un jeu de morceaux par langue, ChunkIndex continu.
- SectionIndexingInterceptor retenu comme unique déclencheur : les 5
sous-contrôleurs totalisaient 30 SaveChanges et 0 Enqueue, donc ajouter des
points d'intérêt à une carte ne réindexait rien.
- HTML retiré avant l'embedding et lignes trop longues recoupées : sans cela un
article dépassait l'entrée max du modèle et emportait son lot de 50 morceaux.
- Gabarits de LanguageInit filtrés, DistinctBy(Text) avant le Take : ils
occupaient les cinq premiers résultats d'une recherche en néerlandais.
Endpoints du guide IA
- GET /api/Ai/knowledge/{id} : agrégats sur ContentEmbedding, donc sur ce qui
est réellement indexé — compter les sections publiées serait plus flatteur et faux.
- GET /api/Ai/insights/{id} : miroir de GuideIaInsights côté manager-app, c'est
l'écran qui a fixé la forme pour que le job de thèmes la remplisse.
RGPD
- VisitorQuestion journalisée dans AiController.Chat. HasAnswer se déduit des
sources du retrieval, pas du texte : un repli poli ressemble à une réponse.
L'écriture n'échoue jamais la réponse au visiteur.
- VisitorQuestionPurgeService, 90 jours, actif sans condition de configuration :
une durée écrite dans les CGU n'est pas un réglage commercial.
Corrections
- Updateinstance ne recopiait pas les quotas du nouveau plan.
- CheckQuota ne bloquait ni ne comptait à quota 0 — IA gratuite non comptée.
- StoragePath et SizeBytes renseignés à Create, types URL exclus.
dotnet build 0 erreur, dotnet test 130/130.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
75 fichiers, ~1 mois de travail depuis f222861 (17/07). Contenu :
Onboarding self-service — jamais exécuté de bout en bout
OnboardingController, StripeWebhookController, StripeService, ResendEmailService
+ IEmailService (10 templates), EmailTemplates/, TrialLifecycleService (Hangfire),
PasswordTokenHelper, SlugHelper, 5 DTOs. Essai 14 j, Stripe customer/Checkout/Tax,
mot de passe oublié + invitation user, plafond IA d'essai.
Schéma Postgres v3 — passe pendant que la base est vide
ContentEmbedding + index HNSW (vector_cosine_ops), IEmbeddingService +
GoogleEmbeddingService (gemini-embedding-001, 768 dims), Deployment/Dockerfile.postgres
(postgis 3.4.3 + pgvector 0.8.6, épinglé par digest — un tag mobile rejouerait le
warning de collation glibc). Colonnes Resource : StoragePath, FileName,
IncludeInAiKnowledge, AiIndexStatus + nouveaux ResourceType ajoutés EN FIN d'enum.
Guide IA
Champs Guide* sur Instance + InstanceDTO, AssistantService lit la configuration client
dans les 4 blocs de prompt (ton codé en dur retiré, règle hors-sujet ajoutée aux deux
variantes qui n'en avaient pas), IHttpClientFactory à la place des new HttpClient().
Table VisitorQuestion + ConversationId sur AiChatRequest.
Stats
Rétention unifiée à 13 mois (instances ET plans), VisitEventPurgeService.
Nettoyage
IsStepLocked / IsHiddenInitially / FactContent supprimés de GuidedStep — IsStepLocked
rendait une étape définitivement infranchissable même après réussite.
SectionMap allégé (-57 lignes).
Tests
SectionParcoursControllerTests, FakeConfiguration, FakeEmailService.
ContentEmbedding a cassé 116 tests sur 124 (EF InMemory ne connaît pas Vector) :
l'entité est exclue quand le provider n'est pas Npgsql. Conséquence assumée —
le vector store n'est couvert par aucun test. dotnet test 124/124.
10 migrations EF. Base locale à jour, dotnet build 0 erreur.
Rien n'est en prod : la bascule Mongo → Postgres est décrite dans DOCS/STATUS.md §1quinquies.
⚠️ appsettings.json contient les clés Stripe (test) et Resend (prod) en clair — à rotationner.