15 Commits

Author SHA1 Message Date
Thomas Fransolet
2dc0cfceeb Lot F : portail de facturation Stripe et coût réel d'une question
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>
2026-08-13 10:10:50 +02:00
Thomas Fransolet
fa92395d17 D2 : exposer DateUpdate sur ResourceDTO pour le filtre incremental
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>
2026-08-12 17:46:37 +02:00
Thomas Fransolet
7a80766d43 Le journal d'audit ne suit plus les ecritures de la machine
Regression introduite par le commit qui journalise les sections.
WeatherSyncService ecrit `section.WeatherResult` sur cron, a 6 h et a 13 h.
Le job existait et etait benin tant que les sections n'etaient pas auditees ;
depuis, chaque rafraichissement produisait une ligne portant la prevision
OpenWeather complete en avant ET en apres -- quelques dizaines de Ko, deux
fois par jour, par section meteo, indefiniment.

Le stockage est le moindre probleme : ce bruit noie les modifications
humaines que l'ecran d'audit existe pour montrer.

Correctif : une liste de colonnes machine (WeatherResult,
WeatherUpdatedDate, DateUpdate) exclues du journal, et surtout aucune ligne
produite quand une modification ne touche qu'elles -- plutot qu'une ligne au
diff vide, qui aurait deplace le bruit sans le retirer.

DateUpdate y est pour une raison distincte de la meteo : estampille a chaque
SaveChanges, il figurait dans tous les diffs sans jamais rien y apprendre. Le
signal etait deja la -- un test ecrit hier devait l'ecarter a la main pour
rester lisible (`newValues.Keys.Where(k => k != "DateUpdate")`). Quand un
test doit filtrer une donnee pour etre lisible, la donnee n'a rien a y faire.

Verifie au passage : AgendaSyncService ecrit des EventAgenda, qui ne sont pas
audites, et ne touche pas la ligne Section. Lui n'est pas concerne.

⚠️ Consequence sur la dette signalee hier : sans le flot meteo, la table ne
grossit plus qu'au rythme des editions humaines -- de l'ordre de quelques
dizaines de milliers de lignes par an sur 4 clients. L'index sur Timestamp
evoque hier n'a plus lieu d'etre, donc pas de migration et le gel du lot B
n'est pas remis en cause. La purge reste pertinente, mais comme sujet RGPD
(lot J), plus comme sujet de volume.

3 tests. dotnet test 203/203.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 16:01:01 +02:00
Thomas Fransolet
2b1b20cd49 Le vector store est enfin eprouve a DEUX instances, sur un vrai Postgres
Le RAG n'avait tourne que sur une base a une seule instance -- le cas ou le
post-filtrage HNSW ne se voit pas. 14 tests Testcontainers sur l'image de
`Deployment/Dockerfile.postgres`, donc le meme PostGIS + pgvector que la
prod. Ils se sautent proprement (SkippableFact) sans demon Docker.

Ce que ca etablit :

- pgvector 0.8.6 : `SET hnsw.iterative_scan` est accepte. Ce SET est pose a
  chaque recherche par SearchAsync et n'existe qu'a partir de 0.8 -- sur une
  version anterieure, toute recherche echouait en production.
- Les extensions vector et postgis sont bien creees par les migrations.
- A deux instances, l'une saturant l'axe de la question a 30 contre 1,
  SearchAsync rend le bon nombre de resultats, tous de la bonne instance.
  Aucune fuite d'un client vers un autre.

⚠️ DEUX RESULTATS INATTENDUS, contraires a ce que le plan supposait.

1. Ce qui protege du post-filtrage n'est PAS le parcours iteratif, c'est
   l'index sur (InstanceId, ContentType). Le planificateur filtre par
   instance d'abord et trie exactement : l'index HNSW n'est jamais touche,
   donc il n'y a rien a post-filtrer. Verifie a 620 lignes (test) et a
   22 000 hors suite, avec 2 000 lignes pour l'instance cible. Un test fige
   ce plan -- si cet index disparaissait, la recherche se degraderait sans
   qu'aucune erreur ne le dise.

2. Et si on retire cet index, `hnsw.iterative_scan = relaxed_order` NE
   RATTRAPE RIEN : le parcours s'epuise apres ~335 lignes sans avoir atteint
   l'instance minoritaire, et rend zero. Le meme jeu de donnees avec l'index
   HNSW construit APRES l'insertion rend bien ses 20 lignes -- c'est donc la
   connectivite du graphe qui decide, et nos migrations creent l'index sur
   une table vide. Le SET de SearchAsync est donc une assurance qui ne couvre
   pas ce qu'on croyait ; on le garde (il est correct et sans cout), mais
   c'est l'index qui fait le travail.

Deux notes d'outillage :

- Testcontainers est epingle en 3.10.0 : la 4.x parle l'API Docker 1.44 et
  l'engine local plafonne a 1.43.
- L'image est construite par le CLI docker, pas par le constructeur d'images
  de Testcontainers : celui-ci relit le FROM pour pre-tirer l'image de base
  et ne sait pas parser `tag@sha256:`. Ce digest protege la base d'un
  changement de glibc sous ses index -- il ne se retire pas pour un test.

S'ajoutent 4 tests de GetSummary contre le vrai Postgres, qui levent le
caveat du commit precedent : le provider InMemory evalue tout cote client,
donc il ne disait rien de la traduction SQL. Les agregats du plan de base,
ceux du plan avance, le filtre appType et la fenetre vide traduisent tous.

dotnet test 200/200, aucun saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:34:56 +02:00
Thomas Fransolet
a3bf9523e3 GetSummary agrege en SQL au lieu de charger 13 mois en memoire
eventsQuery.ToList() ramenait la fenetre entiere, toutes colonnes comprises,
pour n'en faire que des comptages. La retention est passee de 30 jours a 13
mois (StatsRetentionDays = 395) : la methode charge donc desormais treize
fois ce pour quoi elle avait ete ecrite, et ca grossit tout seul.

Ce qui part en SQL : sessions distinctes, sommes de duree par session,
duree moyenne par section, top sections, visites par jour, distributions
par session, top articles, total des scans QR.

Ce qui ne peut pas y aller : POI, agenda, quiz, jeux, menus et validite des
QR se regroupent sur du JSON dans Metadata. On ne remonte plus que les deux
colonnes utiles, et seulement pour le type d'evenement concerne (MetadataOf).

Deux effets de bord voulus :

- les stats avancees ne sont plus calculees puis effacees pour les plans qui
  n'y ont pas droit -- les six requetes ne partent pas ;
- les titres de section ne sont resolus que pour les ids reellement affiches
  (top 10 + quiz), plus pour tous les ids vus dans la fenetre.

Un changement de semantique, delibere : « la langue de la session » etait
prise sur le premier evenement rendu par la base, dans un ordre indefini.
C'est desormais le plus ancien horodatage. Meme chose pour AppType.

Le reste est preserve a l'identique, y compris les cas tordus : un scan QR
sans metadata compte dans le total sans etre ni valide ni invalide, une
metadata illisible compte la completion de quiz a zero.

11 tests ajoutes -- la branche « stats avancees » n'etait couverte par
AUCUN test : les cas existants ne seedent pas d'Instance, donc
hasAdvancedStats etait toujours faux et la moitie de la methode n'etait
jamais executee. dotnet test 186/186.

⚠️ Ces tests tournent sur le provider InMemory, qui evalue tout cote client :
ils tiennent la semantique, pas la traduction SQL. La verification sur un
vrai Postgres arrive avec les tests Testcontainers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:04:39 +02:00
Thomas Fransolet
a3e0b4df71 Le plafond de 5 utilisateurs existe enfin cote serveur
CreateUser ne comptait rien. Ce qui a ete livre le 12/08 dans manager-app --
compteur « X / 5 » et bouton d'ajout desactive -- est un garde-fou
d'interface : un POST direct sur l'API passait toujours. Le controle est
desormais fait la ou il est opposable, et rend 422.

Cote front, rien a retoucher : le resultat d'invokeAPI est deja lu depuis le
correctif du 409 e-mail deja pris, donc le message remontera tel quel.

Deux choix, tous deux documentes dans le code :

- 5 en dur. Le faire varier par plan serait une colonne sur SubscriptionPlan,
  qui n'en porte aucune sur les utilisateurs -- donc une migration apres le
  gel du schema (lot B). Dette V1 assumee.
- Le SuperAdmin n'y est pas soumis. C'est la seule porte de service qui
  reste tant qu'aucun champ ne permet de relever la limite d'un client, et
  ca s'aligne sur le front, qui ne lui montre deja pas le compteur.

L'inscription self-service (OnboardingController) n'est pas concernee : elle
cree le premier utilisateur d'une instance neuve.

4 tests : au plafond, sous le plafond, plafond par instance, SuperAdmin
exempte. dotnet test 175/175.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:55:24 +02:00
Thomas Fransolet
805ca5cf3d Le journal d'audit voyait tout sauf le contenu
AuditedTypes.Contains(entry.Entity.GetType()) exigeait l'egalite exacte de
type, or Section est abstraite : le type runtime est toujours SectionMap,
SectionQuiz... Aucune des 13 sortes de section n'etait journalisee -- soit
precisement ce que l'ecran d'audit livre le 12/08 devait tracer. Resource,
Configuration, Device, User et Instance, eux, passaient : ils sont concrets,
et c'est ce qui rendait le trou invisible.

Remplace par une remontee a la classe de base auditee (IsInstanceOfType).

EntityType porte « Section », pas « SectionMap ». Des deux sorties possibles,
c'est la normalisation cote serveur qui est retenue :

- le filtre « Section » est deja dans l'ecran et se met a rendre des lignes
  sans toucher manager-app, donc sans coordonner deux repos ;
- elargir le filtre front aurait coute 13 entrees de liste et 39 cles i18n,
  et surtout aurait laisse tout futur sous-type sortir du filtre en silence
  -- la meme classe de panne que celle qu'on ferme ici ;
- le sous-type concret n'est pas perdu : le discriminateur TPH est une
  propriete du modele, donc serialisee dans NewValues (« Discriminator »:
  « Article »). Un test le tient.

8 tests, dont un par reflexion qui affirme que les 13 sous-types concrets
resolvent bien vers Section : la liste de types d'origine n'obligeait
personne a la suivre, c'est ce qui l'a laissee devenir fausse.

dotnet test 171/171.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 14:53:21 +02:00
Thomas Fransolet
269b3f6703 Lot C : backfill des colonnes de stockage (C2) et quota autoritaire (C3)
C2 — POST /api/Resource/backfill-storage, SuperAdmin, dryRun à true par
défaut : la migration se joue sur une base vide, ce backfill sur des lignes
de production. StoragePath par ResourceStorage.PathFor, SizeBytes par HEAD.

La méthode annoncée au plan — « SizeBytes par listing du bucket Firebase » —
était inapplicable : le serveur n'avait aucun client de stockage. Le sondage
passe donc par HEAD sur l'URL publique, comme le fait déjà la migration, et
le sondeur est extrait plutôt que recopié (Helpers/ResourceSizeProbe,
consommé par MigrationController et par le backfill). Même raisonnement que
pour ResourceStorage : deux copies auraient divergé sur ce qui compte, le
sort réservé aux échecs.

L'extraction a bouché un trou que personne ne cherchait. L'original ne notait
l'échec que dans son catch, or un HEAD sur un blob absent ne lève pas : il
répond 404, sans Content-Length. Ces ressources arrivaient à 0 octet sans
figurer dans le rapport — invisibles au quota et invisibles au diagnostic,
exactement ce que le commentaire d'origine voulait empêcher.

Le « 37 lignes sur 45 » du plan n'étant pas vérifiable, le backfill rend son
propre inventaire : Orphans (aucune URL, blob peut-être jamais téléversé) et
Unsized (URL présente, bucket muet) restent séparés, ce sont deux causes
distinctes.

C3 — pré-vol du quota sur les deux chemins de création, suppression du blob
à Delete, angle mort d'Update tranché.

Deux défauts trouvés en câblant, qui n'étaient documentés nulle part :

- Le pré-vol existait déjà à moitié. Upload (multipart) contrôlait et
  renvoyait 413, Create (JSON) ne contrôlait rien — or c'est le chemin
  qu'emprunte manager-app, qui crée la ligne puis téléverse.
- Les deux lectures du quota divergeaient. Upload lisait le quota du plan,
  GetQuota celui de l'instance avec le plan en repli. Une instance à quota
  surchargé — le mécanisme même de l'add-on — affichait un chiffre à l'écran
  et se faisait bloquer sur un autre. Helpers/StorageQuota devient la seule
  source de vérité pour les deux.

Delete supprime le blob AVANT la ligne et renvoie 502 en conservant la ligne
si le bucket échoue. manager-app faisait l'inverse en avalant l'échec dans un
print : la ligne disparaissait, le blob restait, et n'ayant plus de ligne il
devenait invisible au quota tout en restant facturé. Une ressource encore
listée se rattrape ; un blob que plus aucune ligne ne désigne, non.

L'angle mort laissé ouvert par C1 était une fausse crainte : PathFor ne
construit qu'un pictures/{instanceId}/{resourceId}, le type n'entre pas dans
le chemin, il décide seulement s'il y en a un. Recalculer ne peut donc pas
pointer ailleurs, et Update rejoue Apply.

Aucun secret nouveau : FirebaseAdmin était déjà référencé pour les
notifications push et Startup charge déjà un service account, donc
Google.Cloud.Storage.V1 réutilise le même GoogleCredential. Seule s'ajoute la
clé Firebase:StorageBucket, vide par défaut — à renseigner en prod (I9),
sans quoi Delete ne supprime rien et ne prétend pas le contraire.

dotnet build vert, dotnet test 163/163 (148 au départ, +7 pour C2, +8 pour C3).

Contient aussi le correctif d'indexation préparé en parallèle : un job
Hangfire par section dans BackfillInstanceAsync au lieu d'une boucle, et un
backoff sur 429/503 dans GoogleEmbeddingService.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:05:25 +02:00
Thomas Fransolet
f9059ed198 D1 : la visite hors ligne embarque enfin ses médias
Le switch de collecte des ressources était commenté : 157 lignes mortes dans
Export, 126 dans Import. Une visite téléchargée n'embarquait donc que l'image
de la configuration, celle du loader et l'image de chaque section — ni
contenus d'articles, ni audios, ni icônes de carte, ni images de quiz.

Export matérialise désormais les entités, pas seulement leurs DTO, et appelle
GetReferencedResourceIds(language) : la méthode existait déjà sur les 13
sous-types et n'attendait que d'être appelée. Elle filtre aussi les audios par
langue, ce que le switch ne faisait pas.

Import cesse de redécouvrir les ressources section par section : l'export
embarquant tout ce qui est référencé, une passe unique sur la charge suffit.
createResource étant idempotente, les quelques appels ciblés qui subsistent
sont sans effet.

4 tests, dont un qui vérifie par réflexion que les 13 sous-types implémentent
la collecte : le trou venait d'un switch où un type oublié passait dans le
default sans bruit, et c'est ce qu'il ne faut plus pouvoir refaire.

dotnet test 148/148.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:57:22 +02:00
Thomas Fransolet
149ebc0cf1 Dry run de la bascule, et les 20 sections qui disparaissaient en silence
Le dry run est automatisé en test (MigrationDryRunTests) : il appelle le
contrôleur directement, ce qui évite la chaîne login + JWT SuperAdmin, et se
saute si aucun Mongo n'écoute sur MIGRATION_TEST_MONGO — la suite reste donc
verte sans dépendance. Les tailles de fichier sont court-circuitées : un HEAD
sur 2374 ressources prendrait des minutes pour une information que ce test ne
vérifie pas.

Il a trouvé du premier coup ce qu'aucune lecture de code n'avait vu : 327
sections dans Mongo, 307 migrées, et Erreurs : 0.

Les 20 manquantes référencent deux configurations supprimées dans Mongo — 19
articles et un slider, du contenu MDLF. La migration a raison de ne pas les
reprendre : les sections sont collectées configuration par configuration,
donc les orphelines ne sont jamais énumérées, et la FK ConfigurationId les
refuserait. Le défaut était le silence — elles partent désormais dans
Skipped, avec le détail de la configuration introuvable.

L'invariant affirmé par le test n'est donc pas « tout est migré » mais
« rien ne disparaît sans être signalé » : migrées + signalées = Mongo.

dotnet test 144/144.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:40:11 +02:00
Thomas Fransolet
7625487806 Lot B : geler le schéma, plus sécurité rapide et calculateur de stockage
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>
2026-08-11 15:32:19 +02:00
Thomas Fransolet
18e4240f0f RAG: pipeline d'ingestion, endpoints du guide IA, journalisation RGPD
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>
2026-08-11 10:48:10 +02:00
Thomas Fransolet
af6b5b76ef Self-service onboarding (Stripe + Resend) + Postgres v3 schema (pgvector, media, visitor questions)
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.
2026-08-09 22:11:48 +02:00
Thomas Fransolet
f2228617ca Add instance index + date update audit + ai quota to token 2026-07-17 15:21:51 +02:00
Thomas Fransolet
e0ff0eeba6 Quota update in controller, audit log (serilog) + ai translator + Unit tests ! (to be tested) + migration update 2026-04-10 16:28:33 +02:00