diff --git a/ManagerService.Tests/Controllers/ResourceControllerTests.cs b/ManagerService.Tests/Controllers/ResourceControllerTests.cs index d3014e7..e26fe1d 100644 --- a/ManagerService.Tests/Controllers/ResourceControllerTests.cs +++ b/ManagerService.Tests/Controllers/ResourceControllerTests.cs @@ -1,10 +1,11 @@ -using Manager.Services; +using Manager.Services; using ManagerService.Controllers; using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Services; using ManagerService.Tests.Infrastructure; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using System.Collections.Generic; using System.Linq; @@ -27,7 +28,8 @@ namespace ManagerService.Tests.Controllers new ConfigurationDatabaseService(cfg), db, httpClientFactory ?? new FakeHttpClientFactory(), - blobService ?? new FakeBlobService()); + blobService ?? new FakeBlobService(), + new ResourceUsageService(db, new MemoryCache(new MemoryCacheOptions()))); FakeUser.SetUser(controller, FakeUser.Create("Manager.contenteditor", "inst-test")); return controller; } @@ -166,21 +168,26 @@ namespace ManagerService.Tests.Controllers // ── DELETE ─────────────────────────────────────────────────────────── + // La suppression d'une ressource utilisée est refusée depuis la Médiathèque V1 : + // la suite de Delete déréférence la ressource partout où elle sert, ce qui viderait + // des contenus en silence. Ces deux tests figeaient l'ancien comportement. [Fact] - public void Delete_NullifiesConfigurationImageId() + public void Delete_ResourceUsedByConfiguration_Returns409() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image }); db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", ImageId = "r1", Title = new List() }); db.SaveChanges(); - BuildController(db).Delete("r1"); + var result = BuildController(db).Delete("r1").GetAwaiter().GetResult(); - Assert.Null(db.Configurations.First().ImageId); + Assert.IsType(result); + Assert.Equal("r1", db.Configurations.First().ImageId); + Assert.Equal(1, db.Resources.Count()); } [Fact] - public void Delete_NullifiesSectionImageId() + public void Delete_ResourceUsedBySection_Returns409() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image }); @@ -190,9 +197,30 @@ namespace ManagerService.Tests.Controllers db.Sections.Add(section); db.SaveChanges(); - BuildController(db).Delete("r1"); + var result = BuildController(db).Delete("r1").GetAwaiter().GetResult(); - Assert.Null(db.Sections.First().ImageId); + Assert.IsType(result); + Assert.Equal("r1", db.Sections.First().ImageId); + } + + [Fact] + public void DeleteBulk_DeletesFreeResources_AndRefusesUsedOnes() + { + using var db = DbContextFactory.Create(); + db.Resources.AddRange( + new Resource { Id = "r-used", InstanceId = "inst-test", Label = "Utilisée", Type = ResourceType.Image }, + new Resource { Id = "r-free", InstanceId = "inst-test", Label = "Libre", Type = ResourceType.Image }); + db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", ImageId = "r-used", Title = new List() }); + db.SaveChanges(); + + var result = BuildController(db) + .DeleteBulk(new ResourceBulkDeleteRequestDTO { ids = new List { "r-used", "r-free" } }) + .GetAwaiter().GetResult(); + + var report = Assert.IsType(Assert.IsType(result).Value); + Assert.Equal(new[] { "r-free" }, report.deleted); + Assert.Equal("r-used", Assert.Single(report.refused).id); + Assert.Equal(1, Assert.Single(report.refused).usageCount); } [Fact] diff --git a/ManagerService.Tests/Services/ResourceUsageServiceTests.cs b/ManagerService.Tests/Services/ResourceUsageServiceTests.cs new file mode 100644 index 0000000..4315382 --- /dev/null +++ b/ManagerService.Tests/Services/ResourceUsageServiceTests.cs @@ -0,0 +1,207 @@ +using Manager.DTOs; +using ManagerService.Data; +using ManagerService.Data.SubSection; +using ManagerService.DTOs; +using ManagerService.Services; +using ManagerService.Tests.Infrastructure; +using Microsoft.Extensions.Caching.Memory; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace ManagerService.Tests.Services +{ + /// + /// L'index inverse décide de ce que la Médiathèque propose à la suppression. + /// Une ressource comptée orpheline à tort est une image effacée d'un contenu vivant, + /// d'où un test par piège identifié. + /// + public class ResourceUsageServiceTests + { + private static ResourceUsageService Build(MyInfoMateDbContext db) => + new ResourceUsageService(db, new MemoryCache(new MemoryCacheOptions())); + + private static void SeedInstance(MyInfoMateDbContext db) + { + db.Configurations.Add(new Configuration + { + Id = "cfg-1", + InstanceId = "inst-1", + Label = "Visite", + Title = new List(), + Languages = new List { "FR", "NL" } + }); + } + + [Fact] + public void ImageUsedOnlyInDutch_IsNotOrphan() + { + using var db = DbContextFactory.Create(); + SeedInstance(db); + var article = TestSection.Article("sec-1", "inst-1", "Article", "cfg-1"); + article.ArticleAudioIds = new List + { + new TranslationDTO { language = "NL", value = "res-nl" } + }; + db.Sections.Add(article); + db.Resources.Add(new Resource { Id = "res-nl", InstanceId = "inst-1", Label = "Audio NL", Type = ResourceType.Audio }); + db.SaveChanges(); + + var usages = Build(db).GetUsages("res-nl"); + + Assert.Single(usages); + Assert.Equal("Section", usages[0].kind); + } + + [Fact] + public void ConfigurationImages_AreCounted() + { + using var db = DbContextFactory.Create(); + db.Configurations.Add(new Configuration + { + Id = "cfg-1", + InstanceId = "inst-1", + Label = "Visite", + Title = new List(), + Languages = new List { "FR" }, + ImageId = "res-accueil", + LoaderImageId = "res-loader" + }); + db.Resources.AddRange( + new Resource { Id = "res-accueil", InstanceId = "inst-1", Label = "Accueil", Type = ResourceType.Image }, + new Resource { Id = "res-loader", InstanceId = "inst-1", Label = "Loader", Type = ResourceType.Image }); + db.SaveChanges(); + + var service = Build(db); + + Assert.Single(service.GetUsages("res-accueil")); + Assert.Equal("image", service.GetUsages("res-accueil")[0].field); + Assert.Equal("loaderImage", service.GetUsages("res-loader")[0].field); + } + + [Fact] + public void GuidedPathOnEvent_IsWalked() + { + using var db = DbContextFactory.Create(); + SeedInstance(db); + db.Sections.Add(new SectionEvent + { + Id = "sec-event", + InstanceId = "inst-1", + ConfigurationId = "cfg-1", + Label = "Carnaval", + Type = SectionType.Event, + Title = new List(), + Description = new List() + }); + db.GuidedPaths.Add(new GuidedPath + { + Id = "path-1", + InstanceId = "inst-1", + SectionEventId = "sec-event", + Title = new List { new TranslationDTO { language = "FR", value = "Chasse au trésor" } }, + Description = new List(), + ImageResourceId = "res-path" + }); + db.Resources.Add(new Resource { Id = "res-path", InstanceId = "inst-1", Label = "Vignette", Type = ResourceType.Image }); + db.SaveChanges(); + + var usages = Build(db).GetUsages("res-path"); + + // SectionEvent.GetReferencedResourceIds ne descend pas dans les parcours : + // sans parcours des GuidedPaths, cette image passerait pour orpheline. + Assert.Single(usages); + Assert.Equal("GuidedPath", usages[0].kind); + Assert.Contains("Chasse au trésor", usages[0].path); + } + + [Fact] + public void StepResource_IsAttributedToTheStep_NotThePath() + { + using var db = DbContextFactory.Create(); + SeedInstance(db); + db.GuidedPaths.Add(new GuidedPath + { + Id = "path-1", + InstanceId = "inst-1", + Title = new List { new TranslationDTO { language = "FR", value = "Parcours" } }, + Description = new List(), + Steps = new List + { + new GuidedStep + { + Id = "step-1", + GuidedPathId = "path-1", + Order = 0, + Title = new List { new TranslationDTO { language = "FR", value = "Première étape" } }, + Description = new List(), + Contents = new List { new ContentDTO { resourceId = "res-step" } }, + AudioIds = new List() + } + } + }); + db.Resources.Add(new Resource { Id = "res-step", InstanceId = "inst-1", Label = "Photo", Type = ResourceType.Image }); + db.SaveChanges(); + + var usages = Build(db).GetUsages("res-step"); + + Assert.Single(usages); + Assert.Equal("GuidedStep", usages[0].kind); + } + + [Fact] + public void UsageSummary_ReportsZeroForUnusedResources_AndConfigurationLabels() + { + using var db = DbContextFactory.Create(); + SeedInstance(db); + var article = TestSection.Article("sec-1", "inst-1", "Article", "cfg-1"); + article.ImageId = "res-used"; + db.Sections.Add(article); + db.Resources.AddRange( + new Resource { Id = "res-used", InstanceId = "inst-1", Label = "Utilisée", Type = ResourceType.Image }, + new Resource { Id = "res-free", InstanceId = "inst-1", Label = "Libre", Type = ResourceType.Image }); + db.SaveChanges(); + + var summary = Build(db).GetUsageSummary("inst-1"); + + Assert.Equal(1, summary.usages["res-used"].count); + Assert.Equal(new[] { "cfg-1" }, summary.usages["res-used"].configurationIds); + Assert.Equal(0, summary.usages["res-free"].count); + Assert.Equal("Visite", summary.configurations["cfg-1"]); + } + + [Fact] + public void GuidedStepImageUrl_IsNotResolved() + { + using var db = DbContextFactory.Create(); + SeedInstance(db); + db.GuidedPaths.Add(new GuidedPath + { + Id = "path-1", + InstanceId = "inst-1", + Title = new List(), + Description = new List(), + Steps = new List + { + new GuidedStep + { + Id = "step-1", + GuidedPathId = "path-1", + Order = 0, + Title = new List(), + Description = new List(), + Contents = new List(), + AudioIds = new List(), + ImageUrl = "https://storage/res-url" + } + } + }); + db.Resources.Add(new Resource { Id = "res-url", InstanceId = "inst-1", Label = "Image d'étape", Type = ResourceType.Image }); + db.SaveChanges(); + + // Limite connue et assumée en V1 : ImageUrl est une URL absolue, pas un id. + // Le test la fige pour que sa disparition en V2 soit un choix, pas un accident. + Assert.Empty(Build(db).GetUsages("res-url")); + } + } +} diff --git a/ManagerService/Controllers/AiController.cs b/ManagerService/Controllers/AiController.cs index c797440..cbede25 100644 --- a/ManagerService/Controllers/AiController.cs +++ b/ManagerService/Controllers/AiController.cs @@ -254,10 +254,18 @@ namespace ManagerService.Controllers // 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) + // `CitedContentIds` est une List sérialisée en jsonb par un convertisseur + // de valeur : EF ne sait pas traduire un SelectMany dessus et lève à l'exécution. + // On rapatrie les listes, puis on aplatit côté client. + var citedLists = await scope + .Select(q => q.CitedContentIds) .ToListAsync(); + var citedIds = citedLists + .Where(ids => ids != null) + .SelectMany(ids => ids) + .ToList(); + // Les titres se résolvent en une seule requête, puis en mémoire : la liste des // contenus cités est courte par nature, elle est déjà tronquée à 6. var topCited = citedIds diff --git a/ManagerService/Controllers/ConfigurationController.cs b/ManagerService/Controllers/ConfigurationController.cs index 7fc4f98..e50fc0b 100644 --- a/ManagerService/Controllers/ConfigurationController.cs +++ b/ManagerService/Controllers/ConfigurationController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; @@ -579,7 +579,7 @@ namespace ManagerService.Controllers resource.InstanceId = resourceExport.instanceId; resource.Type = resourceExport.type; resource.Label = resourceExport.label; - resource.DateCreation = resourceExport.dateCreation; + resource.DateCreation = resourceExport.dateCreation ?? DateTime.Now.ToUniversalTime(); //resource.Data = resourceExport.data; var resourceInDb = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == resourceExport.id); diff --git a/ManagerService/Controllers/ResourceController.cs b/ManagerService/Controllers/ResourceController.cs index 5a9c718..99782f0 100644 --- a/ManagerService/Controllers/ResourceController.cs +++ b/ManagerService/Controllers/ResourceController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Drawing; using System.IO; @@ -34,13 +34,15 @@ namespace ManagerService.Controllers private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly IResourceBlobService _blobService; + private readonly ResourceUsageService _usageService; IHexIdGeneratorService idService = new HexIdGeneratorService(); private static int MaxWidth = 1024; private static int MaxHeight = 1024; - public ResourceController(ILogger logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext, IHttpClientFactory httpClientFactory, IResourceBlobService blobService) + public ResourceController(ILogger logger, ResourceDatabaseService resourceService, SectionDatabaseService sectionService, ConfigurationDatabaseService configurationService, MyInfoMateDbContext myInfoMateDbContext, IHttpClientFactory httpClientFactory, IResourceBlobService blobService, ResourceUsageService usageService) { + _usageService = usageService; _logger = logger; _resourceService = resourceService; _sectionService = sectionService; @@ -270,6 +272,7 @@ namespace ManagerService.Controllers // Todo add some verification ? Resource resource = new Resource(); resource.Label = label; + resource.FileName = file.FileName; resource.Type = resourceType; resource.DateCreation = DateTime.Now.ToUniversalTime(); resource.InstanceId = instanceId; @@ -333,6 +336,9 @@ namespace ManagerService.Controllers resource.Label = newResource.label; resource.Type = newResource.type; resource.Url = newResource.url; + resource.FileName = newResource.fileName; + resource.Width = newResource.width; + resource.Height = newResource.height; resource.DateCreation = DateTime.Now.ToUniversalTime(); //resource.Data = newResource.data; resource.InstanceId = newResource.instanceId; @@ -388,6 +394,9 @@ namespace ManagerService.Controllers resource.Label = updatedResource.label != null ? updatedResource.label : resource.Label; resource.Type = updatedResource.type != null ? updatedResource.type : resource.Type; resource.Url = updatedResource.url != null ? updatedResource.url : resource.Url; + resource.FileName = updatedResource.fileName != null ? updatedResource.fileName : resource.FileName; + resource.Width = updatedResource.width ?? resource.Width; + resource.Height = updatedResource.height ?? resource.Height; if (updatedResource.sizeBytes > 0) resource.SizeBytes = updatedResource.sizeBytes; @@ -425,6 +434,115 @@ namespace ManagerService.Controllers } + /// + /// Contenus qui utilisent une ressource. + /// + /// id de la ressource + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(string), 500)] + [HttpGet("{id}/usages")] + public ObjectResult GetUsages(string id) + { + try + { + return new OkObjectResult(_usageService.GetUsages(id)); + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + + /// + /// Nombre d'usages par ressource pour une instance, zéro compris. + /// + /// id de l'instance + [ProducesResponseType(typeof(ResourceUsageMapDTO), 200)] + [ProducesResponseType(typeof(string), 400)] + [ProducesResponseType(typeof(string), 500)] + [HttpGet("usage-map")] + public ObjectResult GetUsageMap([FromQuery] string instanceId) + { + try + { + if (instanceId == null) + throw new ArgumentNullException("InstanceId needed"); + + return new OkObjectResult(_usageService.GetUsageSummary(instanceId)); + } + catch (ArgumentNullException ex) + { + return new BadRequestObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + + /// + /// Suppression en lot. Même règle que la suppression unitaire, appliquée par élément : + /// une ressource utilisée est refusée, les autres partent, et le rapport dit qui est qui. + /// + [ProducesResponseType(typeof(ResourceBulkDeleteReportDTO), 200)] + [ProducesResponseType(typeof(string), 400)] + [ProducesResponseType(typeof(string), 500)] + [HttpDelete("bulk")] + public async Task DeleteBulk([FromBody] ResourceBulkDeleteRequestDTO request) + { + try + { + if (request?.ids == null) + throw new ArgumentNullException("Ids param is null"); + + var report = new ResourceBulkDeleteReportDTO(); + + foreach (var id in request.ids.Distinct()) + { + var resource = _myInfoMateDbContext.Resources.FirstOrDefault(r => r.Id == id); + if (resource == null) + { + report.refused.Add(new ResourceBulkDeleteRefusalDTO { id = id, reason = "Ressource introuvable" }); + continue; + } + + var usages = _usageService.GetUsages(id); + if (usages.Count > 0) + { + report.refused.Add(new ResourceBulkDeleteRefusalDTO + { + id = id, + label = resource.Label, + usageCount = usages.Count, + reason = "Ressource utilisée" + }); + continue; + } + + var outcome = await Delete(id); + if (outcome.StatusCode == 202) + report.deleted.Add(id); + else + report.refused.Add(new ResourceBulkDeleteRefusalDTO + { + id = id, + label = resource.Label, + reason = outcome.Value?.ToString() + }); + } + + return new OkObjectResult(report); + } + catch (ArgumentNullException ex) + { + return new BadRequestObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + /// /// Delete a resource /// @@ -433,6 +551,7 @@ namespace ManagerService.Controllers [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] + [ProducesResponseType(typeof(List), 409)] [ProducesResponseType(typeof(string), 502)] [HttpDelete("{id}")] public async Task Delete(string id) @@ -447,6 +566,13 @@ namespace ManagerService.Controllers if (resource == null) throw new KeyNotFoundException("Resource does not exist"); + // Le front désactive déjà le bouton, mais la règle doit vivre ici : la + // suite de cette méthode déréférence la ressource partout où elle sert, + // donc un appel venu d'ailleurs viderait des contenus en silence. + var usages = _usageService.GetUsages(id); + if (usages.Count > 0) + return new ConflictObjectResult(usages) { }; + // Le blob part AVANT la ligne. manager-app faisait l'inverse et avalait // l'échec : la ligne disparaissait, le blob restait, et n'ayant plus de // ligne il devenait invisible au quota tout en restant facturé. Ici un @@ -502,7 +628,10 @@ namespace ManagerService.Controllers { foreach (var categorie in map.MapCategories) { - if (categorie.resourceDTO.id == id) + // Une catégorie sans icône a un `resourceDTO` nul : + // le déréférencer faisait échouer toute suppression + // de ressource dès qu'une carte avait des catégories. + if (categorie.resourceDTO?.id == id) { categorie.resourceDTO = null; _myInfoMateDbContext.Entry(map).Property(p => p.MapCategories).IsModified = true; diff --git a/ManagerService/Controllers/UserController.cs b/ManagerService/Controllers/UserController.cs index 2fe25c0..c00a3c6 100644 --- a/ManagerService/Controllers/UserController.cs +++ b/ManagerService/Controllers/UserController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Security.Claims; @@ -76,12 +76,27 @@ namespace ManagerService.Controllers { try { - var query = _myInfoMateDbContext.Users.AsQueryable(); - if (!IsSuperAdmin()) - query = query.Where(u => u.InstanceId == GetCallerInstanceId()); + { + var callerInstanceId = GetCallerInstanceId(); + var users = _myInfoMateDbContext.Users + .Where(u => u.InstanceId == callerInstanceId) + .ToList(); - return new OkObjectResult(query.ToList().Select(u => u.ToDTO())); + return new OkObjectResult(users.Select(u => u.ToDTO())); + } + + // Le SuperAdmin voit toutes les instances : sans le nom de l'instance, + // deux utilisateurs homonymes de clients differents sont indistinguables. + var instanceNames = _myInfoMateDbContext.Instances + .ToDictionary(i => i.Id, i => i.Name); + + return new OkObjectResult(_myInfoMateDbContext.Users.ToList().Select(u => + { + var dto = u.ToDTO(); + dto.instanceName = instanceNames.TryGetValue(u.InstanceId, out var name) ? name : null; + return dto; + })); } catch (Exception ex) { diff --git a/ManagerService/DTOs/ResourceBulkDeleteDTO.cs b/ManagerService/DTOs/ResourceBulkDeleteDTO.cs new file mode 100644 index 0000000..453cb1c --- /dev/null +++ b/ManagerService/DTOs/ResourceBulkDeleteDTO.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace ManagerService.DTOs +{ + public class ResourceBulkDeleteRequestDTO + { + public List ids { get; set; } + } + + public class ResourceBulkDeleteReportDTO + { + public List deleted { get; set; } = new List(); + + public List refused { get; set; } = new List(); + } + + public class ResourceBulkDeleteRefusalDTO + { + public string id { get; set; } + + public string label { get; set; } + + /// Zéro quand le refus vient d'autre chose qu'un usage (introuvable, stockage). + public int usageCount { get; set; } + + public string reason { get; set; } + } +} diff --git a/ManagerService/DTOs/ResourceDTO.cs b/ManagerService/DTOs/ResourceDTO.cs index cfa68ce..8e71fec 100644 --- a/ManagerService/DTOs/ResourceDTO.cs +++ b/ManagerService/DTOs/ResourceDTO.cs @@ -9,9 +9,19 @@ namespace ManagerService.DTOs public ResourceType type { get; set; } public string label { get; set; } public string url { get; set; } // firebase url - public DateTime dateCreation { get; set; } - public DateTime dateUpdate { get; set; } + public DateTime? dateCreation { get; set; } + public DateTime? dateUpdate { get; set; } public string instanceId { get; set; } public long sizeBytes { get; set; } + + /// Vrai nom de fichier ("brochure-2026.pdf"). Nul sur les ressources anciennes. + public string fileName { get; set; } + + public int? width { get; set; } + + public int? height { get; set; } + + /// Nombre d'usages. Rempli seulement par les endpoints qui le calculent. + public int? usageCount { get; set; } } } diff --git a/ManagerService/DTOs/ResourceUsageDTO.cs b/ManagerService/DTOs/ResourceUsageDTO.cs new file mode 100644 index 0000000..d9b36ae --- /dev/null +++ b/ManagerService/DTOs/ResourceUsageDTO.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace ManagerService.DTOs +{ + public class ResourceUsageDTO + { + /// Section | GuidedPath | GuidedStep | Configuration + public string kind { get; set; } + + public string id { get; set; } + + public string label { get; set; } + + public string configurationId { get; set; } + + public string configurationLabel { get; set; } + + /// Section porteuse, pour ouvrir le contenu depuis la Médiathèque. Nulle pour kind = Configuration. + public string sectionId { get; set; } + + /// Champ porteur quand il est identifiable ("image", "loaderImage"), sinon "contenu". + public string field { get; set; } + + /// Chaîne lisible affichée au front : "Escape game › Parcours › image". + public string path { get; set; } + } + + /// + /// Réponse de GET /api/Resource/usage-map : tout ce dont le rail de facettes de la + /// Médiathèque a besoin en une requête — un compteur par ressource, les configurations + /// où elle sert, et le libellé de ces configurations pour l'afficher sans second appel. + /// + public class ResourceUsageMapDTO + { + public Dictionary usages { get; set; } = new Dictionary(); + + public Dictionary configurations { get; set; } = new Dictionary(); + } + + public class ResourceUsageSummaryDTO + { + public int count { get; set; } + + public List configurationIds { get; set; } = new List(); + } +} diff --git a/ManagerService/DTOs/UserDetailDTO.cs b/ManagerService/DTOs/UserDetailDTO.cs index 6820b84..59c6162 100644 --- a/ManagerService/DTOs/UserDetailDTO.cs +++ b/ManagerService/DTOs/UserDetailDTO.cs @@ -9,6 +9,9 @@ namespace ManagerService.DTOs public string firstName { get; set; } public string lastName { get; set; } public string instanceId { get; set; } + + /// Nom de l'instance, renseigne uniquement pour un SuperAdmin qui liste toutes les instances. + public string? instanceName { get; set; } public UserRole? role { get; set; } public string? password { get; set; } } diff --git a/ManagerService/Data/Resource.cs b/ManagerService/Data/Resource.cs index 011e782..73eae4b 100644 --- a/ManagerService/Data/Resource.cs +++ b/ManagerService/Data/Resource.cs @@ -54,6 +54,12 @@ namespace ManagerService.Data /// public string FileName { get; set; } + /// Largeur en pixels, connue de manager-app au moment de l'upload. Nulle sur les ressources anciennes. + public int? Width { get; set; } + + /// Hauteur en pixels, connue de manager-app au moment de l'upload. Nulle sur les ressources anciennes. + public int? Height { get; set; } + /// Inclure ce document dans les connaissances du guide IA. Exposé dans l'UI en V2 seulement. public bool IncludeInAiKnowledge { get; set; } = false; @@ -77,7 +83,10 @@ namespace ManagerService.Data dateCreation = DateCreation, dateUpdate = DateUpdate, instanceId = InstanceId, - sizeBytes = SizeBytes + sizeBytes = SizeBytes, + fileName = FileName, + width = Width, + height = Height }; } } diff --git a/ManagerService/Migrations/20260902135901_AddResourceDimensions.Designer.cs b/ManagerService/Migrations/20260902135901_AddResourceDimensions.Designer.cs new file mode 100644 index 0000000..cce9065 --- /dev/null +++ b/ManagerService/Migrations/20260902135901_AddResourceDimensions.Designer.cs @@ -0,0 +1,1922 @@ +// +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("20260902135901_AddResourceDimensions")] + partial class AddResourceDimensions + { + /// + 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.HasIndex("InstanceId", "AppType") + .IsUnique(); + + 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("Height") + .HasColumnType("integer"); + + 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.Property("Width") + .HasColumnType("integer"); + + 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("Description") + .HasColumnType("jsonb"); + + b.Property("EstimatedDurationMinutes") + .HasColumnType("integer"); + + b.Property("GameMessageDebut") + .HasColumnType("jsonb"); + + b.Property("GameMessageFin") + .HasColumnType("jsonb"); + + b.Property("HideNextStepsUntilComplete") + .HasColumnType("boolean"); + + b.Property("ImageResourceId") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsGameMode") + .HasColumnType("boolean"); + + b.Property("IsLinear") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("RequireSuccessToAdvance") + .HasColumnType("boolean"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("SectionParcoursId") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("ImageResourceId"); + + b.HasIndex("InstanceId"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("SectionParcoursId"); + + b.ToTable("GuidedPaths"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AudioIds") + .HasColumnType("jsonb"); + + b.Property("Contents") + .HasColumnType("jsonb"); + + b.Property("Description") + .HasColumnType("jsonb"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("GuidedPathId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ImageUrl") + .HasColumnType("text"); + + b.Property("IsGeoTriggered") + .HasColumnType("boolean"); + + b.Property("IsStepTimer") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("TimerExpiredMessage") + .HasColumnType("jsonb"); + + b.Property("TimerSeconds") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ZoneRadiusMeters") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("GuidedPathId"); + + b.ToTable("GuidedSteps"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.QuizQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GuidedStepId") + .HasColumnType("text"); + + b.Property("IsSlidingPuzzle") + .HasColumnType("boolean"); + + b.Property>("Label") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("PuzzleCols") + .HasColumnType("integer"); + + b.Property("PuzzleImageId") + .HasColumnType("text"); + + b.Property("PuzzleRows") + .HasColumnType("integer"); + + b.Property("ResourceId") + .HasColumnType("text"); + + b.Property>("Responses") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SectionQuizId") + .HasColumnType("text"); + + b.Property("ValidationQuestionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GuidedStepId"); + + b.HasIndex("PuzzleImageId"); + + b.HasIndex("ResourceId"); + + b.HasIndex("SectionQuizId"); + + b.ToTable("QuizQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+MapAnnotation", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Geometry") + .HasColumnType("geometry"); + + b.Property("GeometryType") + .HasColumnType("integer"); + + b.Property("Icon") + .HasColumnType("text"); + + b.Property("IconResourceId") + .HasColumnType("text"); + + b.Property>("Label") + .HasColumnType("jsonb"); + + b.Property("PolyColor") + .HasColumnType("text"); + + b.Property("ProgrammeBlockId") + .HasColumnType("text"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property>("Type") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("IconResourceId"); + + b.HasIndex("ProgrammeBlockId"); + + b.HasIndex("SectionEventId"); + + b.ToTable("MapAnnotations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property>("Description") + .HasColumnType("jsonb"); + + b.Property("EndTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SectionEventId") + .HasColumnType("text"); + + b.Property("StartTime") + .HasColumnType("timestamp with time zone"); + + b.Property>("Title") + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.ToTable("ProgrammeBlocks"); + }); + + modelBuilder.Entity("ManagerService.Data.SubscriptionPlan", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AiTokensPerMonth") + .HasColumnType("bigint"); + + b.Property("HasAdvancedStats") + .HasColumnType("boolean"); + + b.Property("HasStats") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("StatsHistoryDays") + .HasColumnType("integer"); + + b.Property("StorageQuotaBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.ToTable("SubscriptionPlans"); + + b.HasData( + new + { + Id = "plan-essentiel", + AiTokensPerMonth = 0L, + HasAdvancedStats = false, + HasStats = true, + Name = "Essentiel", + StatsHistoryDays = 30, + StorageQuotaBytes = 1073741824L + }, + new + { + Id = "plan-pro", + AiTokensPerMonth = 0L, + HasAdvancedStats = false, + HasStats = true, + Name = "Pro", + StatsHistoryDays = 30, + StorageQuotaBytes = 16106127360L + }, + new + { + Id = "plan-premium", + AiTokensPerMonth = 20000000L, + HasAdvancedStats = true, + HasStats = true, + Name = "Premium", + StatsHistoryDays = 395, + StorageQuotaBytes = 53687091200L + }, + new + { + Id = "plan-enterprise", + AiTokensPerMonth = 9223372036854775807L, + HasAdvancedStats = true, + HasStats = true, + Name = "Enterprise", + StatsHistoryDays = 395, + StorageQuotaBytes = 0L + }); + }); + + modelBuilder.Entity("ManagerService.Data.User", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("DateCreation") + .HasColumnType("timestamp with time zone"); + + b.Property("DateUpdate") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text"); + + b.Property("PasswordTokenExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PasswordTokenHash") + .HasColumnType("text"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("ManagerService.Data.VisitEvent", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("ConfigurationId") + .HasColumnType("text"); + + b.Property("DurationSeconds") + .HasColumnType("integer"); + + b.Property("EventType") + .HasColumnType("integer"); + + b.Property("InstanceId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Language") + .HasColumnType("text"); + + b.Property("Metadata") + .HasColumnType("text"); + + b.Property("SectionId") + .HasColumnType("text"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.HasIndex("Timestamp"); + + b.ToTable("VisitEvents"); + }); + + modelBuilder.Entity("ManagerService.Data.VisitorQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AppType") + .HasColumnType("integer"); + + b.Property("CitedContentIds") + .HasColumnType("jsonb"); + + b.Property("ConfigurationId") + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HasAnswer") + .HasColumnType("boolean"); + + b.Property("InstanceId") + .HasColumnType("text"); + + b.Property("IsVoice") + .HasColumnType("boolean"); + + b.Property("Language") + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("Question") + .HasColumnType("text"); + + b.Property("Reply") + .HasColumnType("text"); + + b.Property("ThemeId") + .HasColumnType("text"); + + b.Property("TokensUsed") + .HasColumnType("bigint"); + + b.Property("TopScore") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId"); + + b.HasIndex("InstanceId", "CreatedAt"); + + b.ToTable("VisitorQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("AgendaMapProvider") + .HasColumnType("integer"); + + b.Property>("AgendaResourceIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("IsOnlineAgenda") + .HasColumnType("boolean"); + + b.HasDiscriminator().HasValue("Agenda"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionArticle", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("ArticleAudioIds") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("ArticleContent") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("ArticleContents") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("ArticleIsContentTop") + .HasColumnType("boolean"); + + b.Property("ArticleIsReadAudioAuto") + .HasColumnType("boolean"); + + b.HasDiscriminator().HasValue("Article"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("BaseSectionMapId") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("StartDate") + .HasColumnType("timestamp with time zone"); + + b.HasIndex("BaseSectionMapId"); + + b.HasDiscriminator().HasValue("Event"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("GameMessageDebut") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("GameMessageFin") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("GamePuzzleCols") + .HasColumnType("integer"); + + b.Property("GamePuzzleImageId") + .HasColumnType("text"); + + b.Property("GamePuzzleRows") + .HasColumnType("integer"); + + b.Property("GameType") + .HasColumnType("integer"); + + b.HasIndex("GamePuzzleImageId"); + + b.HasDiscriminator().HasValue("Game"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("IconResourceId") + .HasColumnType("text"); + + b.Property("IsListViewEnabled") + .HasColumnType("boolean"); + + b.Property>("MapCategories") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("MapCenterLatitude") + .HasColumnType("text"); + + b.Property("MapCenterLongitude") + .HasColumnType("text"); + + b.Property("MapMapProvider") + .HasColumnType("integer"); + + b.Property("MapMapType") + .HasColumnType("integer"); + + b.Property("MapTypeMapbox") + .HasColumnType("integer"); + + b.Property("MapZoom") + .HasColumnType("integer"); + + b.HasIndex("IconResourceId"); + + b.HasDiscriminator().HasValue("Map"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMenu", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.HasDiscriminator().HasValue("Menu"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionParcours", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("BaseSectionMapId") + .HasColumnType("text"); + + b.Property("ShowMap") + .HasColumnType("boolean"); + + b.HasIndex("BaseSectionMapId"); + + b.ToTable("Sections", t => + { + t.Property("BaseSectionMapId") + .HasColumnName("SectionParcours_BaseSectionMapId"); + }); + + b.HasDiscriminator().HasValue("Parcours"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionPdf", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("PDFOrderedTranslationAndResources") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("PDF"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionQuiz", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("QuizBadLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizGoodLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizGreatLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property>("QuizMediumLevel") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("Quiz"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionSlider", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property>("SliderContents") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasDiscriminator().HasValue("Slider"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionVideo", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("VideoSource") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Video"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionWeather", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("WeatherCity") + .HasColumnType("text"); + + b.Property("WeatherResult") + .HasColumnType("text"); + + b.Property("WeatherUpdatedDate") + .HasColumnType("timestamp with time zone"); + + b.HasDiscriminator().HasValue("Weather"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionWeb", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("WebSource") + .IsRequired() + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Web"); + }); + + modelBuilder.Entity("ManagerService.Data.ApiKey", b => + { + b.HasOne("ManagerService.Data.Instance", "Instance") + .WithMany() + .HasForeignKey("InstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Instance"); + }); + + modelBuilder.Entity("ManagerService.Data.AppConfigurationLink", b => + { + b.HasOne("ManagerService.Data.ApplicationInstance", "ApplicationInstance") + .WithMany("Configurations") + .HasForeignKey("ApplicationInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.Configuration", "Configuration") + .WithMany() + .HasForeignKey("ConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("ManagerService.Data.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId"); + + b.Navigation("ApplicationInstance"); + + b.Navigation("Configuration"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.Navigation("SectionEvent"); + }); + + modelBuilder.Entity("ManagerService.Data.Device", b => + { + b.HasOne("ManagerService.Data.Configuration", "Configuration") + .WithMany() + .HasForeignKey("ConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Configuration"); + }); + + modelBuilder.Entity("ManagerService.Data.Instance", b => + { + b.HasOne("ManagerService.Data.SubscriptionPlan", "SubscriptionPlan") + .WithMany() + .HasForeignKey("SubscriptionPlanId"); + + b.Navigation("SubscriptionPlan"); + }); + + modelBuilder.Entity("ManagerService.Data.Section", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMenu", null) + .WithMany("MenuSections") + .HasForeignKey("SectionMenuId"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.EventAgenda", b => + { + b.HasOne("ManagerService.Data.Resource", "Resource") + .WithMany() + .HasForeignKey("ResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionAgenda", "SectionAgenda") + .WithMany("EventAgendas") + .HasForeignKey("SectionAgendaId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.Resource", "VideoResource") + .WithMany() + .HasForeignKey("VideoResourceId"); + + b.Navigation("Resource"); + + b.Navigation("SectionAgenda"); + + b.Navigation("SectionEvent"); + + b.Navigation("VideoResource"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GeoPoint", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.SubSection.SectionMap", "SectionMap") + .WithMany("MapPoints") + .HasForeignKey("SectionMapId"); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.HasOne("ManagerService.Data.Resource", "ImageResource") + .WithMany() + .HasForeignKey("ImageResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", "SectionEvent") + .WithMany() + .HasForeignKey("SectionEventId"); + + b.HasOne("ManagerService.Data.SubSection.SectionParcours", "SectionParcours") + .WithMany("GuidedPaths") + .HasForeignKey("SectionParcoursId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("ImageResource"); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionParcours"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.HasOne("ManagerService.Data.SubSection.GuidedPath", "GuidedPath") + .WithMany("Steps") + .HasForeignKey("GuidedPathId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GuidedPath"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.QuizQuestion", b => + { + b.HasOne("ManagerService.Data.SubSection.GuidedStep", "GuidedStep") + .WithMany("QuizQuestions") + .HasForeignKey("GuidedStepId"); + + b.HasOne("ManagerService.Data.Resource", "PuzzleImage") + .WithMany() + .HasForeignKey("PuzzleImageId"); + + b.HasOne("ManagerService.Data.Resource", "Resource") + .WithMany() + .HasForeignKey("ResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionQuiz", "SectionQuiz") + .WithMany("QuizQuestions") + .HasForeignKey("SectionQuizId"); + + b.Navigation("GuidedStep"); + + b.Navigation("PuzzleImage"); + + b.Navigation("Resource"); + + b.Navigation("SectionQuiz"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+MapAnnotation", b => + { + b.HasOne("ManagerService.Data.Resource", "IconResource") + .WithMany() + .HasForeignKey("IconResourceId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", null) + .WithMany("MapAnnotations") + .HasForeignKey("ProgrammeBlockId"); + + b.HasOne("ManagerService.Data.SubSection.SectionEvent", null) + .WithMany("GlobalMapAnnotations") + .HasForeignKey("SectionEventId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("IconResource"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionEvent", null) + .WithMany("Programme") + .HasForeignKey("SectionEventId"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMap", "BaseMap") + .WithMany() + .HasForeignKey("BaseSectionMapId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseMap"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionGame", b => + { + b.HasOne("ManagerService.Data.Resource", "GamePuzzleImage") + .WithMany() + .HasForeignKey("GamePuzzleImageId"); + + b.Navigation("GamePuzzleImage"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.HasOne("ManagerService.Data.Resource", "IconResource") + .WithMany() + .HasForeignKey("IconResourceId"); + + b.Navigation("IconResource"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionParcours", b => + { + b.HasOne("ManagerService.Data.SubSection.SectionMap", "BaseMap") + .WithMany() + .HasForeignKey("BaseSectionMapId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseMap"); + }); + + modelBuilder.Entity("ManagerService.Data.ApplicationInstance", b => + { + b.Navigation("Configurations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => + { + b.Navigation("Steps"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.GuidedStep", b => + { + b.Navigation("QuizQuestions"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent+ProgrammeBlock", b => + { + b.Navigation("MapAnnotations"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionAgenda", b => + { + b.Navigation("EventAgendas"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionEvent", b => + { + b.Navigation("GlobalMapAnnotations"); + + b.Navigation("Programme"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMap", b => + { + b.Navigation("MapPoints"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionMenu", b => + { + b.Navigation("MenuSections"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionParcours", b => + { + b.Navigation("GuidedPaths"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionQuiz", b => + { + b.Navigation("QuizQuestions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ManagerService/Migrations/20260902135901_AddResourceDimensions.cs b/ManagerService/Migrations/20260902135901_AddResourceDimensions.cs new file mode 100644 index 0000000..1542568 --- /dev/null +++ b/ManagerService/Migrations/20260902135901_AddResourceDimensions.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ManagerService.Migrations +{ + /// + public partial class AddResourceDimensions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Height", + table: "Resources", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "Width", + table: "Resources", + type: "integer", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Height", + table: "Resources"); + + migrationBuilder.DropColumn( + name: "Width", + table: "Resources"); + } + } +} diff --git a/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs b/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs index 9c74d9e..d755c80 100644 --- a/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs +++ b/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs @@ -644,6 +644,9 @@ namespace ManagerService.Migrations b.Property("FileName") .HasColumnType("text"); + b.Property("Height") + .HasColumnType("integer"); + b.Property("IncludeInAiKnowledge") .HasColumnType("boolean"); @@ -667,6 +670,9 @@ namespace ManagerService.Migrations b.Property("Url") .HasColumnType("text"); + b.Property("Width") + .HasColumnType("integer"); + b.HasKey("Id"); b.HasIndex("InstanceId"); diff --git a/ManagerService/Services/ResourceUsageService.cs b/ManagerService/Services/ResourceUsageService.cs new file mode 100644 index 0000000..b5a8987 --- /dev/null +++ b/ManagerService/Services/ResourceUsageService.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text.RegularExpressions; +using Manager.DTOs; +using ManagerService.Data; +using ManagerService.Data.SubSection; +using ManagerService.DTOs; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; + +namespace ManagerService.Services +{ + /// + /// Index inverse des usages : quelle ressource est employée par quel contenu. + /// Parcourt dans l'autre sens — la même + /// méthode qui fait marcher l'export hors ligne, donc le même périmètre de champs. + /// + /// + /// Quatre points de vigilance, tous traités ici : + /// 1. Langue nulle = toutes les langues (voir SectionText.ResourceIds). Passer une langue + /// ferait passer pour orpheline une image posée uniquement en NL. + /// 2. Configuration.ImageId et LoaderImageId vivent hors des sections : ajoutés à part, + /// comme le fait ConfigurationController.Export. + /// 3. GuidedStep.ImageUrl est une URL absolue, pas un id : une image posée là est comptée + /// orpheline. Limite connue, migrée en V2, signalée dans l'UI. + /// 4. Un GuidedPath peut pendre d'un SectionEvent, dont GetReferencedResourceIds ne + /// descend pas dans les parcours. Les parcours sont donc parcourus séparément, et les + /// GuidedPaths ne sont volontairement pas chargés sur SectionParcours (pas de doublon). + /// + public class ResourceUsageService + { + private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30); + + private readonly MyInfoMateDbContext _db; + private readonly IMemoryCache _cache; + + public ResourceUsageService(MyInfoMateDbContext db, IMemoryCache cache) + { + _db = db; + _cache = cache; + } + + /// Usages de toutes les ressources d'une instance, indexés par id de ressource. + public Dictionary> GetUsageMap(string instanceId) + { + return _cache.GetOrCreate($"resource-usages:{instanceId}", entry => + { + entry.AbsoluteExpirationRelativeToNow = CacheDuration; + return Build(instanceId); + }); + } + + /// + /// Ce que le rail de facettes consomme : un résumé par ressource — zéro compris, sans + /// quoi « Jamais utilisées » n'aurait rien à lire — et le libellé des configurations. + /// + public ResourceUsageMapDTO GetUsageSummary(string instanceId) + { + var usages = GetUsageMap(instanceId); + var summary = new ResourceUsageMapDTO(); + + foreach (var id in _db.Resources.AsNoTracking() + .Where(r => r.InstanceId == instanceId) + .Select(r => r.Id) + .ToList()) + { + usages.TryGetValue(id, out var list); + summary.usages[id] = new ResourceUsageSummaryDTO + { + count = list?.Count ?? 0, + configurationIds = (list ?? new List()) + .Where(u => u.configurationId != null) + .Select(u => u.configurationId) + .Distinct() + .ToList() + }; + } + + foreach (var configuration in _db.Configurations.AsNoTracking() + .Where(c => c.InstanceId == instanceId) + .ToList()) + summary.configurations[configuration.Id] = configuration.Label; + + return summary; + } + + /// Usages d'une ressource. Liste vide si la ressource n'existe pas. + public List GetUsages(string resourceId) + { + var instanceId = _db.Resources.AsNoTracking() + .Where(r => r.Id == resourceId) + .Select(r => r.InstanceId) + .FirstOrDefault(); + + if (instanceId == null) + return new List(); + + return GetUsageMap(instanceId).TryGetValue(resourceId, out var usages) + ? usages + : new List(); + } + + public void Invalidate(string instanceId) => _cache.Remove($"resource-usages:{instanceId}"); + + private Dictionary> Build(string instanceId) + { + var map = new Dictionary>(); + + var configurations = _db.Configurations.AsNoTracking() + .Where(c => c.InstanceId == instanceId) + .ToList(); + var configurationLabels = configurations.ToDictionary(c => c.Id, c => c.Label); + + foreach (var configuration in configurations) + { + Add(map, configuration.ImageId, new ResourceUsageDTO + { + kind = "Configuration", + id = configuration.Id, + label = configuration.Label, + configurationId = configuration.Id, + configurationLabel = configuration.Label, + field = "image", + path = $"{configuration.Label} › image d'accueil" + }); + + Add(map, configuration.LoaderImageId, new ResourceUsageDTO + { + kind = "Configuration", + id = configuration.Id, + label = configuration.Label, + configurationId = configuration.Id, + configurationLabel = configuration.Label, + field = "loaderImage", + path = $"{configuration.Label} › écran de chargement" + }); + } + + var sections = LoadSections(instanceId); + + foreach (var section in sections) + { + var configurationLabel = Label(configurationLabels, section.ConfigurationId); + foreach (var resourceId in section.GetReferencedResourceIds().Distinct()) + { + var field = resourceId == section.ImageId ? "image" : "contenu"; + Add(map, resourceId, new ResourceUsageDTO + { + kind = "Section", + id = section.Id, + label = section.Label, + configurationId = section.ConfigurationId, + configurationLabel = configurationLabel, + sectionId = section.Id, + field = field, + path = $"{configurationLabel} › {section.Label} › {field}" + }); + } + } + + var sectionsById = sections.ToDictionary(s => s.Id, s => s); + + var guidedPaths = _db.GuidedPaths + .Where(p => p.InstanceId == instanceId) + .Include(p => p.Steps).ThenInclude(s => s.QuizQuestions) + .ToList(); + + foreach (var guidedPath in guidedPaths) + { + var parentId = guidedPath.SectionParcoursId ?? guidedPath.SectionEventId; + var parent = parentId != null && sectionsById.TryGetValue(parentId, out var s) ? s : null; + var configurationId = parent?.ConfigurationId; + var configurationLabel = Label(configurationLabels, configurationId); + var pathLabel = FirstTranslation(guidedPath.Title) ?? "Parcours"; + var prefix = parent != null + ? $"{configurationLabel} › {parent.Label} › {pathLabel}" + : $"{configurationLabel} › {pathLabel}"; + + var steps = guidedPath.Steps ?? new List(); + var stepResourceIds = steps.SelectMany(step => step.GetReferencedResourceIds()).ToHashSet(); + + // Les ids propres au parcours : ceux de l'agrégat moins ceux de ses étapes, + // pour ne pas attribuer au parcours ce qui appartient à une étape. + foreach (var resourceId in guidedPath.GetReferencedResourceIds().Distinct() + .Where(id => !stepResourceIds.Contains(id))) + { + Add(map, resourceId, new ResourceUsageDTO + { + kind = "GuidedPath", + id = guidedPath.Id, + label = pathLabel, + configurationId = configurationId, + configurationLabel = configurationLabel, + sectionId = parent?.Id, + field = resourceId == guidedPath.ImageResourceId ? "image" : "contenu", + path = prefix + }); + } + + foreach (var step in steps) + { + var stepLabel = FirstTranslation(step.Title) ?? $"Étape {step.Order + 1}"; + foreach (var resourceId in step.GetReferencedResourceIds().Distinct()) + { + Add(map, resourceId, new ResourceUsageDTO + { + kind = "GuidedStep", + id = step.Id, + label = stepLabel, + configurationId = configurationId, + configurationLabel = configurationLabel, + sectionId = parent?.Id, + field = "contenu", + path = $"{prefix} › {stepLabel}" + }); + } + } + } + + return map; + } + + /// + /// Pas d'AsNoTracking : les colonnes jsonb passent par un convertisseur, et le + /// provider InMemory des tests rend des collections vides sur une requête détachée. + /// Le service ne modifie rien, le suivi ne coûte donc que de la mémoire. + /// + /// Les sous-types dont le contenu vit dans des tables liées ont besoin d'un Include : + /// sans lui, la collection est vide et la section paraît ne référencer que sa vignette. + /// SectionParcours en est volontairement exclu — ses parcours sont traités à part. + /// + private List
LoadSections(string instanceId) + { + var sections = new List
(); + + sections.AddRange(_db.Sections.OfType() + .Where(s => s.InstanceId == instanceId) + .Include(s => s.QuizQuestions)); + + sections.AddRange(_db.Sections.OfType() + .Where(s => s.InstanceId == instanceId) + .Include(s => s.EventAgendas)); + + sections.AddRange(_db.Sections.OfType() + .Where(s => s.InstanceId == instanceId) + .Include(s => s.Programme).ThenInclude(b => b.MapAnnotations) + .Include(s => s.GlobalMapAnnotations)); + + sections.AddRange(_db.Sections.OfType() + .Where(s => s.InstanceId == instanceId) + .Include(s => s.MapPoints)); + + var loaded = new[] { SectionType.Quiz, SectionType.Agenda, SectionType.Event, SectionType.Map }; + sections.AddRange(_db.Sections + .Where(s => s.InstanceId == instanceId && !loaded.Contains(s.Type))); + + return sections; + } + + private static string Label(Dictionary labels, string configurationId) => + configurationId != null && labels.TryGetValue(configurationId, out var label) ? label : "Sans configuration"; + + /// + /// Les traductions sont stockées en HTML (l'éditeur Quill de manager-app les écrit + /// ainsi) : un titre de parcours vaut « <p>Chasse au trésor</p> ». Le chemin + /// affiché dans « Utilisée dans » est un fil d'Ariane sur une ligne, pas du contenu + /// riche — on en retire donc les balises plutôt que de les rendre. + /// + private static string FirstTranslation(IEnumerable translations) + { + var value = translations?.FirstOrDefault(t => !string.IsNullOrWhiteSpace(StripHtml(t.value)))?.value; + return value == null ? null : StripHtml(value); + } + + private static string StripHtml(string text) => + text == null + ? null + : Regex.Replace(WebUtility.HtmlDecode(Regex.Replace(text, "<[^>]+>", " ")), @"\s+", " ").Trim(); + + private static void Add(Dictionary> map, string resourceId, ResourceUsageDTO usage) + { + if (string.IsNullOrWhiteSpace(resourceId)) + return; + + if (!map.TryGetValue(resourceId, out var usages)) + map[resourceId] = usages = new List(); + + usages.Add(usage); + } + } +} diff --git a/ManagerService/Startup.cs b/ManagerService/Startup.cs index f74dbdb..60c5f56 100644 --- a/ManagerService/Startup.cs +++ b/ManagerService/Startup.cs @@ -173,6 +173,8 @@ namespace ManagerService services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddMemoryCache(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/ManagerService/appsettings.Development.json b/ManagerService/appsettings.Development.json index 00e1cb1..b2255f1 100644 --- a/ManagerService/appsettings.Development.json +++ b/ManagerService/appsettings.Development.json @@ -29,3 +29,4 @@ "Landing": "http://localhost:3000" } } +