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 DbSet QuestionThemeMonthlies { 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));
///
/// Colonnes écrites par la machine, pas par un utilisateur. Elles n'apparaissent pas
/// dans le journal, et une modification qui ne touche qu'elles n'y produit aucune ligne.
///
///
/// WeatherSyncService réécrit WeatherResult sur cron, à 6 h et à 13 h.
/// Les sections étant désormais journalisées, chaque rafraîchissement produisait une
/// ligne portant la prévision OpenWeather complète en avant ET en après — quelques
/// dizaines de Ko, deux fois par jour, par section météo. Le coût de stockage est le
/// moindre problème : ce bruit noie les modifications humaines que l'écran d'audit
/// existe pour montrer.
///
/// DateUpdate y est pour une raison distincte : il est estampillé à chaque
/// SaveChanges, donc présent dans tous les diffs sans jamais rien y apprendre.
///
private static readonly HashSet AuditIgnoredProperties = new()
{
nameof(IAuditableEntity.DateUpdate),
nameof(SectionWeather.WeatherResult),
nameof(SectionWeather.WeatherUpdatedDate)
};
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();
var reportable = entry.Properties
.Where(p => !AuditIgnoredProperties.Contains(p.Metadata.Name))
.ToList();
string? oldValues = null;
string? newValues = null;
if (entry.State == EntityState.Modified)
{
var changed = reportable.Where(p => p.IsModified).ToList();
// Une écriture qui ne touche que des colonnes machine n'est pas un
// événement : pas de ligne du tout, plutôt qu'une ligne au diff vide.
if (changed.Count == 0)
continue;
oldValues = JsonSerializer.Serialize(
changed.ToDictionary(p => p.Metadata.Name, p => p.OriginalValue));
newValues = JsonSerializer.Serialize(
changed.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue));
}
else if (entry.State == EntityState.Added)
{
newValues = JsonSerializer.Serialize(
reportable.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));
modelBuilder.Entity()
.Property(ai => ai.AppName)
.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")
.HasValue("Model3D");
/*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);
// SectionScene3D : ses points sont des GeoPoint, comme ceux d'une Map. Le
// troisième rattachement d'un GeoPoint, après SectionMap et SectionEvent.
modelBuilder.Entity()
.HasOne(p => p.SectionScene3D)
.WithMany(s => s.Points)
.HasForeignKey(p => p.SectionScene3DId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
// La position 3D est un petit objet, stocké tel quel : rien ne la requête, et
// trois colonnes de plus sur GeoPoint ne serviraient qu'aux maquettes.
modelBuilder.Entity()
.Property(p => p.LocalTransform)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize(v, options));
// 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.HasImmersiveContent).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,
}
);
}
}
}