using Hangfire; using ManagerService.Data.SubSection; using ManagerService.Services; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using static ManagerService.Data.SubSection.SectionEvent; namespace ManagerService.Data { /// /// Déclenche la ré-indexation des sections touchées par un enregistrement. /// /// /// Central plutôt qu'un Enqueue par contrôleur : le contenu d'une section se modifie /// depuis six endroits (SectionController, les sous-contrôleurs Map/Quiz/Event/Parcours/Agenda, /// et AgendaSyncService), et la plupart d'entre eux enregistrent une entité fille sans jamais /// toucher la ligne Section. Un enqueue posé dans chaque contrôleur laisse passer tout ce qu'on /// oublie d'y ajouter — c'est déjà comme ça que le switch de l'export hors ligne a pourri. /// public class SectionIndexingInterceptor : SaveChangesInterceptor { private readonly IBackgroundJobClient _jobs; private readonly HashSet _sectionIds = new(); private readonly HashSet _deletedSectionIds = new(); private readonly HashSet _guidedPathIds = new(); private readonly HashSet _guidedStepIds = new(); public SectionIndexingInterceptor(IBackgroundJobClient jobs) { _jobs = jobs; } public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { Collect(eventData.Context); return result; } public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { Collect(eventData.Context); return ValueTask.FromResult(result); } public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) { Enqueue(eventData.Context); return result; } public override ValueTask SavedChangesAsync( SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) { Enqueue(eventData.Context); return ValueTask.FromResult(result); } /// /// Les entités supprimées ne sont plus interrogeables une fois l'enregistrement passé : /// les clés étrangères sont donc relevées ici, avant. Les propriétés sont lues via l'entrée /// et non l'objet, pour atteindre aussi les clés étrangères fantômes (ProgrammeBlock). /// private void Collect(DbContext context) { if (context == null) return; foreach (var entry in context.ChangeTracker.Entries()) { if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) continue; switch (entry.Entity) { case Section section: if (entry.State == EntityState.Deleted) Add(_deletedSectionIds, section.Id); else if (entry.State == EntityState.Added || TouchesIndexedContent(entry)) Add(_sectionIds, section.Id); break; case GeoPoint point: Add(_sectionIds, point.SectionMapId ?? point.SectionEventId); break; case EventAgenda eventAgenda: Add(_sectionIds, eventAgenda.SectionAgendaId ?? eventAgenda.SectionEventId); break; case MapAnnotation annotation: // Null pour une annotation de bloc : c'est alors le ProgrammeBlock qui porte // le rattachement à l'événement, et il est enregistré dans la même passe. Add(_sectionIds, annotation.SectionEventId); break; case ProgrammeBlock: Add(_sectionIds, entry.Property("SectionEventId").CurrentValue as string); break; case GuidedPath path: Add(_sectionIds, path.SectionParcoursId ?? path.SectionEventId); break; case GuidedStep step: Add(_guidedPathIds, step.GuidedPathId); break; case QuizQuestion question: Add(_sectionIds, question.SectionQuizId); Add(_guidedStepIds, question.GuidedStepId); break; } } } private void Enqueue(DbContext context) { if (context == null) return; var sectionIds = Drain(_sectionIds); var deletedSectionIds = Drain(_deletedSectionIds); var guidedPathIds = Drain(_guidedPathIds); var guidedStepIds = Drain(_guidedStepIds); // La purge ne coûte rien et doit passer quel que soit le plan : sans elle, une section // supprimée continue d'alimenter les réponses du guide. foreach (var sectionId in deletedSectionIds) _jobs.Enqueue(s => s.DeleteAsync(sectionId, ContentSourceType.Section, default)); if (guidedStepIds.Count > 0) { foreach (var pathId in context.Set() .Where(s => guidedStepIds.Contains(s.Id)) .Select(s => s.GuidedPathId) .ToList()) Add(guidedPathIds, pathId); } if (guidedPathIds.Count > 0) { foreach (var ownerId in context.Set() .Where(p => guidedPathIds.Contains(p.Id)) .Select(p => p.SectionParcoursId ?? p.SectionEventId) .ToList()) Add(sectionIds, ownerId); } sectionIds.ExceptWith(deletedSectionIds); if (sectionIds.Count == 0) return; // Un plan sans IA n'a pas à payer d'embedding : plan-starter est à 0 jeton, et // l'indexation étant silencieuse, rien ne le signalerait avant la facture Google. var indexable = context.Set
() .Where(s => sectionIds.Contains(s.Id)) .Join(context.Set().Where(i => i.AiTokensPerMonth > 0), s => s.InstanceId, i => i.Id, (s, _) => s.Id) .ToList(); foreach (var sectionId in indexable) _jobs.Enqueue(s => s.IngestSectionAsync(sectionId)); } private static HashSet Drain(HashSet source) { var copy = new HashSet(source); source.Clear(); return copy; } /// /// Réordonner des sections ne change pas un mot du texte indexé. Sans ce filtre, un /// glisser-déposer dans le manager fait payer un embedding par section déplacée — /// et plusieurs contrôleurs enregistrent dans une boucle, un SaveChanges par élément. /// private static readonly HashSet NonIndexedSectionProperties = new() { nameof(Section.Order), nameof(Section.DateUpdate) }; /// /// Repli volontairement prudent : toute autre propriété modifiée déclenche la /// ré-indexation. Un champ ajouté plus tard sera donc réindexé pour rien plutôt /// qu'oublié en silence — le sens de l'erreur qui se voit. /// private static bool TouchesIndexedContent(EntityEntry entry) => entry.Properties.Any(p => p.IsModified && !NonIndexedSectionProperties.Contains(p.Metadata.Name)); private static void Add(HashSet target, string id) { if (!string.IsNullOrEmpty(id)) target.Add(id); } } }