manager-service/ManagerService/Data/MyInfoMateDbContext.cs
Thomas Fransolet bd484db48a Lot J : agrégats de thèmes, job de regroupement, interrupteur de collecte
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>
2026-08-13 15:58:35 +02:00

639 lines
28 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
public const int StatsRetentionDays = 395;
private readonly IHttpContextAccessor _httpContextAccessor;
public MyInfoMateDbContext(DbContextOptions<MyInfoMateDbContext> options, IHttpContextAccessor httpContextAccessor)
: base(options)
{
_httpContextAccessor = httpContextAccessor;
}
public DbSet<Instance> Instances { get; set; }
public DbSet<SubscriptionPlan> SubscriptionPlans { get; set; }
public DbSet<Configuration> Configurations { get; set; }
public DbSet<Section> Sections { get; set; }
public DbSet<Device> Devices { get; set; }
public DbSet<Resource> Resources { get; set; }
public DbSet<User> Users { get; set; }
public DbSet<ApplicationInstance> ApplicationInstances { get; set; }
public DbSet<AppConfigurationLink> AppConfigurationLinks { get; set; }
// MAP
public DbSet<GeoPoint> GeoPoints { get; set; }
// QUIZ
public DbSet<QuizQuestion> QuizQuestions { get; set; }
public DbSet<GuidedPath> GuidedPaths { get; set; }
public DbSet<GuidedStep> GuidedSteps { get; set; }
// Events
public DbSet<ProgrammeBlock> ProgrammeBlocks { get; set; }
public DbSet<MapAnnotation> MapAnnotations { get; set; }
// Agenda
public DbSet<EventAgenda> EventAgendas { get; set; }
// Statistics
public DbSet<VisitEvent> VisitEvents { get; set; }
// API Keys
public DbSet<ApiKey> ApiKeys { get; set; }
// Push Notifications
public DbSet<PushNotification> PushNotifications { get; set; }
// Audit
public DbSet<AuditLog> AuditLogs { get; set; }
// Guide IA — recherche sémantique
public DbSet<ContentEmbedding> ContentEmbeddings { get; set; }
// Guide IA — journal des questions visiteurs
public DbSet<VisitorQuestion> VisitorQuestions { get; set; }
public DbSet<QuestionThemeMonthly> 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<int> 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<IAuditableEntity>())
{
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)
};
/// <summary>
/// Type journalisé dont l'entité relève, ou null si elle n'est pas journalisée.
/// </summary>
/// <remarks>
/// L'égalité exacte de type ne convient pas : <see cref="Section"/> 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 <c>EntityType</c> 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.
/// </remarks>
private static Type AuditedTypeOf(object entity) =>
AuditedTypes.FirstOrDefault(t => t.IsInstanceOfType(entity));
/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// <c>WeatherSyncService</c> réécrit <c>WeatherResult</c> 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.
///
/// <c>DateUpdate</c> y est pour une raison distincte : il est estampillé à chaque
/// SaveChanges, donc présent dans tous les diffs sans jamais rien y apprendre.
/// </remarks>
private static readonly HashSet<string> AuditIgnoredProperties = new()
{
nameof(IAuditableEntity.DateUpdate),
nameof(SectionWeather.WeatherResult),
nameof(SectionWeather.WeatherUpdatedDate)
};
private List<AuditLog> BuildAuditEntries()
{
var userId = _httpContextAccessor?.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
var entries = new List<AuditLog>();
// 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<ContentDTO>();
modelBuilder.Ignore<ResponseDTO>();
modelBuilder.Ignore<CategorieDTO>();
modelBuilder.Ignore<TranslationDTO>();
modelBuilder.Ignore<TranslationAndResourceDTO>();
modelBuilder.Ignore<OrderedTranslationAndResourceDTO>();
modelBuilder.Ignore<EventAddress>();
modelBuilder.Ignore<Translation>();
modelBuilder.Ignore<TranslationAndResource>();
modelBuilder.Ignore<OrderedTranslationAndResource>();
modelBuilder.Entity<Configuration>()
.Property(s => s.Title)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<Instance>()
.Property(i => i.GuideFallbackMessages)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(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<ContentEmbedding>();
}
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<ContentEmbedding>(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<VisitorQuestion>(entity =>
{
entity.Property(e => e.CitedContentIds)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<string>>(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<Section>()
.Property<string>("Discriminator")
.HasMaxLength(50);
modelBuilder.Entity<Section>()
.HasDiscriminator<string>("Discriminator")
.HasValue<Section>("Base")
.HasValue<SectionAgenda>("Agenda")
.HasValue<SectionArticle>("Article")
.HasValue<SectionEvent>("Event")
.HasValue<SectionMap>("Map")
.HasValue<SectionMenu>("Menu")
.HasValue<SectionPdf>("PDF")
.HasValue<SectionGame>("Game")
.HasValue<SectionQuiz>("Quiz")
.HasValue<SectionSlider>("Slider")
.HasValue<SectionVideo>("Video")
.HasValue<SectionWeather>("Weather")
.HasValue<SectionWeb>("Web")
.HasValue<SectionParcours>("Parcours");
/*modelBuilder.Entity<GeoPoint>(entity =>
{
entity.Property(e => e.Geometry).HasColumnType("geometry");
});
modelBuilder.Entity<GuidedStep>(entity =>
{
entity.Property(e => e.Geometry).HasColumnType("geometry");
});*/
modelBuilder.Entity<Section>()
.Property(s => s.Title)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<Section>()
.Property(s => s.Description)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GeoPoint>()
.Property(s => s.Title)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GeoPoint>()
.Property(s => s.Description)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GeoPoint>()
.Property(s => s.Contents)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<ContentDTO>>(v, options));
modelBuilder.Entity<GeoPoint>()
.Property(s => s.Schedules)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GeoPoint>()
.Property(s => s.Prices)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GeoPoint>()
.Property(s => s.Phone)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GeoPoint>()
.Property(s => s.Email)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GeoPoint>()
.Property(s => s.Site)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
// Configurations JSON pour GuidedPath
modelBuilder.Entity<GuidedPath>()
.Property(gp => gp.Title)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GuidedPath>()
.Property(gp => gp.Description)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GuidedPath>()
.Property(gp => gp.GameMessageDebut)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationAndResourceDTO>>(v, options));
modelBuilder.Entity<GuidedPath>()
.Property(gp => gp.GameMessageFin)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationAndResourceDTO>>(v, options));
// Configurations JSON pour GuidedStep
modelBuilder.Entity<GuidedStep>()
.Property(gs => gs.Title)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GuidedStep>()
.Property(gs => gs.Description)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GuidedStep>()
.Property(gp => gp.TimerExpiredMessage)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GuidedStep>()
.Property(gs => gs.AudioIds)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<GuidedStep>()
.Property(gs => gs.Contents)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<ContentDTO>>(v, options));
// SectionParcours: lien optionnel vers une SectionMap de base
modelBuilder.Entity<SectionParcours>()
.HasOne(sp => sp.BaseMap)
.WithMany()
.HasForeignKey(sp => sp.BaseSectionMapId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
// GuidedPath: lien vers SectionParcours
modelBuilder.Entity<GuidedPath>()
.HasOne(gp => gp.SectionParcours)
.WithMany(sp => sp.GuidedPaths)
.HasForeignKey(gp => gp.SectionParcoursId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<EventAgenda>()
.Property(s => s.Label)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<EventAgenda>()
.Property(s => s.Description)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<List<TranslationDTO>>(v, options));
modelBuilder.Entity<EventAgenda>()
.Property(s => s.Address)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<EventAddress>(v, options));
// SectionEvent: link to base SectionMap
modelBuilder.Entity<SectionEvent>()
.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<MapAnnotation>()
.HasOne<SectionEvent>()
.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<Instance>()
.Property(i => i.StorageQuotaBytes).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.AiTokensPerMonth).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.HasStats).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.StatsHistoryDays).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.HasAdvancedStats).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.IsActive).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.IsTrialActive).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.TrialAiTokensUsed).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.TrialCheckInEmailSent).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.TrialReminderEmailSent).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.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<SubscriptionPlan>().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,
}
);
}
}
}