using Manager.DTOs; using ManagerService.Data.SubSection; using ManagerService.DTOs; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using static ManagerService.Data.SubSection.SectionEvent; namespace ManagerService.Data { public class MyInfoMateDbContext : DbContext { /// /// Fenêtre d'historique des statistiques, en jours — 13 mois. /// Sert à la fois de valeur seed des plans et de seuil de purge des VisitEvent. /// public const int StatsRetentionDays = 395; private readonly IHttpContextAccessor _httpContextAccessor; public MyInfoMateDbContext(DbContextOptions options, IHttpContextAccessor httpContextAccessor) : base(options) { _httpContextAccessor = httpContextAccessor; } public DbSet Instances { get; set; } public DbSet SubscriptionPlans { get; set; } public DbSet Configurations { get; set; } public DbSet
Sections { get; set; } public DbSet Devices { get; set; } public DbSet Resources { get; set; } public DbSet Users { get; set; } public DbSet ApplicationInstances { get; set; } public DbSet AppConfigurationLinks { get; set; } // MAP public DbSet GeoPoints { get; set; } // QUIZ public DbSet QuizQuestions { get; set; } public DbSet GuidedPaths { get; set; } public DbSet GuidedSteps { get; set; } // Events public DbSet ProgrammeBlocks { get; set; } public DbSet MapAnnotations { get; set; } // Agenda public DbSet EventAgendas { get; set; } // Statistics public DbSet VisitEvents { get; set; } // API Keys public DbSet ApiKeys { get; set; } // Push Notifications public DbSet PushNotifications { get; set; } // Audit public DbSet AuditLogs { get; set; } // Guide IA — recherche sémantique public DbSet ContentEmbeddings { get; set; } // Guide IA — journal des questions visiteurs public DbSet VisitorQuestions { get; set; } public override int SaveChanges(bool acceptAllChangesOnSuccess) { StampAuditableEntities(); var auditEntries = BuildAuditEntries(); var result = base.SaveChanges(acceptAllChangesOnSuccess); if (auditEntries.Any()) base.SaveChanges(acceptAllChangesOnSuccess); return result; } public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { StampAuditableEntities(); var auditEntries = BuildAuditEntries(); var result = await base.SaveChangesAsync(cancellationToken); if (auditEntries.Any()) await base.SaveChangesAsync(cancellationToken); return result; } private void StampAuditableEntities() { var now = DateTime.UtcNow; foreach (var entry in ChangeTracker.Entries()) { if (entry.State == EntityState.Added) entry.Entity.DateCreation = now; if (entry.State is EntityState.Added or EntityState.Modified) entry.Entity.DateUpdate = now; } } private static readonly Type[] AuditedTypes = { typeof(Section), typeof(Resource), typeof(Configuration), typeof(Device), typeof(User), typeof(Instance) }; /// /// Type journalisé dont l'entité relève, ou null si elle n'est pas journalisée. /// /// /// L'égalité exacte de type ne convient pas : est abstraite, /// le type runtime est toujours un des 13 sous-types (SectionMap, SectionQuiz…), /// et aucune section n'était donc journalisée. On remonte à la classe de base pour /// que EntityType porte « Section » — le filtre de l'écran d'audit interroge /// ce nom-là, et un nouveau sous-type y entre sans que personne n'ait à l'inscrire. /// Le sous-type concret reste lisible dans les valeurs, via le discriminateur TPH. /// private static Type AuditedTypeOf(object entity) => AuditedTypes.FirstOrDefault(t => t.IsInstanceOfType(entity)); private List BuildAuditEntries() { var userId = _httpContextAccessor?.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier); var entries = new List(); // Matérialisé avant la boucle : ajouter un AuditLog au contexte modifie le // ChangeTracker, ce qui invaliderait l'énumération en cours // ("Collection was modified"). Les logs sont donc ajoutés après la boucle. var auditedEntries = ChangeTracker.Entries() .Select(e => new { Entry = e, AuditedType = AuditedTypeOf(e.Entity) }) .Where(x => x.AuditedType != null && x.Entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) .ToList(); foreach (var (entry, auditedType) in auditedEntries.Select(x => (x.Entry, x.AuditedType))) { var action = entry.State switch { EntityState.Added => "Create", EntityState.Modified => "Update", EntityState.Deleted => "Delete", _ => null }; var entityId = entry.Properties .FirstOrDefault(p => p.Metadata.IsPrimaryKey())?.CurrentValue?.ToString(); var instanceId = entry.Properties .FirstOrDefault(p => p.Metadata.Name == "InstanceId")?.CurrentValue?.ToString(); string? oldValues = null; string? newValues = null; if (entry.State == EntityState.Modified) { oldValues = JsonSerializer.Serialize( entry.Properties.Where(p => p.IsModified) .ToDictionary(p => p.Metadata.Name, p => p.OriginalValue)); newValues = JsonSerializer.Serialize( entry.Properties.Where(p => p.IsModified) .ToDictionary(p => p.Metadata.Name, p => p.CurrentValue)); } else if (entry.State == EntityState.Added) { newValues = JsonSerializer.Serialize( entry.Properties.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue)); } var log = new AuditLog { EntityType = auditedType.Name, EntityId = entityId ?? "", Action = action!, UserId = userId, InstanceId = instanceId, OldValues = oldValues, NewValues = newValues }; entries.Add(log); } if (entries.Count > 0) AuditLogs.AddRange(entries); return entries; } protected override void OnModelCreating(ModelBuilder modelBuilder) { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; base.OnModelCreating(modelBuilder); // Les types suivants sont utilisés uniquement comme valeurs JSONB, pas comme entités DB. // Le provider InMemory les découvre par convention et échoue à la validation // si aucune clé primaire n'est définie. On les exclut explicitement. modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Entity() .Property(s => s.Title) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(i => i.GuideFallbackMessages) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); // Guide IA — recherche sémantique. // Le type vector n'existe que sur Npgsql : les tests tournent sur EF InMemory, // qui échoue à la validation du modèle s'il rencontre une propriété Vector. // L'entité y est donc exclue — elle n'a de sens que face à une vraie base. if (!Database.IsNpgsql()) { modelBuilder.Ignore(); } else { // L'extension est déclarée ici pour que la migration la crée : aucun // CREATE EXTENSION à lancer à la main sur dev, preprod ou prod. modelBuilder.HasPostgresExtension("vector"); modelBuilder.Entity(entity => { entity.Property(e => e.Embedding) .HasColumnType($"vector({ContentEmbedding.Dimensions})") .IsRequired(); // Le projet n'active pas les reference types nullable : sans IsRequired() // explicite, EF génère tout en nullable — y compris InstanceId, qui porte // la séparation entre deux clients. entity.Property(e => e.InstanceId).IsRequired(); entity.Property(e => e.ContentId).IsRequired(); entity.Property(e => e.Text).IsRequired(); entity.Property(e => e.Language).HasMaxLength(5).IsRequired(); // Recherche par similarité cosinus. entity.HasIndex(e => e.Embedding) .HasMethod("hnsw") .HasOperators("vector_cosine_ops"); // Filtre appliqué à chaque recherche. entity.HasIndex(e => new { e.InstanceId, e.ContentType }); // Hangfire rejoue les jobs (retry, redémarrage de conteneur). Sans cette // contrainte, un second passage dupliquerait silencieusement les morceaux, // qui remonteraient ensuite deux fois dans chaque réponse du guide. entity.HasIndex(e => new { e.ContentType, e.ContentId, e.ChunkIndex }) .IsUnique(); }); } modelBuilder.Entity(entity => { entity.Property(e => e.CitedContentIds) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); entity.Property(e => e.Language).HasMaxLength(5); // Volume par période et questions sans réponse du mois. entity.HasIndex(e => new { e.InstanceId, e.CreatedAt }); // Reconstitution d'une conversation, et purge à 90 jours. entity.HasIndex(e => e.ConversationId); }); modelBuilder.Entity
() .Property("Discriminator") .HasMaxLength(50); modelBuilder.Entity
() .HasDiscriminator("Discriminator") .HasValue
("Base") .HasValue("Agenda") .HasValue("Article") .HasValue("Event") .HasValue("Map") .HasValue("Menu") .HasValue("PDF") .HasValue("Game") .HasValue("Quiz") .HasValue("Slider") .HasValue("Video") .HasValue("Weather") .HasValue("Web") .HasValue("Parcours"); /*modelBuilder.Entity(entity => { entity.Property(e => e.Geometry).HasColumnType("geometry"); }); modelBuilder.Entity(entity => { entity.Property(e => e.Geometry).HasColumnType("geometry"); });*/ modelBuilder.Entity
() .Property(s => s.Title) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity
() .Property(s => s.Description) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Title) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Description) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Contents) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Schedules) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Prices) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Phone) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Email) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Site) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); // Configurations JSON pour GuidedPath modelBuilder.Entity() .Property(gp => gp.Title) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(gp => gp.Description) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(gp => gp.GameMessageDebut) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(gp => gp.GameMessageFin) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); // Configurations JSON pour GuidedStep modelBuilder.Entity() .Property(gs => gs.Title) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(gs => gs.Description) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(gp => gp.TimerExpiredMessage) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(gs => gs.AudioIds) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(gs => gs.Contents) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); // SectionParcours: lien optionnel vers une SectionMap de base modelBuilder.Entity() .HasOne(sp => sp.BaseMap) .WithMany() .HasForeignKey(sp => sp.BaseSectionMapId) .IsRequired(false) .OnDelete(DeleteBehavior.SetNull); // GuidedPath: lien vers SectionParcours modelBuilder.Entity() .HasOne(gp => gp.SectionParcours) .WithMany(sp => sp.GuidedPaths) .HasForeignKey(gp => gp.SectionParcoursId) .IsRequired(false) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .Property(s => s.Label) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Description) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize>(v, options)); modelBuilder.Entity() .Property(s => s.Address) .HasColumnType("jsonb") .HasConversion( v => JsonSerializer.Serialize(v, options), v => JsonSerializer.Deserialize(v, options)); // SectionEvent: link to base SectionMap modelBuilder.Entity() .HasOne(se => se.BaseMap) .WithMany() .HasForeignKey(se => se.BaseSectionMapId) .IsRequired(false) .OnDelete(DeleteBehavior.SetNull); // MapAnnotation: global event-level annotations linked directly to SectionEvent modelBuilder.Entity() .HasOne() .WithMany(se => se.GlobalMapAnnotations) .HasForeignKey(ma => ma.SectionEventId) .IsRequired(false) .OnDelete(DeleteBehavior.Cascade); // Ces colonnes ont un defaultValue dans la migration, ce qui amène EF Core à les traiter // comme ValueGeneratedOnAdd (read-only après insert). On force ValueGeneratedNever. modelBuilder.Entity() .Property(i => i.StorageQuotaBytes).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.AiTokensPerMonth).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.HasStats).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.StatsHistoryDays).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.HasAdvancedStats).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.IsActive).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.IsTrialActive).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.TrialAiTokensUsed).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.TrialCheckInEmailSent).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.TrialReminderEmailSent).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.TrialLastDayEmailSent).ValueGeneratedNever(); // Fenêtre d'historique lisible, identique pour tous les plans qui ont les stats. // 13 mois : un dossier de subside est annuel, et il faut pouvoir comparer au même // mois de l'année précédente. La différenciation commerciale se fait sur la // profondeur (HasAdvancedStats), pas sur la durée — décidé le 2026-08-09. // Au-delà, les VisitEvent sont purgés (job `visit-events-purge`). // Seed : plans d'abonnement, alignés sur la grille de tarifs de // myinfomate-landing (source de vérité) — Essentiel 39€ / Pro 99€ / // Premium 179€ / Enterprise sur devis. // // Ce que le plan NE porte pas : app native, offline + beacons, push et // traduction automatique sont dans la grille mais pas dans cette table. // Ils se règlent par instance (IsMobile, IsWeb…) : la base ne les applique // pas, l'affectation le fait. // // ⚠️ Sémantique de 0, asymétrique et volontaire : pour StorageQuotaBytes, // 0 = illimité ; pour AiTokensPerMonth, 0 = pas d'IA. D'où la sentinelle // long.MaxValue sur Enterprise plutôt qu'un 0 qui le priverait d'assistant. modelBuilder.Entity().HasData( new SubscriptionPlan { Id = "plan-essentiel", Name = "Essentiel", StorageQuotaBytes = 1L * 1024 * 1024 * 1024, // 1 GB // L'assistant IA n'est pas inclus dans ce plan. Il se vend en add-on, // activé en surchargeant Instance.AiTokensPerMonth sans changer de plan // (ApplyPlanQuotas ne repart du plan qu'au changement de plan). AiTokensPerMonth = 0, HasStats = true, StatsHistoryDays = 30, // « Visiteurs / jour » HasAdvancedStats = false, }, new SubscriptionPlan { Id = "plan-pro", Name = "Pro", StorageQuotaBytes = 15L * 1024 * 1024 * 1024, // 15 GB AiTokensPerMonth = 0, // non inclus, cf. Essentiel HasStats = true, StatsHistoryDays = 30, HasAdvancedStats = false, }, new SubscriptionPlan { Id = "plan-premium", Name = "Premium", StorageQuotaBytes = 50L * 1024 * 1024 * 1024, // 50 GB AiTokensPerMonth = 20_000_000, // ~2 000 req/mois * ~10k jetons HasStats = true, StatsHistoryDays = StatsRetentionDays, HasAdvancedStats = true, }, new SubscriptionPlan { Id = "plan-enterprise", Name = "Enterprise", StorageQuotaBytes = 0, // 0 = illimité AiTokensPerMonth = long.MaxValue, // « quota sur mesure » HasStats = true, StatsHistoryDays = StatsRetentionDays, HasAdvancedStats = true, } ); } } }