Securite
- RequireAppKey : filtre qui exige une cle d'API valide ou un utilisateur du
manager. Pose sur les routes de contenu consommees par les apps visiteur
(Configuration, Section, Resource, SectionMap, SectionEvent, SectionAgenda,
SectionParcours, SectionQuiz, ApplicationInstance). Un [Authorize] d'action
ne peut pas assouplir celui de la classe ; seul [AllowAnonymous] le
court-circuite, et le filtre redevient le controle d'acces. Il ferme
l'enumeration par identifiant, pas la confidentialite : la cle s'obtient
par le slug ou le pincode.
- Instance/slug/{slug} rendait le pinCode, l'adresse de facturation, la TVA et
les quotas a qui lit l'URL du site visiteur. StripCommercialFields est
desormais applique sur slug et byPin ; isTrialActive reste expose pour le
filigrane d'essai.
- Device.Create et Device/{id}/detail repondaient 403 a toute tablette depuis
a452f4a (13/03) : la classe exige InstanceAdmin, une cle ne porte que
AppRead. Ouverts a la cle, le cloisonnement par instance etait deja ecrit.
Canal VR
- Device.AppType (defaut Tablet, backfill a 1) ; Create resout
l'ApplicationInstance sur ce type au lieu de Tablet en dur.
- Get filtre optionnellement par appType ; DeviceDetailDTO expose appVersion
et lastSeen.
- PUT Device/{id}/heartbeat : batterie, version, connexion. Volontairement
etroit, une app ne peut ni se renommer ni changer d'instance.
- ApiKeyAppType.VrApp en fin d'enum.
Contenu immersif
- ResourceType : Image360 (11), Video360 (12), Model3D (13), en fin d'enum.
- Section Scene3D : un modele GLB et ses points d'interet, en objet manipule
ou en decor habite. Les points sont des GeoPoint, avec une LocalTransform
en jsonb (convention glTF) ; CRUD dans SectionScene3DController.
- Instance.HasImmersiveContent : l'add-on ajoute 100 Go au quota de stockage,
repose apres un changement de plan et retire a la desactivation.
- ImmersiveBackground (owned) sur Configuration et ApplicationInstance, avec
une image de repli pour les canaux qui ne rendent pas l'immersif.
Export de configuration
- exportVersion (1) et generatedAt : le JSON devient un contrat, lu tel quel
par l'app Unity.
- Section.ToDTO() n'etant pas virtuelle, l'export ne portait aucun champ
specifique de sous-type. Passe par SectionFactory.ToDTO, et charge les
points des Map et des Scene3D.
- Le fond immersif et son repli partent avec les ressources, URL resolues.
Migrations : AddAppTypeToDevice, AddScene3DSectionAndImmersiveAddon,
AddImmersiveBackground.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
667 lines
30 KiB
C#
667 lines
30 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")
|
|
.HasValue<SectionScene3D>("Model3D");
|
|
|
|
/*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);
|
|
|
|
// 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<GeoPoint>()
|
|
.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<GeoPoint>()
|
|
.Property(p => p.LocalTransform)
|
|
.HasColumnType("jsonb")
|
|
.HasConversion(
|
|
v => JsonSerializer.Serialize(v, options),
|
|
v => JsonSerializer.Deserialize<Position3D>(v, options));
|
|
|
|
// 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.HasImmersiveContent).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,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
}
|