diff --git a/ManagerService.Tests/Controllers/AiControllerTests.cs b/ManagerService.Tests/Controllers/AiControllerTests.cs
index 82a7463..e52959d 100644
--- a/ManagerService.Tests/Controllers/AiControllerTests.cs
+++ b/ManagerService.Tests/Controllers/AiControllerTests.cs
@@ -194,6 +194,25 @@ namespace ManagerService.Tests.Controllers
Assert.False(string.IsNullOrWhiteSpace(db.VisitorQuestions.First().ConversationId));
}
+ ///
+ /// Le client est responsable de traitement : couper la collecte doit vraiment
+ /// l'arrêter. Les jetons restent comptés — la question a bien été posée et traitée.
+ ///
+ [Fact]
+ public async Task Chat_CollectionDisabled_AnswersButLogsNothing()
+ {
+ using var db = DbContextFactory.Create();
+ SeedAssistantInstance(db);
+ db.Instances.First().IsVisitorQuestionCollectionEnabled = false;
+ db.SaveChanges();
+
+ var result = await BuildController(db).Chat(MakeRequest("i1"));
+
+ Assert.IsType(result);
+ Assert.Empty(db.VisitorQuestions);
+ Assert.Equal(42, db.Instances.First().AiTokensThisMonth);
+ }
+
private static void SeedAssistantInstance(MyInfoMateDbContext db)
{
db.Instances.Add(new Instance
diff --git a/ManagerService.Tests/Services/QuestionThemingServiceTests.cs b/ManagerService.Tests/Services/QuestionThemingServiceTests.cs
new file mode 100644
index 0000000..d30f738
--- /dev/null
+++ b/ManagerService.Tests/Services/QuestionThemingServiceTests.cs
@@ -0,0 +1,193 @@
+using ManagerService.Data;
+using ManagerService.Services;
+using ManagerService.Tests.Infrastructure;
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace ManagerService.Tests.Services
+{
+ public class QuestionThemingServiceTests
+ {
+ ///
+ /// Le modèle rend une ligne « numéro|thème » par question. Ce faux le rejoue à partir
+ /// des thèmes qu'on lui donne, dans l'ordre.
+ ///
+ private static QuestionThemingService BuildService(
+ MyInfoMateDbContext db, params string[] themesInOrder)
+ {
+ var reply = string.Join("\n", themesInOrder.Select((t, i) => $"{i + 1}|{t}"));
+
+ var chatClient = new Mock();
+ chatClient
+ .Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, reply)));
+
+ return new QuestionThemingService(db, chatClient.Object,
+ NullLogger.Instance);
+ }
+
+ private static VisitorQuestion Question(string id, string instanceId, string text, DateTime createdAt) =>
+ new VisitorQuestion
+ {
+ ConversationId = id,
+ InstanceId = instanceId,
+ Language = "fr",
+ Question = text,
+ Reply = "…",
+ HasAnswer = true,
+ CreatedAt = createdAt
+ };
+
+ [Fact]
+ public async Task RunAsync_AssignsThemesAndFillsMonthlyAggregates()
+ {
+ using var db = DbContextFactory.Create();
+ var day = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc);
+ db.VisitorQuestions.AddRange(
+ Question("c1", "i1", "Vous ouvrez à quelle heure ?", day),
+ Question("c2", "i1", "C'est accessible en fauteuil ?", day));
+ db.SaveChanges();
+
+ await BuildService(db, "Horaires et tarifs", "Accessibilité").RunAsync();
+
+ Assert.Equal(new[] { "Accessibilité", "Horaires et tarifs" },
+ db.VisitorQuestions.Select(q => q.ThemeId).OrderBy(t => t).ToArray());
+
+ var aggregates = db.QuestionThemeMonthlies.OrderBy(a => a.Theme).ToList();
+ Assert.Equal(2, aggregates.Count);
+ Assert.All(aggregates, a => Assert.Equal(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), a.Month));
+ Assert.All(aggregates, a => Assert.Equal(1, a.Count));
+ }
+
+ ///
+ /// Le job est rejouable : un second passage ne doit pas doubler un mois. Les questions
+ /// déjà classées sont hors de son périmètre, donc les compteurs ne bougent pas.
+ ///
+ [Fact]
+ public async Task RunAsync_IsIdempotent_AlreadyThemedQuestionsAreLeftAlone()
+ {
+ using var db = DbContextFactory.Create();
+ db.VisitorQuestions.Add(Question("c1", "i1", "Vous ouvrez quand ?", DateTime.UtcNow));
+ db.SaveChanges();
+
+ await BuildService(db, "Horaires et tarifs").RunAsync();
+ await BuildService(db, "Horaires et tarifs").RunAsync();
+
+ var aggregate = Assert.Single(db.QuestionThemeMonthlies);
+ Assert.Equal(1, aggregate.Count);
+ }
+
+ ///
+ /// Les compteurs d'un même mois s'additionnent d'un passage à l'autre — c'est ce qui
+ /// permet au job de tourner tous les jours sans écraser le début du mois.
+ ///
+ [Fact]
+ public async Task RunAsync_AddsToAnExistingMonth()
+ {
+ using var db = DbContextFactory.Create();
+ var day = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc);
+ db.VisitorQuestions.Add(Question("c1", "i1", "Horaires ?", day));
+ db.SaveChanges();
+ await BuildService(db, "Horaires et tarifs").RunAsync();
+
+ db.VisitorQuestions.Add(Question("c2", "i1", "Et le dimanche ?", day.AddDays(3)));
+ db.SaveChanges();
+ await BuildService(db, "Horaires et tarifs").RunAsync();
+
+ Assert.Equal(2, Assert.Single(db.QuestionThemeMonthlies).Count);
+ }
+
+ ///
+ /// Un thème inventé par le modèle ne doit pas créer de ligne d'agrégat parasite :
+ /// deux mois ne se comparent que si les libellés sont stables.
+ ///
+ [Fact]
+ public async Task RunAsync_UnknownThemeFallsBackToOther()
+ {
+ using var db = DbContextFactory.Create();
+ db.VisitorQuestions.Add(Question("c1", "i1", "Une question", DateTime.UtcNow));
+ db.SaveChanges();
+
+ await BuildService(db, "Questions diverses sur le musée").RunAsync();
+
+ Assert.Equal(QuestionThemes.Other, db.VisitorQuestions.First().ThemeId);
+ }
+
+ ///
+ /// ⚠️ Le point qui protège de la perte : une réponse du modèle à laquelle il manque une
+ /// ligne ne doit pas décaler les suivantes. Ici la ligne 1 manque — la question 2 doit
+ /// garder SON thème, pas hériter de celui de la 3.
+ ///
+ [Fact]
+ public async Task RunAsync_MissingLineDoesNotShiftTheOthers()
+ {
+ using var db = DbContextFactory.Create();
+ var day = DateTime.UtcNow;
+ db.VisitorQuestions.AddRange(
+ Question("c1", "i1", "Première", day),
+ Question("c2", "i1", "Deuxième", day.AddMinutes(1)),
+ Question("c3", "i1", "Troisième", day.AddMinutes(2)));
+ db.SaveChanges();
+
+ var chatClient = new Mock();
+ chatClient
+ .Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant,
+ "2|Accessibilité\n3|Services sur place")));
+
+ await new QuestionThemingService(db, chatClient.Object,
+ NullLogger.Instance).RunAsync();
+
+ var byQuestion = db.VisitorQuestions.ToDictionary(q => q.Question, q => q.ThemeId);
+ Assert.Equal(QuestionThemes.Other, byQuestion["Première"]);
+ Assert.Equal("Accessibilité", byQuestion["Deuxième"]);
+ Assert.Equal("Services sur place", byQuestion["Troisième"]);
+ }
+
+ ///
+ /// Les plus anciennes d'abord : c'est ce qui fait que le plafond par passage retarde
+ /// sans jamais sauter une question, et donc qu'aucune ne soit purgée non classée.
+ ///
+ [Fact]
+ public async Task RunAsync_ProcessesOldestFirst()
+ {
+ using var db = DbContextFactory.Create();
+ var old = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
+ db.VisitorQuestions.AddRange(
+ Question("c-recent", "i1", "Récente", old.AddDays(30)),
+ Question("c-old", "i1", "Ancienne", old));
+ db.SaveChanges();
+
+ var captured = new List();
+ var chatClient = new Mock();
+ chatClient
+ .Setup(c => c.GetResponseAsync(
+ It.IsAny>(),
+ It.IsAny(),
+ It.IsAny()))
+ .Callback, ChatOptions, CancellationToken>(
+ (messages, _, __) => captured.Add(messages.First().Text ?? ""))
+ .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "1|Autre\n2|Autre")));
+
+ await new QuestionThemingService(db, chatClient.Object,
+ NullLogger.Instance).RunAsync();
+
+ var prompt = Assert.Single(captured);
+ Assert.Contains("1. Ancienne", prompt);
+ Assert.Contains("2. Récente", prompt);
+ }
+ }
+}
diff --git a/ManagerService/Controllers/AiController.cs b/ManagerService/Controllers/AiController.cs
index 7aead48..c797440 100644
--- a/ManagerService/Controllers/AiController.cs
+++ b/ManagerService/Controllers/AiController.cs
@@ -249,6 +249,11 @@ namespace ManagerService.Controllers
var scope = _context.VisitorQuestions
.Where(q => q.InstanceId == instanceId && q.CreatedAt >= since);
+ // Les agrégats de thèmes sont mensuels : une fenêtre de 30 jours à cheval sur deux
+ // mois se lit donc depuis le premier de ces deux mois. Approximation assumée — la
+ // granularité au jour supposerait de garder les questions, ce que la purge interdit.
+ var monthOfSince = new DateTime(since.Year, since.Month, 1, 0, 0, 0, DateTimeKind.Utc);
+
var citedIds = await scope
.SelectMany(q => q.CitedContentIds)
.ToListAsync();
@@ -268,7 +273,9 @@ namespace ManagerService.Controllers
{
questions = await scope.CountAsync(),
unanswered = await scope.CountAsync(q => !q.HasAnswer),
- themes = await scope.Where(q => q.ThemeId != null).Select(q => q.ThemeId).Distinct().CountAsync(),
+ themes = await _context.QuestionThemeMonthlies
+ .Where(a => a.InstanceId == instanceId && a.Month >= monthOfSince)
+ .Select(a => a.Theme).Distinct().CountAsync(),
languages = await scope.Select(q => q.Language).Distinct().CountAsync(),
unansweredQuestions = await scope
@@ -279,10 +286,14 @@ namespace ManagerService.Controllers
.Take(5)
.ToListAsync(),
- topics = await scope
- .Where(q => q.ThemeId != null)
- .GroupBy(q => q.ThemeId)
- .Select(g => new CountedLabelDTO { label = g.Key, count = g.Count() })
+ // ⚠️ Lu dans la table d'agrégats, **pas** dans les questions de la fenêtre.
+ // C'est ce qui tient la promesse du §8.4 des CGU : les questions brutes sont
+ // purgées à 90 jours, les regroupements survivent. Les lire dans `scope`
+ // ferait disparaître l'historique du client au 91ᵉ jour, sans erreur ni trace.
+ topics = await _context.QuestionThemeMonthlies
+ .Where(a => a.InstanceId == instanceId && a.Month >= monthOfSince)
+ .GroupBy(a => a.Theme)
+ .Select(g => new CountedLabelDTO { label = g.Key, count = g.Sum(a => a.Count) })
.OrderByDescending(x => x.count)
.Take(6)
.ToListAsync(),
@@ -387,7 +398,9 @@ namespace ManagerService.Controllers
// système s'est écrit à lui-même, ou gestionnaire qui teste sa personnalité
// dans l'aperçu — ne va ni dans « Ce que demandent vos visiteurs », ni dans
// les trous de contenu, ni dans les thèmes du lot J.
- if (request.IsVisitorQuestion)
+ // Et le client peut refuser la collecte : c'est lui le responsable de
+ // traitement, elle était inconditionnelle dès que l'assistant était actif.
+ if (request.IsVisitorQuestion && instance.IsVisitorQuestionCollectionEnabled)
RecordVisitorQuestion(request, result);
return Ok(result);
diff --git a/ManagerService/DTOs/InstanceDTO.cs b/ManagerService/DTOs/InstanceDTO.cs
index 39c843d..d034af4 100644
--- a/ManagerService/DTOs/InstanceDTO.cs
+++ b/ManagerService/DTOs/InstanceDTO.cs
@@ -21,6 +21,8 @@ namespace ManagerService.DTOs
public string? guideName { get; set; }
public string? guidePersonaPrompt { get; set; }
public string? guideVoiceId { get; set; }
+ /// Le client autorise-t-il l'enregistrement des questions de ses visiteurs ? (RGPD, lot J)
+ public bool? isVisitorQuestionCollectionEnabled { get; set; }
public List? guideFallbackMessages { get; set; }
public string? webSlug { get; set; }
diff --git a/ManagerService/Data/Instance.cs b/ManagerService/Data/Instance.cs
index c7901e3..a5d58dc 100644
--- a/ManagerService/Data/Instance.cs
+++ b/ManagerService/Data/Instance.cs
@@ -54,6 +54,21 @@ namespace ManagerService.Data
/// Voix Gemini TTS : "Sulafat" (Viva, féminine) ou "Umbriel" (Marco, masculine).
public string? GuideVoiceId { get; set; }
+ ///
+ /// Le client autorise-t-il l'enregistrement des questions posées par ses visiteurs ?
+ ///
+ /// ⚠️ **C'est lui le responsable de traitement**, pas nous : il doit pouvoir refuser
+ /// la collecte. Elle était inconditionnelle dès que l'assistant était actif.
+ ///
+ /// Défaut à true : couvert par les CGU §8, et le désactiver par défaut viderait
+ /// l'onglet « Ce que demandent vos visiteurs » et le rapport de trous de contenu chez
+ /// tout le monde — une fonction qu'il paie, perdue sans l'avoir demandé.
+ ///
+ /// ⚠️ Couper la collecte n'efface rien : les questions déjà enregistrées vivent
+ /// jusqu'à leur purge à 90 jours, et les agrégats de thèmes leur survivent.
+ ///
+ public bool IsVisitorQuestionCollectionEnabled { get; set; } = true;
+
///
/// Formulations utilisées quand le guide ne trouve pas de réponse.
/// Liste à plat : plusieurs entrées peuvent partager la même langue,
@@ -128,6 +143,7 @@ namespace ManagerService.Data
guideName = GuideName,
guidePersonaPrompt = GuidePersonaPrompt,
guideVoiceId = GuideVoiceId,
+ isVisitorQuestionCollectionEnabled = IsVisitorQuestionCollectionEnabled,
guideFallbackMessages = GuideFallbackMessages,
webSlug = WebSlug,
publicApiKey = PublicApiKey,
@@ -169,6 +185,8 @@ namespace ManagerService.Data
GuidePersonaPrompt = instanceDTO.guidePersonaPrompt;
if (instanceDTO.guideVoiceId != null)
GuideVoiceId = instanceDTO.guideVoiceId;
+ if (instanceDTO.isVisitorQuestionCollectionEnabled != null)
+ IsVisitorQuestionCollectionEnabled = instanceDTO.isVisitorQuestionCollectionEnabled.Value;
if (instanceDTO.guideFallbackMessages != null)
GuideFallbackMessages = instanceDTO.guideFallbackMessages;
if (instanceDTO.webSlug != null)
diff --git a/ManagerService/Data/MyInfoMateDbContext.cs b/ManagerService/Data/MyInfoMateDbContext.cs
index ac87d3a..da5b060 100644
--- a/ManagerService/Data/MyInfoMateDbContext.cs
+++ b/ManagerService/Data/MyInfoMateDbContext.cs
@@ -75,6 +75,7 @@ namespace ManagerService.Data
// Guide IA — journal des questions visiteurs
public DbSet VisitorQuestions { get; set; }
+ public DbSet QuestionThemeMonthlies { get; set; }
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
diff --git a/ManagerService/Data/MyInfoMateDbContextFactory.cs b/ManagerService/Data/MyInfoMateDbContextFactory.cs
new file mode 100644
index 0000000..e2c00c6
--- /dev/null
+++ b/ManagerService/Data/MyInfoMateDbContextFactory.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
+using System;
+
+namespace ManagerService.Data
+{
+ ///
+ /// Fabrique utilisée **uniquement par les outils EF** (`dotnet ef migrations add`).
+ ///
+ /// ⚠️ Sans elle, EF construit tout l'hôte pour trouver le contexte — et l'hôte ouvre une
+ /// connexion PostgreSQL au démarrage (stockage Hangfire). Générer une migration exigeait
+ /// donc une base joignable, alors qu'écrire un fichier de migration n'a besoin d'aucune
+ /// base. Sur une machine sans Postgres ni Docker, c'était tout simplement impossible.
+ ///
+ /// La chaîne de connexion n'est jamais utilisée pour se connecter à ce stade ; elle doit
+ /// seulement être syntaxiquement valide. MIGRATIONS_CONNECTION permet de la
+ /// surcharger pour un database update ponctuel.
+ ///
+ public class MyInfoMateDbContextFactory : IDesignTimeDbContextFactory
+ {
+ public MyInfoMateDbContext CreateDbContext(string[] args)
+ {
+ var connectionString = Environment.GetEnvironmentVariable("MIGRATIONS_CONNECTION")
+ ?? "Host=localhost;Port=5432;Database=my_info_mate;Username=postgres;Password=postgres";
+
+ var options = new DbContextOptionsBuilder()
+ .UseNpgsql(connectionString, o =>
+ {
+ o.UseNetTopologySuite();
+ o.UseVector();
+ })
+ .Options;
+
+ return new MyInfoMateDbContext(options, new HttpContextAccessor());
+ }
+ }
+}
diff --git a/ManagerService/Data/QuestionThemeMonthly.cs b/ManagerService/Data/QuestionThemeMonthly.cs
new file mode 100644
index 0000000..579478a
--- /dev/null
+++ b/ManagerService/Data/QuestionThemeMonthly.cs
@@ -0,0 +1,40 @@
+using System;
+using Microsoft.EntityFrameworkCore;
+
+namespace ManagerService.Data
+{
+ ///
+ /// Compteur mensuel de questions par thème, pour une instance.
+ ///
+ /// ⚠️ **C'est cette table qui rend tenable la promesse du §8.4 des CGU** — « les
+ /// regroupements par thème sont conservés au-delà, sous forme agrégée ». Sans elle, le
+ /// regroupement vit dans , donc la purge du 90ᵉ jour
+ /// l'emporte avec la question : le client perdrait tout son historique au 91ᵉ jour.
+ ///
+ /// Elle ne porte **que des compteurs** : ni texte de question, ni identifiant de session,
+ /// ni langue. Aucune donnée personnelle, donc rien qui justifierait de la purger — c'est
+ /// précisément ce qui l'autorise à survivre aux questions dont elle est issue.
+ ///
+ [Index(nameof(InstanceId), nameof(Month))]
+ public class QuestionThemeMonthly
+ {
+ public long Id { get; set; }
+
+ public string InstanceId { get; set; }
+
+ /// Premier jour du mois concerné, en UTC. La granularité est le mois, pas le jour.
+ public DateTime Month { get; set; }
+
+ ///
+ /// Libellé du thème, pris dans .
+ ///
+ /// ⚠️ Volontairement une liste fixe et non des thèmes découverts par l'IA : deux mois
+ /// ne se comparent que si leurs thèmes portent le même nom. Des libellés régénérés à
+ /// chaque passage produiraient « Horaires » en janvier et « Questions d'horaires » en
+ /// février — deux lignes distinctes, et une courbe qui ne veut rien dire.
+ ///
+ public string Theme { get; set; }
+
+ public int Count { get; set; }
+ }
+}
diff --git a/ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.Designer.cs b/ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.Designer.cs
new file mode 100644
index 0000000..ac7b124
--- /dev/null
+++ b/ManagerService/Migrations/20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch.Designer.cs
@@ -0,0 +1,1913 @@
+//
+using System;
+using System.Collections.Generic;
+using Manager.DTOs;
+using ManagerService.DTOs;
+using ManagerService.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NetTopologySuite.Geometries;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Pgvector;
+
+#nullable disable
+
+namespace ManagerService.Migrations
+{
+ [DbContext(typeof(MyInfoMateDbContext))]
+ [Migration("20260813125628_LotJ_ThemeAggregatesAndCollectionSwitch")]
+ partial class LotJ_ThemeAggregatesAndCollectionSwitch
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "vector");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("ManagerService.Data.ApiKey", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AppType")
+ .HasColumnType("integer");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateExpiration")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InstanceId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("Key")
+ .HasColumnType("text");
+
+ b.Property("KeyHash")
+ .HasColumnType("text");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("ApiKeys");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("ApplicationInstanceId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ConfigurationId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeviceId")
+ .HasColumnType("text");
+
+ b.Property("GridColSpan")
+ .HasColumnType("integer");
+
+ b.Property("GridRowSpan")
+ .HasColumnType("integer");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("IsDate")
+ .HasColumnType("boolean");
+
+ b.Property("IsHour")
+ .HasColumnType("boolean");
+
+ b.Property("IsSectionImageBackground")
+ .HasColumnType("boolean");
+
+ b.Property("LoaderImageId")
+ .HasColumnType("text");
+
+ b.Property("LoaderImageUrl")
+ .HasColumnType("text");
+
+ b.Property("Order")
+ .HasColumnType("integer");
+
+ b.Property("PrimaryColor")
+ .HasColumnType("text");
+
+ b.Property("RoundedValue")
+ .HasColumnType("integer");
+
+ b.Property("ScreenPercentageSectionsMainPage")
+ .HasColumnType("integer");
+
+ b.Property("SecondaryColor")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicationInstanceId");
+
+ b.HasIndex("ConfigurationId");
+
+ b.HasIndex("DeviceId");
+
+ b.ToTable("AppConfigurationLinks");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AppType")
+ .HasColumnType("integer");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("InstanceId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsAssistant")
+ .HasColumnType("boolean");
+
+ b.PrimitiveCollection>("Languages")
+ .HasColumnType("text[]");
+
+ b.Property("LoaderImageId")
+ .HasColumnType("text");
+
+ b.Property("LoaderImageUrl")
+ .HasColumnType("text");
+
+ b.Property("MainImageId")
+ .HasColumnType("text");
+
+ b.Property("MainImageUrl")
+ .HasColumnType("text");
+
+ b.Property("PrimaryColor")
+ .HasColumnType("text");
+
+ b.Property("SecondaryColor")
+ .HasColumnType("text");
+
+ b.Property("SectionEventId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId");
+
+ b.HasIndex("SectionEventId");
+
+ b.ToTable("ApplicationInstances");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.AuditLog", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Action")
+ .HasColumnType("text");
+
+ b.Property("EntityId")
+ .HasColumnType("text");
+
+ b.Property("EntityType")
+ .HasColumnType("text");
+
+ b.Property("InstanceId")
+ .HasColumnType("text");
+
+ b.Property("NewValues")
+ .HasColumnType("text");
+
+ b.Property("OldValues")
+ .HasColumnType("text");
+
+ b.Property("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("AuditLogs");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.Configuration", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ImageId")
+ .HasColumnType("text");
+
+ b.Property("ImageSource")
+ .HasColumnType("text");
+
+ b.Property("InstanceId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsOffline")
+ .HasColumnType("boolean");
+
+ b.Property("IsQRCode")
+ .HasColumnType("boolean");
+
+ b.Property("IsSearchNumber")
+ .HasColumnType("boolean");
+
+ b.Property("IsSearchText")
+ .HasColumnType("boolean");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.PrimitiveCollection>("Languages")
+ .HasColumnType("text[]");
+
+ b.Property("LoaderImageId")
+ .HasColumnType("text");
+
+ b.Property("LoaderImageUrl")
+ .HasColumnType("text");
+
+ b.Property("PrimaryColor")
+ .HasColumnType("text");
+
+ b.Property("SecondaryColor")
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("Configurations");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.ContentEmbedding", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ChunkIndex")
+ .HasColumnType("integer");
+
+ b.Property("ConfigurationId")
+ .HasColumnType("text");
+
+ b.Property("ContentId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ContentType")
+ .HasColumnType("integer");
+
+ b.Property("Embedding")
+ .IsRequired()
+ .HasColumnType("vector(768)");
+
+ b.Property("InstanceId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasMaxLength(5)
+ .HasColumnType("character varying(5)");
+
+ b.Property("PageNumber")
+ .HasColumnType("integer");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Embedding");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Embedding"), "hnsw");
+ NpgsqlIndexBuilderExtensions.HasOperators(b.HasIndex("Embedding"), new[] { "vector_cosine_ops" });
+
+ b.HasIndex("InstanceId", "ContentType");
+
+ b.HasIndex("ContentType", "ContentId", "ChunkIndex")
+ .IsUnique();
+
+ b.ToTable("ContentEmbeddings");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.Device", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AppVersion")
+ .HasColumnType("text");
+
+ b.Property("BatteryLevel")
+ .HasColumnType("text");
+
+ b.Property("ConfigurationId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Connected")
+ .HasColumnType("boolean");
+
+ b.Property("ConnectionLevel")
+ .HasColumnType("text");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Identifier")
+ .HasColumnType("text");
+
+ b.Property("InstanceId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IpAddressETH")
+ .HasColumnType("text");
+
+ b.Property("IpAddressWLAN")
+ .HasColumnType("text");
+
+ b.Property("LastBatteryLevel")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastConnectionLevel")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LastSeen")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ConfigurationId");
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("Devices");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.Instance", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AiTokensPerMonth")
+ .HasColumnType("bigint");
+
+ b.Property("AiTokensThisMonth")
+ .HasColumnType("bigint");
+
+ b.Property("AiUsageMonthKey")
+ .HasColumnType("text");
+
+ b.Property("BillingAddress")
+ .HasColumnType("text");
+
+ b.Property("BillingCountry")
+ .HasColumnType("text");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("GuideFallbackMessages")
+ .HasColumnType("jsonb");
+
+ b.Property("GuideName")
+ .HasColumnType("text");
+
+ b.Property("GuidePersonaPrompt")
+ .HasColumnType("text");
+
+ b.Property("GuideVoiceId")
+ .HasColumnType("text");
+
+ b.Property("HasAdvancedStats")
+ .HasColumnType("boolean");
+
+ b.Property("HasStats")
+ .HasColumnType("boolean");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("IsAssistant")
+ .HasColumnType("boolean");
+
+ b.Property("IsImageWatermark")
+ .HasColumnType("boolean");
+
+ b.Property("IsMobile")
+ .HasColumnType("boolean");
+
+ b.Property("IsPushNotification")
+ .HasColumnType("boolean");
+
+ b.Property("IsTablet")
+ .HasColumnType("boolean");
+
+ b.Property("IsTrialActive")
+ .HasColumnType("boolean");
+
+ b.Property("IsVR")
+ .HasColumnType("boolean");
+
+ b.Property("IsVisitorQuestionCollectionEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("IsWeb")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("PinCode")
+ .HasColumnType("text");
+
+ b.Property("PublicApiKey")
+ .HasColumnType("text");
+
+ b.Property("StatsHistoryDays")
+ .HasColumnType("integer");
+
+ b.Property("StorageQuotaBytes")
+ .HasColumnType("bigint");
+
+ b.Property("StripeCustomerId")
+ .HasColumnType("text");
+
+ b.Property("StripeSubscriptionId")
+ .HasColumnType("text");
+
+ b.Property("SubscriptionPlanId")
+ .HasColumnType("text");
+
+ b.Property("TrialAiTokensUsed")
+ .HasColumnType("bigint");
+
+ b.Property("TrialCheckInEmailSent")
+ .HasColumnType("boolean");
+
+ b.Property("TrialEndsAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("TrialLastDayEmailSent")
+ .HasColumnType("boolean");
+
+ b.Property("TrialReminderEmailSent")
+ .HasColumnType("boolean");
+
+ b.Property("VatNumber")
+ .HasColumnType("text");
+
+ b.Property("VatRate")
+ .HasColumnType("numeric");
+
+ b.Property("WebSlug")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SubscriptionPlanId");
+
+ b.ToTable("Instances");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.PushNotification", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("Body")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("HangfireJobId")
+ .HasColumnType("text");
+
+ b.Property("InstanceId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ScheduledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SentAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Status")
+ .HasColumnType("integer");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Topic")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("PushNotifications");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.QuestionThemeMonthly", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Count")
+ .HasColumnType("integer");
+
+ b.Property("InstanceId")
+ .HasColumnType("text");
+
+ b.Property("Month")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Theme")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId", "Month");
+
+ b.ToTable("QuestionThemeMonthlies");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.Resource", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("AiChunkCount")
+ .HasColumnType("integer");
+
+ b.Property("AiIndexMessage")
+ .HasColumnType("text");
+
+ b.Property("AiIndexStatus")
+ .HasColumnType("integer");
+
+ b.Property("AiIndexedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("FileName")
+ .HasColumnType("text");
+
+ b.Property("IncludeInAiKnowledge")
+ .HasColumnType("boolean");
+
+ b.Property("InstanceId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("SizeBytes")
+ .HasColumnType("bigint");
+
+ b.Property("StoragePath")
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.Property("Url")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId");
+
+ b.ToTable("Resources");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.Section", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("BeaconId")
+ .HasColumnType("integer");
+
+ b.Property("ConfigurationId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("jsonb");
+
+ b.Property("Discriminator")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("ImageId")
+ .HasColumnType("text");
+
+ b.Property("ImageSource")
+ .HasColumnType("text");
+
+ b.Property("InstanceId")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean");
+
+ b.Property("IsBeacon")
+ .HasColumnType("boolean");
+
+ b.Property("IsSubSection")
+ .HasColumnType("boolean");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Latitude")
+ .HasColumnType("text");
+
+ b.Property("Longitude")
+ .HasColumnType("text");
+
+ b.Property("MeterZoneGPS")
+ .HasColumnType("integer");
+
+ b.Property("Order")
+ .HasColumnType("integer");
+
+ b.Property("ParentId")
+ .HasColumnType("text");
+
+ b.Property("SectionMenuId")
+ .HasColumnType("text");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("InstanceId");
+
+ b.HasIndex("SectionMenuId");
+
+ b.ToTable("Sections");
+
+ b.HasDiscriminator().HasValue("Base");
+
+ b.UseTphMappingStrategy();
+ });
+
+ modelBuilder.Entity("ManagerService.Data.SubSection.EventAgenda", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Address")
+ .HasColumnType("jsonb");
+
+ b.Property("DateAdded")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateFrom")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateTo")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Email")
+ .HasColumnType("text");
+
+ b.Property("IdVideoYoutube")
+ .HasColumnType("text");
+
+ b.Property("IsSynced")
+ .HasColumnType("boolean");
+
+ b.Property("Label")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Phone")
+ .HasColumnType("text");
+
+ b.Property("ResourceId")
+ .HasColumnType("text");
+
+ b.Property("SectionAgendaId")
+ .HasColumnType("text");
+
+ b.Property("SectionEventId")
+ .HasColumnType("text");
+
+ b.Property("SyncedImageUrl")
+ .HasColumnType("text");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.Property("VideoLink")
+ .HasColumnType("text");
+
+ b.Property("VideoResourceId")
+ .HasColumnType("text");
+
+ b.Property("Website")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ResourceId");
+
+ b.HasIndex("SectionAgendaId");
+
+ b.HasIndex("SectionEventId");
+
+ b.HasIndex("VideoResourceId");
+
+ b.ToTable("EventAgendas");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CategorieId")
+ .HasColumnType("integer");
+
+ b.Property("Contents")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Geometry")
+ .HasColumnType("geometry");
+
+ b.Property("ImageResourceId")
+ .HasColumnType("text");
+
+ b.Property("ImageUrl")
+ .HasColumnType("text");
+
+ b.Property("Phone")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("PolyColor")
+ .HasColumnType("text");
+
+ b.Property("Prices")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Schedules")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("SectionEventId")
+ .HasColumnType("text");
+
+ b.Property("SectionMapId")
+ .HasColumnType("text");
+
+ b.Property("Site")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SectionEventId");
+
+ b.HasIndex("SectionMapId");
+
+ b.ToTable("GeoPoints");
+ });
+
+ modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("text");
+
+ b.Property("DateCreation")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DateUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property