ApplicationInstance porte désormais AppName (traductions, jsonb), IsQRCodeEnabled (défaut true) et les liens App Store / Play Store. Une ligne existe par (instance, type d'app) : chaque réglage vaut donc séparément pour le mobile et pour le web. Les liens stores ne sont modifiables que par un SuperAdmin — c'est Unov qui publie les apps —, contrôlé côté API et pas seulement masqué dans l'UI. La liste anonyme des apps expose le slug web et le nom de l'instance : la page /download de visitapp-web n'a que l'instanceId d'un QR imprimé. Réserve les slugs download, demo et api : un segment statique de visitapp-web l'emporte sur [slug], l'instance serait inaccessible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
646 lines
28 KiB
C#
646 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));
|
|
|
|
modelBuilder.Entity<ApplicationInstance>()
|
|
.Property(ai => ai.AppName)
|
|
.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,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|