diff --git a/ManagerService.Tests/Controllers/ConfigurationExportTests.cs b/ManagerService.Tests/Controllers/ConfigurationExportTests.cs index 1961e86..4884399 100644 --- a/ManagerService.Tests/Controllers/ConfigurationExportTests.cs +++ b/ManagerService.Tests/Controllers/ConfigurationExportTests.cs @@ -43,6 +43,67 @@ namespace ManagerService.Tests.Controllers }, }; + /// + /// ⚠️ Le second trou du même endroit, trouvé le 12/09. Les ressources + /// partaient bien, mais pas les champs qui les référencent : + /// Section.ToDTO() n'est pas virtuelle, et l'export appelait + /// s.ToDTO() sur une variable de type Section. Le JSON ne portait + /// donc que les champs communs — une galerie sans ses images, une carte sans ses + /// points, une maquette sans son modèle. L'export est censé être « tout le + /// contenu en un appel » ; il l'est depuis que SectionFactory.ToDTO fait + /// le sous-type réel. + /// + [Theory] + [InlineData(typeof(SectionMap), typeof(MapDTO))] + [InlineData(typeof(SectionSlider), typeof(SliderDTO))] + [InlineData(typeof(SectionVideo), typeof(VideoDTO))] + [InlineData(typeof(SectionScene3D), typeof(Scene3DDTO))] + [InlineData(typeof(SectionArticle), typeof(ArticleDTO))] + public void Export_ProducesTheRealSubTypeDto(System.Type sectionType, System.Type expectedDto) + { + var section = (Section)System.Activator.CreateInstance(sectionType)!; + section.Title = new List(); + section.Description = new List(); + + var dto = ManagerService.Services.SectionFactory.ToDTO(section); + + Assert.IsType(expectedDto, dto); + Assert.IsAssignableFrom(dto); + } + + [Fact] + public void Export_KeepsModel3DFields() + { + var section = new SectionScene3D + { + Id = "s1", + Title = new List(), + Description = new List(), + Model3DResourceId = "res-glb", + Model3DSource = "https://cdn/model.glb", + Points = new List + { + new GeoPoint + { + Id = 1, + Title = new List(), + Description = new List(), + Contents = new List(), + LocalTransform = new Position3D { x = 1.5f, y = 0.2f, z = -3f } + } + } + }; + + var dto = Assert.IsType(ManagerService.Services.SectionFactory.ToDTO(section)); + + Assert.Equal("res-glb", dto.model3DResourceId); + Assert.Equal("https://cdn/model.glb", dto.model3DSource); + + var point = Assert.Single(dto.points); + Assert.Equal(1.5f, point.localTransform.x); + Assert.Equal(-3f, point.localTransform.z); + } + [Fact] public void Article_ExposesItsContentAndAudioResources() { diff --git a/ManagerService.Tests/Controllers/DeviceControllerTests.cs b/ManagerService.Tests/Controllers/DeviceControllerTests.cs index 1926228..b6b89ee 100644 --- a/ManagerService.Tests/Controllers/DeviceControllerTests.cs +++ b/ManagerService.Tests/Controllers/DeviceControllerTests.cs @@ -28,9 +28,91 @@ namespace ManagerService.Tests.Controllers { db.Configurations.Add(new Configuration { Id = "conf-1", InstanceId = "inst-test", Label = "Conf", Title = new System.Collections.Generic.List() }); db.ApplicationInstances.Add(new ApplicationInstance { Id = "ai-1", InstanceId = "inst-test", AppType = AppType.Tablet }); + db.ApplicationInstances.Add(new ApplicationInstance { Id = "ai-vr", InstanceId = "inst-test", AppType = AppType.VR }); db.SaveChanges(); } + // ── HEARTBEAT (XR-5) ───────────────────────────────────────────────── + + private static Device SeedDevice(MyInfoMateDbContext db, string instanceId = "inst-test") + { + var device = new Device + { + Id = "dev-1", + Identifier = "headset-abc", + InstanceId = instanceId, + ConfigurationId = "conf-1", + AppType = AppType.VR, + Connected = false + }; + db.Devices.Add(device); + db.SaveChanges(); + return device; + } + + [Fact] + public void Heartbeat_WritesBatteryVersionAndLastSeen() + { + using var db = DbContextFactory.Create(); + SeedPrerequisites(db); + SeedDevice(db); + + var result = BuildController(db).Heartbeat("dev-1", new DeviceHeartbeatDTO + { + batteryLevel = "87", + appVersion = "1.4.2" + }); + + Assert.IsType(result); + + var device = db.Devices.First(); + Assert.Equal("87", device.BatteryLevel); + Assert.Equal("1.4.2", device.AppVersion); + Assert.NotNull(device.LastSeen); + + // Recevoir le battement est la preuve que l'appareil est en ligne : l'app + // n'a pas à le déclarer. + Assert.True(device.Connected); + } + + [Fact] + public void Heartbeat_CannotRenameOrMoveTheDevice() + { + using var db = DbContextFactory.Create(); + SeedPrerequisites(db); + SeedDevice(db); + + BuildController(db).Heartbeat("dev-1", new DeviceHeartbeatDTO { batteryLevel = "50" }); + + var device = db.Devices.First(); + Assert.Equal("inst-test", device.InstanceId); + Assert.Equal("conf-1", device.ConfigurationId); + Assert.Equal("headset-abc", device.Identifier); + } + + [Fact] + public void Heartbeat_OtherInstance_Returns403() + { + using var db = DbContextFactory.Create(); + SeedPrerequisites(db); + SeedDevice(db, instanceId: "autre-instance"); + + var result = BuildController(db).Heartbeat("dev-1", new DeviceHeartbeatDTO { batteryLevel = "10" }); + + Assert.Equal(403, result.StatusCode); + } + + [Fact] + public void Heartbeat_UnknownDevice_Returns404() + { + using var db = DbContextFactory.Create(); + SeedPrerequisites(db); + + var result = BuildController(db).Heartbeat("nope", new DeviceHeartbeatDTO()); + + Assert.IsType(result); + } + // ── CREATE ─────────────────────────────────────────────────────────── [Fact] @@ -132,5 +214,84 @@ namespace ManagerService.Tests.Controllers Assert.IsType(result); } + + // ── CANAL (AppType) ────────────────────────────────────────────────── + + [Fact] + public void Create_VrDevice_LinksToVrApplicationInstance() + { + using var db = DbContextFactory.Create(); + SeedPrerequisites(db); + + var result = BuildController(db).Create(new DeviceDetailDTO + { + identifier = "quest-1", + instanceId = "inst-test", + configurationId = "conf-1", + name = "Casque 1", + appType = AppType.VR + }); + + Assert.IsType(result); + Assert.Equal(AppType.VR, db.Devices.Single().AppType); + // Le rattachement se faisait sur AppType.Tablet en dur : un casque atterrissait + // dans l'onglet Kiosk. + Assert.Equal("ai-vr", db.AppConfigurationLinks.Single().ApplicationInstanceId); + } + + [Fact] + public void Create_WithoutAppType_DefaultsToTablet() + { + using var db = DbContextFactory.Create(); + SeedPrerequisites(db); + + BuildController(db).Create(new DeviceDetailDTO + { + identifier = "device-abc", + instanceId = "inst-test", + configurationId = "conf-1", + name = "Tablet 1" + }); + + Assert.Equal(AppType.Tablet, db.Devices.Single().AppType); + Assert.Equal("ai-1", db.AppConfigurationLinks.Single().ApplicationInstanceId); + } + + [Fact] + public void Create_VrDevice_WithoutVrApplicationInstance_Returns404() + { + using var db = DbContextFactory.Create(); + db.Configurations.Add(new Configuration { Id = "conf-1", InstanceId = "inst-test", Label = "Conf", Title = new System.Collections.Generic.List() }); + db.ApplicationInstances.Add(new ApplicationInstance { Id = "ai-1", InstanceId = "inst-test", AppType = AppType.Tablet }); + db.SaveChanges(); + + var result = BuildController(db).Create(new DeviceDetailDTO + { + identifier = "quest-1", + instanceId = "inst-test", + configurationId = "conf-1", + appType = AppType.VR + }); + + Assert.IsType(result); + Assert.Equal(0, db.Devices.Count()); + } + + [Fact] + public void Get_FiltersByAppType() + { + using var db = DbContextFactory.Create(); + SeedPrerequisites(db); + db.Devices.Add(new Device { Id = "d1", Identifier = "tab-1", InstanceId = "inst-test", ConfigurationId = "conf-1", AppType = AppType.Tablet }); + db.Devices.Add(new Device { Id = "d2", Identifier = "quest-1", InstanceId = "inst-test", ConfigurationId = "conf-1", AppType = AppType.VR }); + db.SaveChanges(); + + var vr = Assert.IsType(BuildController(db).Get(null, AppType.VR)); + Assert.Equal("quest-1", Assert.Single((System.Collections.Generic.IEnumerable)vr.Value).identifier); + + // Sans filtre : comportement historique, tous les appareils. + var all = Assert.IsType(BuildController(db).Get(null)); + Assert.Equal(2, ((System.Collections.Generic.IEnumerable)all.Value).Count()); + } } } diff --git a/ManagerService.Tests/Controllers/InstanceAddonTests.cs b/ManagerService.Tests/Controllers/InstanceAddonTests.cs new file mode 100644 index 0000000..48ebb27 --- /dev/null +++ b/ManagerService.Tests/Controllers/InstanceAddonTests.cs @@ -0,0 +1,170 @@ +using Hangfire; +using Manager.Services; +using Manager.Interfaces.Models; +using ManagerService.Controllers; +using ManagerService.Helpers; +using ManagerService.Data; +using ManagerService.DTOs; +using ManagerService.Services; +using ManagerService.Tests.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace ManagerService.Tests.Controllers +{ + /// + /// L'add-on « Contenu immersif » — §6 de DOCS/v2/vr-quest-unity-plan.md. + /// + /// ⚠️ Ces tests existent pour une raison précise : la migration pose un + /// defaultValue, ce qui amène EF Core à traiter la colonne comme + /// ValueGeneratedOnAddread-only après insert. Le piège est déjà + /// documenté dans MyInfoMateDbContext pour les quotas, et il a coûté une + /// migration corrigée à la main sur Device.AppType. Sans + /// ValueGeneratedNever, l'add-on ne s'activerait jamais — silencieusement. + /// + public class InstanceAddonTests + { + private InstanceController BuildController(MyInfoMateDbContext db) + { + var cfg = FakeMongoConfig.Create(); + return new InstanceController( + NullLogger.Instance, + new InstanceDatabaseService(cfg), + new UserDatabaseService(cfg), + new ProfileLogic(NullLogger.Instance), + db, + new ApiKeyDatabaseService(db), + new Mock().Object); + } + + [Fact] + public void ImmersiveContent_IsOffByDefault() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow }); + db.SaveChanges(); + + Assert.False(db.Instances.First().HasImmersiveContent); + } + + [Fact] + public void ImmersiveContent_CanBeTurnedOn() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow }); + db.SaveChanges(); + + var result = BuildController(db).Updateinstance( + new InstanceDTO { id = "i1", hasImmersiveContent = true }); + + Assert.IsType(result); + Assert.True(db.Instances.First().HasImmersiveContent); + } + + [Fact] + public void ImmersiveContent_AbsentFromPayload_KeepsPreviousValue() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance + { + Id = "i1", + Name = "Musée", + DateCreation = DateTime.UtcNow, + HasImmersiveContent = true + }); + db.SaveChanges(); + + // Un écran qui ne parle pas de l'add-on ne doit pas le désactiver au passage : + // le contrôleur applique `?? valeur actuelle`, comme pour les autres drapeaux. + BuildController(db).Updateinstance(new InstanceDTO { id = "i1", name = "Musée" }); + + Assert.True(db.Instances.First().HasImmersiveContent); + } + + private const long Envelope = 100L * 1024 * 1024 * 1024; + private const long PlanQuota = 10L * 1024 * 1024 * 1024; + + private static MyInfoMateDbContext WithPlan(long quota = PlanQuota) + { + var db = DbContextFactory.Create(); + db.SubscriptionPlans.Add(new SubscriptionPlan + { + Id = "pro", + Name = "Pro", + StorageQuotaBytes = quota + }); + db.Instances.Add(new Instance + { + Id = "i1", + Name = "Musée", + DateCreation = DateTime.UtcNow, + StorageQuotaBytes = quota + }); + db.SaveChanges(); + return db; + } + + [Fact] + public void ActivatingImmersive_RaisesStorageQuota() + { + using var db = WithPlan(); + + BuildController(db).Updateinstance( + new InstanceDTO { id = "i1", hasImmersiveContent = true }); + + // Sans ce relèvement, un client Pro activait la 360 et saturait son quota à la + // première vidéo : c'est le point de marge du §6 du plan. + Assert.Equal(PlanQuota + Envelope, db.Instances.First().StorageQuotaBytes); + } + + [Fact] + public void DeactivatingImmersive_LowersStorageQuotaBack() + { + using var db = WithPlan(); + var controller = BuildController(db); + + controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = true }); + controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = false }); + + Assert.Equal(PlanQuota, db.Instances.First().StorageQuotaBytes); + } + + [Fact] + public void ActivatingImmersiveTwice_DoesNotStackEnvelopes() + { + using var db = WithPlan(); + var controller = BuildController(db); + + controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = true }); + controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = true }); + + // Un écran qui renvoie tout le formulaire à chaque enregistrement repasse `true` + // sans rien changer : le quota ne doit pas grimper d'une enveloppe par clic. + Assert.Equal(PlanQuota + Envelope, db.Instances.First().StorageQuotaBytes); + } + + [Fact] + public void ChangingPlan_KeepsTheImmersiveEnvelope() + { + using var db = WithPlan(); + db.SubscriptionPlans.Add(new SubscriptionPlan + { + Id = "premium", + Name = "Premium", + StorageQuotaBytes = 50L * 1024 * 1024 * 1024 + }); + db.SaveChanges(); + + var controller = BuildController(db); + controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = true }); + controller.Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "premium" }); + + // ApplyPlanQuotas réécrit le quota depuis le plan : sans reposer l'enveloppe + // par-dessus, faire monter un client en gamme lui retirerait son add-on. + Assert.Equal(50L * 1024 * 1024 * 1024 + Envelope, + db.Instances.First().StorageQuotaBytes); + } + } +} diff --git a/ManagerService.Tests/Controllers/InstanceControllerTests.cs b/ManagerService.Tests/Controllers/InstanceControllerTests.cs index 83c8409..4d7a682 100644 --- a/ManagerService.Tests/Controllers/InstanceControllerTests.cs +++ b/ManagerService.Tests/Controllers/InstanceControllerTests.cs @@ -69,6 +69,77 @@ namespace ManagerService.Tests.Controllers Assert.IsType(result); } + // ── ROUTES ANONYMES ────────────────────────────────────────────────── + // Le slug est public : c'est l'URL du site visiteur. Ce qui sort d'ici sort + // donc pour tout le monde, et un ajout au DTO ne doit plus jamais fuiter en + // silence — c'est l'objet de ces trois tests. + + private static Instance BuildInstanceWithSecrets() => new Instance + { + Id = "i1", + Name = "Musée", + DateCreation = DateTime.UtcNow, + WebSlug = "musee", + PinCode = "4821", + PublicApiKey = "ap_xxx", + BillingAddress = "12 rue des Tests", + VatNumber = "BE0123456789", + StorageQuotaBytes = 42, + IsTrialActive = true + }; + + [Fact] + public void GetInstanceBySlug_DoesNotLeakPinCodeOrBilling() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(BuildInstanceWithSecrets()); + db.SaveChanges(); + + var result = Assert.IsType(BuildController(db).GetInstanceBySlug("musee")); + var dto = Assert.IsType(result.Value); + + // Le pinCode ouvre l'appairage des tablettes et des casques. + Assert.Null(dto.pinCode); + Assert.Null(dto.billingAddress); + Assert.Null(dto.vatNumber); + Assert.Null(dto.storageQuotaBytes); + Assert.Null(dto.subscriptionPlan); + } + + [Fact] + public void GetInstanceBySlug_KeepsWhatTheWebAppNeeds() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(BuildInstanceWithSecrets()); + db.SaveChanges(); + + var result = Assert.IsType(BuildController(db).GetInstanceBySlug("musee")); + var dto = Assert.IsType(result.Value); + + // visitapp-web s'amorce sur cette route : sans la clé il n'a rien, et sans + // isTrialActive le filigrane d'essai disparaît ([slug]/layout.tsx:41). + Assert.Equal("ap_xxx", dto.publicApiKey); + Assert.Equal("musee", dto.webSlug); + Assert.True(dto.isTrialActive); + } + + [Fact] + public void GetInstanceByPinCode_KeepsPinButDropsBilling() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(BuildInstanceWithSecrets()); + db.SaveChanges(); + + var result = Assert.IsType(BuildController(db).GetInstanceByPinCode("4821")); + var dto = Assert.IsType(result.Value); + + // L'appelant a fourni le pinCode : le rendre ne lui apprend rien. Le reste, + // si. + Assert.Equal("4821", dto.pinCode); + Assert.Null(dto.billingAddress); + Assert.Null(dto.vatNumber); + } + // ── UPDATE ─────────────────────────────────────────────────────────── [Fact] diff --git a/ManagerService.Tests/Data/AuditLogTests.cs b/ManagerService.Tests/Data/AuditLogTests.cs index 4d17993..f6e832c 100644 --- a/ManagerService.Tests/Data/AuditLogTests.cs +++ b/ManagerService.Tests/Data/AuditLogTests.cs @@ -222,7 +222,10 @@ namespace ManagerService.Tests.Data .Where(t => typeof(Section).IsAssignableFrom(t) && !t.IsAbstract) .ToList(); - Assert.Equal(13, subTypes.Count); + // 14 depuis le 12/09 : SectionScene3D (maquette 3D, item E7 du lot XR-4). + // Ce chiffre est volontairement en dur — il force à passer ici, donc à + // vérifier que le nouveau sous-type est bien journalisé. + Assert.Equal(14, subTypes.Count); var auditedTypeOf = typeof(MyInfoMateDbContext) .GetMethod("AuditedTypeOf", BindingFlags.NonPublic | BindingFlags.Static)!; diff --git a/ManagerService.Tests/Data/ResourceTypeTests.cs b/ManagerService.Tests/Data/ResourceTypeTests.cs new file mode 100644 index 0000000..007ddb1 --- /dev/null +++ b/ManagerService.Tests/Data/ResourceTypeTests.cs @@ -0,0 +1,35 @@ +using ManagerService.Data; +using Xunit; + +namespace ManagerService.Tests.Data +{ + /// + /// ResourceType est persisté en int dans une colonne integer : + /// réordonner l'enum ou insérer une valeur au milieu réécrirait silencieusement le + /// type de toutes les ressources déjà en base — les PDF deviendraient des JSON, et + /// rien ne le signalerait. Ce test est le garde-fou : il échoue à la moindre + /// insertion ailleurs qu'à la fin. + /// + public class ResourceTypeTests + { + [Theory] + [InlineData(ResourceType.Image, 0)] + [InlineData(ResourceType.Video, 1)] + [InlineData(ResourceType.ImageUrl, 2)] + [InlineData(ResourceType.VideoUrl, 3)] + [InlineData(ResourceType.Audio, 4)] + [InlineData(ResourceType.PDF, 5)] + [InlineData(ResourceType.JSON, 6)] + [InlineData(ResourceType.JSONUrl, 7)] + [InlineData(ResourceType.Word, 8)] + [InlineData(ResourceType.PowerPoint, 9)] + [InlineData(ResourceType.Text, 10)] + [InlineData(ResourceType.Image360, 11)] + [InlineData(ResourceType.Video360, 12)] + [InlineData(ResourceType.Model3D, 13)] + public void Values_AreStable(ResourceType type, int expected) + { + Assert.Equal(expected, (int)type); + } + } +} diff --git a/ManagerService.Tests/Security/RequireAppKeyAttributeTests.cs b/ManagerService.Tests/Security/RequireAppKeyAttributeTests.cs new file mode 100644 index 0000000..ad9655f --- /dev/null +++ b/ManagerService.Tests/Security/RequireAppKeyAttributeTests.cs @@ -0,0 +1,101 @@ +using System.Security.Claims; +using ManagerService.Security; +using ManagerService.Tests.Infrastructure; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using Xunit; + +namespace ManagerService.Tests.Security +{ + /// + /// Les tests des contrôleurs appellent les méthodes directement : aucun filtre + /// d'autorisation ne s'y exécute, et ils ne prouvent donc rien sur la fermeture des + /// routes. C'est ce trou-là que ces tests comblent — ils visent le filtre lui-même. + /// + public class RequireAppKeyAttributeTests + { + private static AuthorizationFilterContext BuildContext( + bool apiKeyValid, ClaimsPrincipal user = null) + { + var authentication = new Mock(); + authentication + .Setup(a => a.AuthenticateAsync(It.IsAny(), + RequireAppKeyAttribute.ApiKeyScheme)) + .ReturnsAsync(apiKeyValid + ? AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity("ApiKey")), + RequireAppKeyAttribute.ApiKeyScheme)) + : AuthenticateResult.NoResult()); + + var services = new ServiceCollection(); + services.AddSingleton(authentication.Object); + + var http = new DefaultHttpContext + { + RequestServices = services.BuildServiceProvider(), + User = user ?? new ClaimsPrincipal(new ClaimsIdentity()) + }; + + return new AuthorizationFilterContext( + new ActionContext(http, new RouteData(), new ActionDescriptor()), + new List()); + } + + [Fact] + public async Task NoKeyNoUser_Returns401() + { + var context = BuildContext(apiKeyValid: false); + + await new RequireAppKeyAttribute().OnAuthorizationAsync(context); + + var result = Assert.IsType(context.Result); + Assert.Equal(401, result.StatusCode); + } + + [Fact] + public async Task ValidApiKey_LetsThrough() + { + var context = BuildContext(apiKeyValid: true); + + await new RequireAppKeyAttribute().OnAuthorizationAsync(context); + + Assert.Null(context.Result); + } + + [Fact] + public async Task ManagerUser_LetsThrough() + { + // Le back-office appelle les mêmes routes avec son jeton, sans clé d'API. + var manager = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim(ClaimTypeKeys.Permission, Permissions.Viewer) }, + authenticationType: "Bearer")); + + var context = BuildContext(apiKeyValid: false, user: manager); + + await new RequireAppKeyAttribute().OnAuthorizationAsync(context); + + Assert.Null(context.Result); + } + + [Fact] + public async Task AuthenticatedWithoutPermission_Returns401() + { + // Authentifié ne suffit pas : il faut la permission de lecture. + var stranger = new ClaimsPrincipal(new ClaimsIdentity( + new[] { new Claim("sub", "someone") }, authenticationType: "Bearer")); + + var context = BuildContext(apiKeyValid: false, user: stranger); + + await new RequireAppKeyAttribute().OnAuthorizationAsync(context); + + var result = Assert.IsType(context.Result); + Assert.Equal(401, result.StatusCode); + } + } +} diff --git a/ManagerService/Controllers/ApplicationInstanceController.cs b/ManagerService/Controllers/ApplicationInstanceController.cs index 46f114d..f9cda54 100644 --- a/ManagerService/Controllers/ApplicationInstanceController.cs +++ b/ManagerService/Controllers/ApplicationInstanceController.cs @@ -7,6 +7,7 @@ using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; +using ManagerService.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -224,6 +225,7 @@ namespace ManagerService.Controllers /// /// application instance id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 500)] [HttpGet("{applicationInstanceId}/application-link")] diff --git a/ManagerService/Controllers/ConfigurationController.cs b/ManagerService/Controllers/ConfigurationController.cs index c89c7fb..73780d3 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; @@ -14,7 +14,9 @@ using ManagerService.DTOs; using ManagerService.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using ManagerService.Security; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Mqtt.Client.AspNetCore.Services; @@ -51,6 +53,7 @@ namespace ManagerService.Controllers /// /// id instance [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 500)] [HttpGet] @@ -116,6 +119,7 @@ namespace ManagerService.Controllers /// /// id configuration [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(ConfigurationDTO), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -296,6 +300,8 @@ namespace ManagerService.Controllers configuration.IsOffline = updatedConfiguration.isOffline; configuration.LoaderImageId = updatedConfiguration.loaderImageId; configuration.LoaderImageUrl = updatedConfiguration.loaderImageUrl; + configuration.ImmersiveBackground = + ImmersiveBackground.FromDTO(updatedConfiguration.immersiveBackground); //Configuration configurationModified = _configurationService.Update(updatedConfiguration.id, configuration); _myInfoMateDbContext.SaveChanges(); @@ -436,7 +442,29 @@ namespace ManagerService.Controllers // Les entités, pas seulement leurs DTO : la collecte des ressources passe // par GetReferencedResourceIds, qui vit sur le sous-type. List
sections = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == configuration.Id).ToList(); - List sectionDTOs = sections.Select(s => s.ToDTO()).ToList(); + + // Les collections des sous-types sont dans d'autres tables : sans ces + // chargements, une Map exporte zéro point et une maquette zéro POI. EF + // rattache les entités à celles déjà suivies, il n'y a rien à réaffecter. + var configurationId = configuration.Id; + + _myInfoMateDbContext.Sections.OfType() + .Include(s => s.MapPoints) + .Where(s => s.ConfigurationId == configurationId).ToList(); + + _myInfoMateDbContext.Sections.OfType() + .Include(s => s.Points) + .Where(s => s.ConfigurationId == configurationId).ToList(); + + // ⚠️ `Section.ToDTO()` n'est pas virtuelle : appelée sur une variable de + // type `Section`, elle ne rend que les champs communs. L'export ne portait + // donc **aucun champ spécifique** — ni les contenus d'un Slider, ni les + // points d'une Map, ni le programme d'un Event — alors qu'il est censé + // être « tout le contenu en un appel ». `SectionFactory.ToDTO` fait le + // bon sous-type, et la sérialisation suit le type réel. + List sectionDTOs = sections + .Select(s => (SectionDTO)SectionFactory.ToDTO(s)) + .ToList(); List resourceDTOs = new List(); if (configuration.ImageId != null) @@ -449,6 +477,15 @@ namespace ManagerService.Controllers addResourceToList(resourceDTOs, configuration.LoaderImageId); } + // Le fond immersif est une ressource comme une autre : oubliée ici, elle + // n'existerait pas dans la visite hors ligne, et le casque afficherait son + // menu dans le noir dès la première coupure de réseau. + if (configuration.ImmersiveBackground != null) + { + addResourceToList(resourceDTOs, configuration.ImmersiveBackground.ResourceId); + addResourceToList(resourceDTOs, configuration.ImmersiveBackground.FallbackResourceId); + } + foreach (var section in sections) { // Remplace 157 lignes de `switch` commenté qui n'ont jamais tourné : @@ -462,6 +499,19 @@ namespace ManagerService.Controllers addResourceToList(resourceDTOs, resourceId); } ExportConfigurationDTO toDownload = configuration.ToExportDTO(sectionDTOs, resourceDTOs); + + // Les URL sont posées ici parce que c'est le seul endroit qui a les + // ressources sous la main. Le casque lit ce JSON sans client généré et + // souvent sans réseau : lui faire résoudre un id de plus serait un appel + // qu'il ne peut pas passer. + if (toDownload.immersiveBackground != null) + { + toDownload.immersiveBackground.resourceUrl = resourceDTOs + .FirstOrDefault(r => r.id == toDownload.immersiveBackground.resourceId)?.url; + toDownload.immersiveBackground.fallbackUrl = resourceDTOs + .FirstOrDefault(r => r.id == toDownload.immersiveBackground.fallbackResourceId)?.url; + } + string jsonString = JsonConvert.SerializeObject(toDownload); var fileName = $"{configuration.Label}.json"; var mimeType = "application/json"; @@ -534,6 +584,17 @@ namespace ManagerService.Controllers createResource(exportConfiguration.resources.Where(r => r.id == configuration.LoaderImageId).FirstOrDefault()); } + configuration.ImmersiveBackground = + ImmersiveBackground.FromDTO(exportConfiguration.immersiveBackground); + + if (configuration.ImmersiveBackground != null) + { + createResource(exportConfiguration.resources.FirstOrDefault( + r => r.id == configuration.ImmersiveBackground.ResourceId)); + createResource(exportConfiguration.resources.FirstOrDefault( + r => r.id == configuration.ImmersiveBackground.FallbackResourceId)); + } + _myInfoMateDbContext.Configurations.Add(configuration); //_configurationService.Create(configuration); diff --git a/ManagerService/Controllers/DeviceController.cs b/ManagerService/Controllers/DeviceController.cs index 96e5f30..6cf62fe 100644 --- a/ManagerService/Controllers/DeviceController.cs +++ b/ManagerService/Controllers/DeviceController.cs @@ -43,11 +43,12 @@ namespace ManagerService.Controllers /// /// Get a list of all devices /// - /// id instance + /// id instance + /// Canal à filtrer. Absent = tous les appareils, comportement historique. [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 500)] [HttpGet] - public ObjectResult Get([FromQuery] string instanceId) + public ObjectResult Get([FromQuery] string instanceId, [FromQuery] AppType? appType = null) { try { @@ -56,6 +57,8 @@ namespace ManagerService.Controllers var query = _myInfoMateDbContext.Devices.Include(d => d.Configuration).AsQueryable(); if (scopedInstanceId != null) query = query.Where(d => d.InstanceId == scopedInstanceId); + if (appType != null) + query = query.Where(d => d.AppType == appType.Value); return new OkObjectResult(query.ToList().Select(d => d.ToDTO())); } @@ -70,6 +73,19 @@ namespace ManagerService.Controllers /// Get a specific device /// /// id device + /// + /// ⚠️ C'est le premier appel d'une tablette qui démarre : + /// tablet-app/lib/main.dart:38 reconstruit son client avec l'hôte mémorisé, + /// puis demande son propre détail pour savoir quelle configuration afficher. Sans + /// exception à la policy InstanceAdmin de la classe, cet appel répondait + /// 403 et la tablette ne retrouvait plus son contenu au démarrage — même + /// cause que l'appairage cassé depuis le 13/03/2026 (voir ). + /// + /// Le cloisonnement est déjà écrit plus bas : une clé ne voit que les appareils + /// de son instance, et un appareil d'ailleurs ressort en 404. + /// + [AllowAnonymous] + [Security.RequireAppKey] [ProducesResponseType(typeof(DeviceDetailDTO), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -100,6 +116,21 @@ namespace ManagerService.Controllers /// Create a new device /// /// New device info + /// + /// ⚠️ C'est une app qui appelle cette route, pas un humain : une tablette + /// qui s'appaire avec un code PIN, et bientôt un casque. Or le contrôleur exige + /// InstanceAdmin, qu'une clé d'API n'a pas — elle ne porte que + /// AppRead et Viewer — et tablet-app ne s'authentifie + /// jamais autrement. Depuis que la policy a été posée sur la classe (commit + /// a452f4a, 13/03/2026, « need to be tested »), l'appairage d'une + /// nouvelle tablette répondait 403 ; les tablettes déjà appairées ne + /// rappellent pas cette route, donc rien ne le signalait. + /// + /// Le cloisonnement, lui, était déjà écrit juste en dessous : une clé ne peut + /// créer un appareil que dans son instance. + /// + [AllowAnonymous] + [Security.RequireAppKey] [ProducesResponseType(typeof(DeviceDetailDTO), 200)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] @@ -151,11 +182,14 @@ namespace ManagerService.Controllers device.LastConnectionLevel = newDevice.lastConnectionLevel; device.BatteryLevel = newDevice.batteryLevel; device.LastBatteryLevel = newDevice.lastBatteryLevel; + device.AppType = newDevice.appType; - ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.FirstOrDefault(ai => ai.InstanceId == newDevice.instanceId && ai.AppType == AppType.Tablet); + // Était hardcodé sur AppType.Tablet : un casque enregistré par ce chemin était + // rattaché à l'instance kiosk et apparaissait dans l'onglet Kiosk. + ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.FirstOrDefault(ai => ai.InstanceId == newDevice.instanceId && ai.AppType == newDevice.appType); if (applicationInstance == null) - throw new KeyNotFoundException("Application instance does not exist"); + throw new KeyNotFoundException($"Application instance does not exist for app type {newDevice.appType}"); //OldDevice deviceCreated = _deviceService.IsExistIdentifier(newDevice.identifier) ? _deviceService.Update(device.Id, device) : _deviceService.Create(device); if (deviceDB != null) @@ -263,10 +297,86 @@ namespace ManagerService.Controllers } } + /// + /// Heartbeat sent by a running app: battery, app version, last seen. + /// + /// Device id + /// What the app knows about itself + /// + /// Lot XR-5. L'onglet XR affiche batterie, version et dernier vu depuis + /// le 12/09 — mais rien ne les alimentait : Update n'écrit ni + /// AppVersion ni LastSeen, et exige de toute façon un compte admin. + /// + /// Volontairement étroit : une app en fonctionnement ne doit pas pouvoir se + /// renommer, changer d'instance ni se réassigner une configuration. Elle dit + /// seulement comment elle va. + /// + [AllowAnonymous] + [Security.RequireAppKey] + [ProducesResponseType(typeof(DeviceDTO), 200)] + [ProducesResponseType(typeof(string), 403)] + [ProducesResponseType(typeof(string), 404)] + [ProducesResponseType(typeof(string), 500)] + [HttpPut("{id}/heartbeat")] + public ObjectResult Heartbeat(string id, [FromBody] DeviceHeartbeatDTO beat) + { + try + { + Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == id); + + if (device == null) + throw new KeyNotFoundException("Device does not exist"); + + // Une clé ne parle que des appareils de son instance. Sans ce contrôle, + // n'importe quelle app pourrait écrire l'état des casques d'un autre lieu. + if (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId()) + throw new UnauthorizedAccessException("This key does not grant access to this device"); + + var now = DateTime.Now.ToUniversalTime(); + + if (beat?.batteryLevel != null) + { + device.BatteryLevel = beat.batteryLevel; + device.LastBatteryLevel = now; + } + + if (!string.IsNullOrEmpty(beat?.appVersion)) + device.AppVersion = beat.appVersion; + + if (beat?.connectionLevel != null) + { + device.ConnectionLevel = beat.connectionLevel; + device.LastConnectionLevel = now; + } + + // Recevoir un battement **est** la preuve que l'appareil est en ligne : + // on ne demande pas à l'app de nous dire qu'elle est connectée. + device.Connected = true; + device.LastSeen = now; + device.DateUpdate = now; + + _myInfoMateDbContext.SaveChanges(); + + return new OkObjectResult(device.ToDTO()); + } + catch (UnauthorizedAccessException ex) + { + return new ObjectResult(ex.Message) { StatusCode = 403 }; + } + catch (KeyNotFoundException ex) + { + return new NotFoundObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + /// /// Update device main info /// - /// Device to update + /// Device to update [ProducesResponseType(typeof(DeviceDTO), 200)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] diff --git a/ManagerService/Controllers/InstanceController.cs b/ManagerService/Controllers/InstanceController.cs index edf2654..49f94e1 100644 --- a/ManagerService/Controllers/InstanceController.cs +++ b/ManagerService/Controllers/InstanceController.cs @@ -161,6 +161,36 @@ namespace ManagerService.Controllers instance.HasAdvancedStats = plan.HasAdvancedStats; } + /// + /// Enveloppe de stockage de l'add-on « Contenu immersif ». + /// + /// C'est le point de marge du §6 du plan VR : une vidéo 360 de 5 minutes pèse des + /// gigaoctets, et un client Pro a un quota dimensionné pour du Pro. Sans enveloppe + /// propre, le premier client immersif sur un petit palier mange la marge de l'add-on. + /// + /// ⚠️ Elle s'ajoute au quota du plan, elle ne le remplace pas — et elle se retire à + /// la désactivation, sinon désactiver puis réactiver empilerait les enveloppes. + /// + private const long ImmersiveStorageBytes = 100L * 1024 * 1024 * 1024; + + private void ApplyImmersiveStorage(Instance instance, bool hadImmersive, bool planQuotasReapplied) + { + // Le changement de plan vient d'écraser le quota par celui du plan : l'enveloppe + // doit être reposée par-dessus, même si l'add-on n'a pas bougé. + if (planQuotasReapplied) + { + if (instance.HasImmersiveContent) + instance.StorageQuotaBytes += ImmersiveStorageBytes; + return; + } + + if (!hadImmersive && instance.HasImmersiveContent) + instance.StorageQuotaBytes += ImmersiveStorageBytes; + else if (hadImmersive && !instance.HasImmersiveContent) + instance.StorageQuotaBytes = + Math.Max(0, instance.StorageQuotaBytes - ImmersiveStorageBytes); + } + /// /// Create an instance /// @@ -252,6 +282,9 @@ namespace ManagerService.Controllers instance.IsVR = updatedInstance.isVR ?? instance.IsVR; instance.IsAssistant = updatedInstance.isAssistant ?? instance.IsAssistant; + var hadImmersive = instance.HasImmersiveContent; + instance.HasImmersiveContent = updatedInstance.hasImmersiveContent ?? instance.HasImmersiveContent; + if (updatedInstance.guideName != null) instance.GuideName = updatedInstance.guideName; if (updatedInstance.guidePersonaPrompt != null) @@ -276,9 +309,14 @@ namespace ManagerService.Controllers // L'endpoint /quota masquait la moitié du problème en retombant sur le plan à la // lecture, mais AiController lit `instance.AiTokensPerMonth` — un client passé à // un plan payant restait à 0, donc sans IA. - if (instance.SubscriptionPlanId != previousPlanId) + var planQuotasReapplied = instance.SubscriptionPlanId != previousPlanId; + if (planQuotasReapplied) ApplyPlanQuotas(instance); + // Après ApplyPlanQuotas, jamais avant : celui-ci réécrit le quota depuis le + // plan, et reposerait donc l'enveloppe immersive à zéro en silence. + ApplyImmersiveStorage(instance, hadImmersive, planQuotasReapplied); + //OldInstance instanceModified = _instanceService.Update(updatedInstance.Id, instance); _myInfoMateDbContext.SaveChanges(); @@ -328,7 +366,22 @@ namespace ManagerService.Controllers var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList(); - return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList())); + var dto = instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()); + + // Cette route est anonyme **et** le slug est public : c'est l'URL du site + // visiteur (app.myinfomate.be/{slug}). Sans ce filtrage elle rendait le + // pinCode — qui ouvre l'appairage des tablettes et des casques — ainsi que + // l'adresse de facturation, le numéro de TVA et les quotas du client, à + // qui lit une URL. Le filtrage existait déjà pour GetDetail ; il manquait ici. + var isTrialActive = dto.isTrialActive; + StripCommercialFields(dto); + + // Seule exception : le filigrane d'essai de visitapp-web + // (`[slug]/layout.tsx:41`) s'affiche au visiteur, donc ce n'est pas un + // secret — et sans ce drapeau il disparaîtrait. + dto.isTrialActive = isTrialActive; + + return new OkObjectResult(dto); } catch (KeyNotFoundException ex) { @@ -343,7 +396,7 @@ namespace ManagerService.Controllers /// /// Get Instance by pincode /// - /// Code pin + /// Code pin [AllowAnonymous] [ProducesResponseType(typeof(InstanceDTO), 200)] [ProducesResponseType(typeof(string), 404)] @@ -361,7 +414,18 @@ namespace ManagerService.Controllers var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList(); - return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList())); + var dto = instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()); + + // Anonyme aussi, mais il faut connaître le pinCode pour arriver ici : le + // rendre n'apprend donc rien à l'appelant. Le reste — facturation, TVA, + // quotas, plan — n'a jamais concerné une app visiteur. + var knownPinCode = dto.pinCode; + var isTrialActive = dto.isTrialActive; + StripCommercialFields(dto); + dto.pinCode = knownPinCode; + dto.isTrialActive = isTrialActive; + + return new OkObjectResult(dto); } catch (KeyNotFoundException ex) { diff --git a/ManagerService/Controllers/ResourceController.cs b/ManagerService/Controllers/ResourceController.cs index 99782f0..6b54197 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; @@ -13,6 +13,7 @@ using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; +using ManagerService.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -100,6 +101,7 @@ namespace ManagerService.Controllers /// /// id resource [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(ResourceDTO), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -161,6 +163,7 @@ namespace ManagerService.Controllers /// /// id resource [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(FileResult), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] diff --git a/ManagerService/Controllers/SectionAgendaController.cs b/ManagerService/Controllers/SectionAgendaController.cs index 7ce643b..a3819ba 100644 --- a/ManagerService/Controllers/SectionAgendaController.cs +++ b/ManagerService/Controllers/SectionAgendaController.cs @@ -2,6 +2,7 @@ using ManagerService.Data; using ManagerService.Data.SubSection; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; +using ManagerService.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -38,6 +39,7 @@ namespace ManagerService.Controllers /// /// Section id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -80,6 +82,7 @@ namespace ManagerService.Controllers /// /// Section id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] diff --git a/ManagerService/Controllers/SectionController.cs b/ManagerService/Controllers/SectionController.cs index d21f734..84eb872 100644 --- a/ManagerService/Controllers/SectionController.cs +++ b/ManagerService/Controllers/SectionController.cs @@ -9,6 +9,7 @@ using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; +using ManagerService.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -134,6 +135,7 @@ namespace ManagerService.Controllers /// /// configuration id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 500)] [ProducesResponseType(typeof(string), 400)] @@ -172,6 +174,7 @@ namespace ManagerService.Controllers /// /// configuration id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 500)] [ProducesResponseType(typeof(string), 400)] @@ -453,6 +456,7 @@ namespace ManagerService.Controllers /// /// section id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -485,6 +489,7 @@ namespace ManagerService.Controllers /// /// Instance id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] diff --git a/ManagerService/Controllers/SectionEventController.cs b/ManagerService/Controllers/SectionEventController.cs index 8fc1434..505f62a 100644 --- a/ManagerService/Controllers/SectionEventController.cs +++ b/ManagerService/Controllers/SectionEventController.cs @@ -4,6 +4,7 @@ using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; +using ManagerService.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -39,6 +40,7 @@ namespace ManagerService.Controllers /// /// Section id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -210,6 +212,7 @@ namespace ManagerService.Controllers /// /// Program block id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -387,6 +390,7 @@ namespace ManagerService.Controllers /// /// Section event id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] diff --git a/ManagerService/Controllers/SectionMapController.cs b/ManagerService/Controllers/SectionMapController.cs index 1c2c76d..7d725a2 100644 --- a/ManagerService/Controllers/SectionMapController.cs +++ b/ManagerService/Controllers/SectionMapController.cs @@ -5,6 +5,7 @@ using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; +using ManagerService.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -39,6 +40,7 @@ namespace ManagerService.Controllers /// /// Section id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -330,6 +332,7 @@ namespace ManagerService.Controllers /// /// Section id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -541,6 +544,7 @@ namespace ManagerService.Controllers /// /// Guided path id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] diff --git a/ManagerService/Controllers/SectionParcoursController.cs b/ManagerService/Controllers/SectionParcoursController.cs index 808d051..fa0f4a8 100644 --- a/ManagerService/Controllers/SectionParcoursController.cs +++ b/ManagerService/Controllers/SectionParcoursController.cs @@ -5,6 +5,7 @@ using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; +using ManagerService.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -37,6 +38,7 @@ namespace ManagerService.Controllers /// Get all guided paths from a parcours section /// [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -235,6 +237,7 @@ namespace ManagerService.Controllers /// Get all steps from a guided path /// [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] diff --git a/ManagerService/Controllers/SectionQuizController.cs b/ManagerService/Controllers/SectionQuizController.cs index b2d29f1..93d3fc3 100644 --- a/ManagerService/Controllers/SectionQuizController.cs +++ b/ManagerService/Controllers/SectionQuizController.cs @@ -5,6 +5,7 @@ using ManagerService.Data.SubSection; using ManagerService.DTOs; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; +using ManagerService.Security; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -44,6 +45,7 @@ namespace ManagerService.Controllers /// /// Section id [AllowAnonymous] + [RequireAppKey] [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] @@ -173,15 +175,15 @@ namespace ManagerService.Controllers var questions = existingSection.QuizQuestions.OrderBy(q => q.Order).ToList(); - // Retirer la question dplace + // Retirer la question d�plac�e questions.RemoveAll(q => q.Id == existingQuestion.Id); - // Insrer la nouvelle position (dj en 0-based) + // Ins�rer � la nouvelle position (d�j� en 0-based) int newIndex = questionDTO.order.Value; newIndex = Math.Clamp(newIndex, 0, questions.Count); questions.Insert(newIndex, existingQuestion); - // Rassigner les ordres en 0-based + // R�assigner les ordres en 0-based for (int i = 0; i < questions.Count; i++) { questions[i].Order = i; diff --git a/ManagerService/Controllers/SectionScene3DController.cs b/ManagerService/Controllers/SectionScene3DController.cs new file mode 100644 index 0000000..1536991 --- /dev/null +++ b/ManagerService/Controllers/SectionScene3DController.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Manager.DTOs; +using ManagerService.Data; +using ManagerService.Data.SubSection; +using ManagerService.Security; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using NSwag.Annotations; + +namespace ManagerService.Controllers +{ + /// + /// Les points d'intérêt d'une maquette 3D — item E7 du lot XR-4. + /// + /// Pourquoi un contrôleur à part. Le CRUD des points existe déjà, mais il est + /// écrit sur SectionMap : OfType<SectionMap>(), MapPoints. + /// Une maquette a les mêmes points — c'est le même GeoPoint — sur une autre + /// collection. Élargir SectionMapController aurait mélangé deux types de + /// section dans chaque méthode ; ce contrôleur-ci ne parle que de maquettes. + /// + /// La différence de fond tient en un champ : ici un point porte une + /// LocalTransform (x, y, z dans le repère du modèle) là où une carte porte + /// une géométrie PostGIS. Les deux coexistent sur l'entité, et rien n'oblige un point + /// à n'en avoir qu'une. + /// + [Authorize(Policy = ManagerService.Service.Security.Policies.ContentEditor)] + [ApiController, Route("api/[controller]")] + [OpenApiTag("SectionScene3D", Description = "3D model section points of interest")] + public class SectionScene3DController : ControllerBase + { + private readonly ILogger _logger; + private readonly MyInfoMateDbContext _myInfoMateDbContext; + + public SectionScene3DController(ILogger logger, + MyInfoMateDbContext myInfoMateDbContext) + { + _logger = logger; + _myInfoMateDbContext = myInfoMateDbContext; + } + + /// Get all points of interest of a 3D model section + /// Section id + [AllowAnonymous] + [RequireAppKey] + [ProducesResponseType(typeof(List), 200)] + [ProducesResponseType(typeof(string), 404)] + [ProducesResponseType(typeof(string), 500)] + [HttpGet("{sectionId}/points")] + public ObjectResult GetAllPointsFromSection(string sectionId) + { + try + { + var section = _myInfoMateDbContext.Sections + .OfType() + .Include(s => s.Points) + .FirstOrDefault(s => s.Id == sectionId); + + if (section == null) + throw new KeyNotFoundException("3D model section does not exist"); + + return new OkObjectResult( + (section.Points ?? new List()).Select(p => p.ToDTO()).ToList()); + } + catch (KeyNotFoundException ex) + { + return new NotFoundObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + + /// Add a point of interest to a 3D model section + /// Section id + /// Point to create + [ProducesResponseType(typeof(GeoPointDTO), 200)] + [ProducesResponseType(typeof(string), 400)] + [ProducesResponseType(typeof(string), 404)] + [ProducesResponseType(typeof(string), 500)] + [HttpPost("{sectionId}/points")] + public ObjectResult CreatePoint(string sectionId, [FromBody] GeoPointDTO geoPointDTO) + { + try + { + if (geoPointDTO == null) + throw new ArgumentNullException("GeoPoint is null"); + + var section = _myInfoMateDbContext.Sections + .OfType() + .Include(s => s.Points) + .FirstOrDefault(s => s.Id == sectionId); + + if (section == null) + throw new KeyNotFoundException("3D model section does not exist"); + + var point = new GeoPoint + { + Title = geoPointDTO.title, + Description = geoPointDTO.description, + Contents = geoPointDTO.contents, + ImageResourceId = geoPointDTO.imageResourceId, + ImageUrl = geoPointDTO.imageUrl, + Schedules = geoPointDTO.schedules, + Prices = geoPointDTO.prices, + Phone = geoPointDTO.phone, + Email = geoPointDTO.email, + Site = geoPointDTO.site, + + // Un point neuf n'a pas encore été posé sur la maquette : il arrive à + // l'origine du modèle, là où l'éditeur le montrera pour qu'on le place. + LocalTransform = geoPointDTO.localTransform ?? new Position3D() + }; + + section.Points ??= new List(); + section.Points.Add(point); + + _myInfoMateDbContext.SaveChanges(); + + return new OkObjectResult(point.ToDTO()); + } + catch (ArgumentNullException ex) + { + return new BadRequestObjectResult(ex.Message) { }; + } + catch (KeyNotFoundException ex) + { + return new NotFoundObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + + /// Update a point of interest, position included + /// Point to update + [ProducesResponseType(typeof(GeoPointDTO), 200)] + [ProducesResponseType(typeof(string), 400)] + [ProducesResponseType(typeof(string), 404)] + [ProducesResponseType(typeof(string), 500)] + [HttpPut("points")] + public ObjectResult UpdatePoint([FromBody] GeoPointDTO geoPointDTO) + { + try + { + if (geoPointDTO == null) + throw new ArgumentNullException("GeoPoint param is null"); + + var point = _myInfoMateDbContext.GeoPoints + .FirstOrDefault(p => p.Id == geoPointDTO.id); + + if (point == null) + throw new KeyNotFoundException("GeoPoint does not exist"); + + point.Title = geoPointDTO.title ?? point.Title; + point.Description = geoPointDTO.description ?? point.Description; + point.Contents = geoPointDTO.contents ?? point.Contents; + point.ImageResourceId = geoPointDTO.imageResourceId ?? point.ImageResourceId; + point.ImageUrl = geoPointDTO.imageUrl ?? point.ImageUrl; + + // C'est le champ que l'éditeur 3D renvoie à chaque déplacement, et la + // seule raison pour laquelle cette route est appelée souvent. + if (geoPointDTO.localTransform != null) + point.LocalTransform = geoPointDTO.localTransform; + + _myInfoMateDbContext.SaveChanges(); + + return new OkObjectResult(point.ToDTO()); + } + catch (ArgumentNullException ex) + { + return new BadRequestObjectResult(ex.Message) { }; + } + catch (KeyNotFoundException ex) + { + return new NotFoundObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + + /// Delete a point of interest + /// Point id + [ProducesResponseType(typeof(string), 200)] + [ProducesResponseType(typeof(string), 404)] + [ProducesResponseType(typeof(string), 500)] + [HttpDelete("points/{id}")] + public ObjectResult DeletePoint(int id) + { + try + { + var point = _myInfoMateDbContext.GeoPoints.FirstOrDefault(p => p.Id == id); + + if (point == null) + throw new KeyNotFoundException("GeoPoint does not exist"); + + _myInfoMateDbContext.GeoPoints.Remove(point); + _myInfoMateDbContext.SaveChanges(); + + return new OkObjectResult("Point deleted"); + } + catch (KeyNotFoundException ex) + { + return new NotFoundObjectResult(ex.Message) { }; + } + catch (Exception ex) + { + return new ObjectResult(ex.Message) { StatusCode = 500 }; + } + } + } +} diff --git a/ManagerService/DTOs/ApplicationInstanceDTO.cs b/ManagerService/DTOs/ApplicationInstanceDTO.cs index e516f5d..a693e62 100644 --- a/ManagerService/DTOs/ApplicationInstanceDTO.cs +++ b/ManagerService/DTOs/ApplicationInstanceDTO.cs @@ -49,5 +49,8 @@ namespace ManagerService.DTOs public string? appStoreUrl { get; set; } public string? playStoreUrl { get; set; } + + /// Fond du menu général, spécifique VR. + public ImmersiveBackgroundDTO? immersiveBackground { get; set; } } } diff --git a/ManagerService/DTOs/ConfigurationDTO.cs b/ManagerService/DTOs/ConfigurationDTO.cs index d2b2e7b..276c418 100644 --- a/ManagerService/DTOs/ConfigurationDTO.cs +++ b/ManagerService/DTOs/ConfigurationDTO.cs @@ -19,5 +19,8 @@ namespace ManagerService.DTOs public List sectionIds { get; set; } public string loaderImageId { get; set; } // == ResourceId public string loaderImageUrl { get; set; } // == Image url + + /// Fond immersif de la visite, null si elle n'en a pas. + public ImmersiveBackgroundDTO immersiveBackground { get; set; } } } diff --git a/ManagerService/DTOs/DeviceDTO.cs b/ManagerService/DTOs/DeviceDTO.cs index 475e34e..66fb27a 100644 --- a/ManagerService/DTOs/DeviceDTO.cs +++ b/ManagerService/DTOs/DeviceDTO.cs @@ -15,6 +15,9 @@ namespace ManagerService.DTOs public DateTime? dateCreation{ get; set; } public DateTime? dateUpdate { get; set; } public string instanceId { get; set; } + + /// Canal de l'appareil. Absent de la requête = tablette, pour ne pas casser tablet-app. + public ManagerService.Data.AppType appType { get; set; } = ManagerService.Data.AppType.Tablet; } public class DeviceDetailDTO : DeviceDTO @@ -23,5 +26,9 @@ namespace ManagerService.DTOs public DateTime lastConnectionLevel { get; set; } public string batteryLevel { get; set; } public DateTime lastBatteryLevel { get; set; } + + // Colonnes déjà en base, jamais exposées : la carte casque les affiche. + public string? appVersion { get; set; } + public DateTime? lastSeen { get; set; } } } diff --git a/ManagerService/DTOs/DeviceHeartbeatDTO.cs b/ManagerService/DTOs/DeviceHeartbeatDTO.cs new file mode 100644 index 0000000..524d8a3 --- /dev/null +++ b/ManagerService/DTOs/DeviceHeartbeatDTO.cs @@ -0,0 +1,22 @@ +namespace ManagerService.DTOs +{ + /// + /// Ce qu'une app en fonctionnement dit d'elle-même — lot XR-5. + /// + /// Trois champs, et rien d'autre : ni nom, ni instance, ni configuration. Un appareil + /// en salle ne doit pas pouvoir se renommer ou changer de contenu tout seul ; le + /// serveur pose lui-même Connected et LastSeen, puisque recevoir le + /// battement est la preuve. + /// + public class DeviceHeartbeatDTO + { + /// Niveau de batterie, tel que l'app le lit (« 87 »). + public string batteryLevel { get; set; } + + /// Version de l'app installée sur l'appareil. + public string appVersion { get; set; } + + /// Qualité de connexion, quand l'app sait la mesurer. + public string connectionLevel { get; set; } + } +} diff --git a/ManagerService/DTOs/ExportConfigurationDTO.cs b/ManagerService/DTOs/ExportConfigurationDTO.cs index 29cb62f..c39096e 100644 --- a/ManagerService/DTOs/ExportConfigurationDTO.cs +++ b/ManagerService/DTOs/ExportConfigurationDTO.cs @@ -1,11 +1,47 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; namespace ManagerService.DTOs { + /// + /// Le contenu complet d'une configuration, en un seul appel — le contrat public + /// entre le back-office et tout ce qui affiche une visite. + /// + /// Trois consommateurs, et c'est ce qui en fait un contrat plutôt qu'un DTO interne : + /// la visite hors ligne de mymuseum-visitapp, l'import/export du back-office, + /// et l'app Unity du canal VR — qui n'a pas de client généré et lit ce JSON tel quel. + /// + /// Règles de compatibilité, à tenir tant que le numéro ne change pas : + /// + /// on ajoute des champs, on n'en retire ni n'en renomme aucun ; + /// les valeurs d'enum s'ajoutent en fin — elles sont persistées en int + /// et un client plus ancien doit pouvoir ignorer ce qu'il ne connaît pas ; + /// un champ absent vaut « pas de valeur », jamais « valeur par défaut » + /// différente de celle du client. + /// + /// + /// Documentation complète : DOCS/export-contract.md. + /// public class ExportConfigurationDTO : ConfigurationDTO { + /// + /// Version du format, pas du contenu. Un client qui lit un numéro plus + /// grand que celui qu'il connaît doit le dire à l'utilisateur plutôt que de + /// deviner — c'est exactement ce que fait ManifestReader côté casque. + /// + public int exportVersion { get; set; } = CurrentExportVersion; + + /// Quand ce JSON a été produit. Sert au diagnostic d'un cache périmé. + public DateTime generatedAt { get; set; } + public List sections { get; set; } public List resources { get; set; } - } -} \ No newline at end of file + /// + /// 1 — version initiale, figée le 2026-09-12. L'export existait avant, sans + /// numéro : un client qui n'en trouve pas lit un export antérieur à cette date, et + /// doit le traiter comme la version 1. + /// + public const int CurrentExportVersion = 1; + } +} diff --git a/ManagerService/DTOs/ImmersiveBackgroundDTO.cs b/ManagerService/DTOs/ImmersiveBackgroundDTO.cs new file mode 100644 index 0000000..d41f4a3 --- /dev/null +++ b/ManagerService/DTOs/ImmersiveBackgroundDTO.cs @@ -0,0 +1,27 @@ +using ManagerService.Data; + +namespace ManagerService.DTOs +{ + /// + /// Le fond immersif d'une visite ou du menu VR. Voir + /// pour ce que porte chaque champ et pourquoi le repli n'est pas optionnel. + /// + public class ImmersiveBackgroundDTO + { + public string resourceId { get; set; } + + public ImmersiveBackgroundKind kind { get; set; } + + public string fallbackResourceId { get; set; } + + /// + /// URL de la ressource, remplie à la lecture comme imageSource. Le casque + /// lit l'export sans client généré : lui demander de résoudre un id de plus + /// serait un appel de plus au démarrage, quand il n'a peut-être pas de réseau. + /// + public string resourceUrl { get; set; } + + /// URL du repli, remplie de la même façon. + public string fallbackUrl { get; set; } + } +} diff --git a/ManagerService/DTOs/InstanceDTO.cs b/ManagerService/DTOs/InstanceDTO.cs index d034af4..c82d3aa 100644 --- a/ManagerService/DTOs/InstanceDTO.cs +++ b/ManagerService/DTOs/InstanceDTO.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; namespace ManagerService.DTOs @@ -16,6 +16,9 @@ namespace ManagerService.DTOs public bool? isVR { get; set; } public bool? isAssistant { get; set; } + + /// Add-on « Contenu immersif » : 360 et modeles 3D. + public bool? hasImmersiveContent { get; set; } public bool? isImageWatermark { get; set; } public string? guideName { get; set; } diff --git a/ManagerService/DTOs/SectionType.cs b/ManagerService/DTOs/SectionType.cs index 6ad6770..6dfadec 100644 --- a/ManagerService/DTOs/SectionType.cs +++ b/ManagerService/DTOs/SectionType.cs @@ -14,6 +14,13 @@ Agenda, Weather, Event, - Parcours + Parcours, + + /// + /// Scène 3D : un modèle glTF et ses points d'intérêt, en objet manipulé ou en + /// décor habité. ⚠️ Persisté en int comme le reste : ajouté en fin, + /// jamais au milieu. + /// + Scene3D } } diff --git a/ManagerService/DTOs/SubSection/MapDTO.cs b/ManagerService/DTOs/SubSection/MapDTO.cs index 2bce516..41f5387 100644 --- a/ManagerService/DTOs/SubSection/MapDTO.cs +++ b/ManagerService/DTOs/SubSection/MapDTO.cs @@ -1,4 +1,4 @@ -using ManagerService.DTOs; +using ManagerService.DTOs; using System.Collections.Generic; namespace Manager.DTOs @@ -38,6 +38,10 @@ namespace Manager.DTOs public string polyColor { get; set; } // color of the polyline or polygon public string sectionMapId { get; set; } public string sectionEventId { get; set; } + public string sectionScene3DId { get; set; } + + /// Position sur une maquette 3D, nulle sur un point de carte. + public ManagerService.Data.SubSection.Position3D localTransform { get; set; } } public class CategorieDTO diff --git a/ManagerService/DTOs/SubSection/Scene3DDTO.cs b/ManagerService/DTOs/SubSection/Scene3DDTO.cs new file mode 100644 index 0000000..e22ef95 --- /dev/null +++ b/ManagerService/DTOs/SubSection/Scene3DDTO.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using ManagerService.DTOs; + +namespace Manager.DTOs +{ + /// + /// Une maquette 3D et ses points d'intérêt — item E7 du lot XR-4. + /// + /// Le modèle arrive par un id de ressource (ResourceType.Model3D), comme une + /// image ou une vidéo : rien de particulier à prévoir dans la médiathèque, et le + /// téléchargement hors ligne l'emporte avec le reste. + /// + public class Scene3DDTO : SectionDTO + { + public string model3DResourceId { get; set; } + + /// URL du modèle, remplie à la lecture comme imageSource. + public string model3DSource { get; set; } + + /// Objet manipulé (0) ou décor habité (1). + public ManagerService.Data.SubSection.Scene3DMode mode { get; set; } + + public List points { get; set; } + } +} diff --git a/ManagerService/Data/ApiKeyAppType.cs b/ManagerService/Data/ApiKeyAppType.cs index 460badb..7223804 100644 --- a/ManagerService/Data/ApiKeyAppType.cs +++ b/ManagerService/Data/ApiKeyAppType.cs @@ -1,4 +1,6 @@ namespace ManagerService.Data { - public enum ApiKeyAppType { VisitApp, TabletApp, Other } + // ⚠️ Persisté en int. Ne JAMAIS réordonner ni insérer au milieu : + // toute nouvelle valeur s'ajoute à la fin. + public enum ApiKeyAppType { VisitApp, TabletApp, Other, VrApp } } diff --git a/ManagerService/Data/ApplicationInstance.cs b/ManagerService/Data/ApplicationInstance.cs index e9afc93..c5b3854 100644 --- a/ManagerService/Data/ApplicationInstance.cs +++ b/ManagerService/Data/ApplicationInstance.cs @@ -63,6 +63,12 @@ namespace ManagerService.Data public string? PlayStoreUrl { get; set; } // Specific Mobile + /// + /// Fond du menu général — spécifique VR. C'est le pendant immersif de + /// MainImageId : sans lui, le menu du casque flotte dans le noir. + /// + public ImmersiveBackground? ImmersiveBackground { get; set; } + public ApplicationInstanceDTO ToDTO(MyInfoMateDbContext myInfoMateDbContext) { SectionEventDTO sectionEventDTO = null; @@ -92,7 +98,8 @@ namespace ManagerService.Data isQRCodeEnabled = IsQRCodeEnabled, appName = AppName, appStoreUrl = AppStoreUrl, - playStoreUrl = PlayStoreUrl + playStoreUrl = PlayStoreUrl, + immersiveBackground = ImmersiveBackground?.ToDTO() }; } @@ -114,6 +121,7 @@ namespace ManagerService.Data AppName = dto.appName; AppStoreUrl = dto.appStoreUrl; PlayStoreUrl = dto.playStoreUrl; + ImmersiveBackground = Data.ImmersiveBackground.FromDTO(dto.immersiveBackground); return this; } diff --git a/ManagerService/Data/Configuration.cs b/ManagerService/Data/Configuration.cs index 009fbbc..b20adc5 100644 --- a/ManagerService/Data/Configuration.cs +++ b/ManagerService/Data/Configuration.cs @@ -55,6 +55,12 @@ namespace ManagerService.Data public bool IsSearchNumber { get; set; } // True if we want to have search box (number type), false otherwise + /// + /// Le fond de cette visite, null si elle n'en a pas. Voir + /// — le même type sert au menu VR. + /// + public ImmersiveBackground ImmersiveBackground { get; set; } + public ConfigurationDTO ToDTO(List sectionIds) { return new ConfigurationDTO() @@ -72,6 +78,7 @@ namespace ManagerService.Data languages = Languages, secondaryColor = SecondaryColor, isOffline = IsOffline, + immersiveBackground = ImmersiveBackground?.ToDTO(), sectionIds = sectionIds }; } @@ -79,6 +86,8 @@ namespace ManagerService.Data public ExportConfigurationDTO ToExportDTO(List sections, List resources) { return new ExportConfigurationDTO() { + exportVersion = ExportConfigurationDTO.CurrentExportVersion, + generatedAt = DateTime.UtcNow, id = Id, instanceId = InstanceId, label = Label, @@ -92,6 +101,7 @@ namespace ManagerService.Data languages = Languages, secondaryColor = SecondaryColor, isOffline = IsOffline, + immersiveBackground = ImmersiveBackground?.ToDTO(), sections = sections, resources = resources, sectionIds = sections.Select(s => s.id).ToList() diff --git a/ManagerService/Data/Device.cs b/ManagerService/Data/Device.cs index fbac136..53007b2 100644 --- a/ManagerService/Data/Device.cs +++ b/ManagerService/Data/Device.cs @@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore; namespace ManagerService.Data { /// - /// Device Information (Tablet) + /// Device Information (tablette kiosk, casque VR, ...) /// [Index(nameof(InstanceId))] public class Device : IAuditableEntity @@ -77,6 +77,12 @@ namespace ManagerService.Data public DateTime? LastSeen { get; set; } + /// + /// Canal de l'appareil. Défaut : jusqu'ici la table + /// n'était peuplée que par tablet-app, les lignes existantes ne doivent pas bouger. + /// + public AppType AppType { get; set; } = AppType.Tablet; + public DeviceDTO ToDTO() { return new DeviceDTO() @@ -91,7 +97,8 @@ namespace ManagerService.Data configurationId = ConfigurationId, dateUpdate = DateUpdate, dateCreation = DateCreation, - instanceId = InstanceId + instanceId = InstanceId, + appType = AppType }; } @@ -113,7 +120,10 @@ namespace ManagerService.Data lastBatteryLevel = LastBatteryLevel, dateUpdate = DateUpdate, dateCreation = DateCreation, - instanceId = InstanceId + instanceId = InstanceId, + appType = AppType, + appVersion = AppVersion, + lastSeen = LastSeen }; } @@ -132,6 +142,7 @@ namespace ManagerService.Data LastBatteryLevel = deviceDetailDTO.lastBatteryLevel; DateUpdate = deviceDetailDTO.dateUpdate != null ? deviceDetailDTO.dateUpdate.Value : DateTime.Now.ToUniversalTime(); InstanceId = deviceDetailDTO.instanceId; + AppType = deviceDetailDTO.appType; return this; } } diff --git a/ManagerService/Data/ImmersiveBackground.cs b/ManagerService/Data/ImmersiveBackground.cs new file mode 100644 index 0000000..7b0e66e --- /dev/null +++ b/ManagerService/Data/ImmersiveBackground.cs @@ -0,0 +1,66 @@ +using ManagerService.DTOs; +using Microsoft.EntityFrameworkCore; + +namespace ManagerService.Data +{ + /// + /// Le fond d'un lieu — §4 de DOCS/v2/immersif-frontiere-plan.md. + /// + /// Un type, deux porteurs. Une Configuration le porte pour le fond d'une + /// visite, l'ApplicationInstance VR pour le fond du menu général. Poser un + /// BackgroundResourceId à plat sur chacun aurait donné quatre champs + /// « background » incohérents dans six mois — c'est le travers que ce type évite. + /// + /// ⚠️ Le repli n'est pas optionnel. Trois canaux sur quatre ne savent pas rendre + /// un panorama : web le fait tourner, mobile et kiosk retombent sur une image plate. + /// Sans FallbackResourceId, activer un fond immersif rendrait l'écran noir + /// partout ailleurs que dans le casque, en silence. À défaut, c'est + /// Configuration.ImageId qui sert de repli. + /// + [Owned] + public class ImmersiveBackground + { + /// Ressource immersive : une 360, une vidéo 360 ou un GLB. + public string ResourceId { get; set; } + + /// + /// ⚠️ Persisté en int, comme tous les enums du projet : on ajoute en fin, + /// on ne réordonne jamais. + /// + public ImmersiveBackgroundKind Kind { get; set; } + + /// Image plate pour les canaux qui ne rendent pas l'immersif. + public string FallbackResourceId { get; set; } + + public ImmersiveBackgroundDTO ToDTO() => new ImmersiveBackgroundDTO + { + resourceId = ResourceId, + kind = Kind, + fallbackResourceId = FallbackResourceId + }; + + /// + /// Rend null quand aucune ressource n'est choisie : un fond sans ressource n'est + /// pas un fond, et le porter quand même obligerait chaque lecteur à tester deux + /// choses au lieu d'une. + /// + public static ImmersiveBackground FromDTO(ImmersiveBackgroundDTO dto) + { + if (dto == null || string.IsNullOrWhiteSpace(dto.resourceId)) return null; + + return new ImmersiveBackground + { + ResourceId = dto.resourceId, + Kind = dto.kind, + FallbackResourceId = dto.fallbackResourceId + }; + } + } + + public enum ImmersiveBackgroundKind + { + Pano = 0, + Video360 = 1, + Scene3D = 2 + } +} diff --git a/ManagerService/Data/Instance.cs b/ManagerService/Data/Instance.cs index a5d58dc..f0ebdaf 100644 --- a/ManagerService/Data/Instance.cs +++ b/ManagerService/Data/Instance.cs @@ -1,4 +1,4 @@ -using ManagerService.DTOs; +using ManagerService.DTOs; using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; @@ -36,6 +36,21 @@ namespace ManagerService.Data public bool IsAssistant { get; set; } + /// + /// Add-on « Contenu immersif » : image 360, vidéo 360 et modèles 3D. + /// + /// Un add-on est un bool sur l'instance — c'est déjà le patron d' + /// , et il tient parce que l'Instance recopie + /// localement les valeurs de son plan au lieu de les lire à travers : le + /// SubscriptionPlan n'est qu'un gabarit. + /// + /// ⚠️ Le stockage est le point de vigilance, pas la facturation : une + /// vidéo 360 de cinq minutes pèse des Go, et un client sur un petit palier ferait + /// sauter la marge. L'activation doit s'accompagner d'un relèvement explicite de + /// (§6 de DOCS/v2/vr-quest-unity-plan.md). + /// + public bool HasImmersiveContent { get; set; } + /// /// Appose le filigrane du lieu sur les images téléversées. Remplace le /// `instanceId == "633ee379…"` en dur de ResourceController.Create. @@ -139,6 +154,7 @@ namespace ManagerService.Data isWeb = IsWeb, isVR = IsVR, isAssistant = IsAssistant, + hasImmersiveContent = HasImmersiveContent, isImageWatermark = IsImageWatermark, guideName = GuideName, guidePersonaPrompt = GuidePersonaPrompt, @@ -178,6 +194,7 @@ namespace ManagerService.Data IsWeb = instanceDTO.isWeb ?? false; IsVR = instanceDTO.isVR ?? false; IsAssistant = instanceDTO.isAssistant ?? false; + HasImmersiveContent = instanceDTO.hasImmersiveContent ?? false; IsImageWatermark = instanceDTO.isImageWatermark ?? false; if (instanceDTO.guideName != null) GuideName = instanceDTO.guideName; diff --git a/ManagerService/Data/MyInfoMateDbContext.cs b/ManagerService/Data/MyInfoMateDbContext.cs index 4e508b1..c3c0894 100644 --- a/ManagerService/Data/MyInfoMateDbContext.cs +++ b/ManagerService/Data/MyInfoMateDbContext.cs @@ -1,4 +1,4 @@ -using Manager.DTOs; +using Manager.DTOs; using ManagerService.Data.SubSection; using ManagerService.DTOs; using Microsoft.AspNetCore.Http; @@ -352,7 +352,8 @@ namespace ManagerService.Data .HasValue("Video") .HasValue("Weather") .HasValue("Web") - .HasValue("Parcours"); + .HasValue("Parcours") + .HasValue("Model3D"); /*modelBuilder.Entity(entity => { @@ -544,6 +545,24 @@ namespace ManagerService.Data .IsRequired(false) .OnDelete(DeleteBehavior.SetNull); + // SectionScene3D : ses points sont des GeoPoint, comme ceux d'une Map. Le + // troisième rattachement d'un GeoPoint, après SectionMap et SectionEvent. + modelBuilder.Entity() + .HasOne(p => p.SectionScene3D) + .WithMany(s => s.Points) + .HasForeignKey(p => p.SectionScene3DId) + .IsRequired(false) + .OnDelete(DeleteBehavior.Cascade); + + // La position 3D est un petit objet, stocké tel quel : rien ne la requête, et + // trois colonnes de plus sur GeoPoint ne serviraient qu'aux maquettes. + modelBuilder.Entity() + .Property(p => p.LocalTransform) + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v, options), + v => JsonSerializer.Deserialize(v, options)); + // MapAnnotation: global event-level annotations linked directly to SectionEvent modelBuilder.Entity() .HasOne() @@ -564,6 +583,8 @@ namespace ManagerService.Data .Property(i => i.StatsHistoryDays).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.HasAdvancedStats).ValueGeneratedNever(); + modelBuilder.Entity() + .Property(i => i.HasImmersiveContent).ValueGeneratedNever(); modelBuilder.Entity() .Property(i => i.IsActive).ValueGeneratedNever(); modelBuilder.Entity() diff --git a/ManagerService/Data/Resource.cs b/ManagerService/Data/Resource.cs index 73eae4b..5ccfec6 100644 --- a/ManagerService/Data/Resource.cs +++ b/ManagerService/Data/Resource.cs @@ -106,7 +106,22 @@ namespace ManagerService.Data JSONUrl, // 7 Word, // 8 PowerPoint, // 9 - Text // 10 + Text, // 10 + + /// Photo équirectangulaire, affichée en skybox dans un casque. + Image360, // 11 + + /// Vidéo équirectangulaire, même usage. + Video360, // 12 + + /// + /// Modèle 3D glTF binaire. Posé ici en même temps que les deux précédents parce + /// qu'une valeur d'enum ne coûte rien à ajouter et que la suivante devra encore + /// aller en fin — mais son exploitation demande bien plus : un type de section + /// dédié, une position 3D sur les points d'intérêt et un éditeur de placement + /// (voir le §9 de DOCS/v2/vr-quest-unity-plan.md). + /// + Model3D // 13 } public enum AiIndexStatus diff --git a/ManagerService/Data/SubSection/SectionMap.cs b/ManagerService/Data/SubSection/SectionMap.cs index 73a8395..7e5b3b0 100644 --- a/ManagerService/Data/SubSection/SectionMap.cs +++ b/ManagerService/Data/SubSection/SectionMap.cs @@ -1,4 +1,4 @@ -using Manager.DTOs; +using Manager.DTOs; using ManagerService.DTOs; using ManagerService.Helpers; using NetTopologySuite.Geometries; @@ -139,6 +139,22 @@ namespace ManagerService.Data.SubSection [ForeignKey(nameof(SectionEventId))] public SectionEvent? SectionEvent { get; set; } + public string? SectionScene3DId { get; set; } + + [ForeignKey(nameof(SectionScene3DId))] + public SectionScene3D? SectionScene3D { get; set; } + + /// + /// Position du point sur un modèle 3D, quand il en décore un — à côté du + /// PostGIS, qui reste la position sur une carte. + /// + /// C'est toute l'économie du lot E7 : un point d'intérêt sur une maquette porte + /// déjà titre, description, image, contenus audio et multilingue. Il ne lui + /// manquait qu'un endroit où se tenir. + /// + [Column(TypeName = "jsonb")] + public Position3D? LocalTransform { get; set; } + public string GetEmbeddableText(string language) => JoinText(Translate(Title, language), Translate(Description, language), @@ -171,12 +187,34 @@ namespace ManagerService.Data.SubSection email = Email, site = Site, sectionMapId = SectionMapId, - sectionEventId = SectionEventId + sectionEventId = SectionEventId, + sectionScene3DId = SectionScene3DId, + localTransform = LocalTransform }; } } + /// + /// Une position dans un modèle 3D, dans le repère du modèle lui-même — pas dans celui + /// du monde. Un point posé sur une maquette doit rester au même endroit de la maquette + /// quand on la déplace, l'oriente ou la change d'échelle. + /// + /// ⚠️ Convention glTF, celle du fichier, pas celle d'Unity : Y vers le haut, + /// Z vers l'arrière, main droite. La conversion vit dans un seul endroit côté + /// casque (GltfSpace), et c'est exprès — un miroir d'axes est invisible sur une + /// maquette symétrique et se paie très cher découvert tard. + /// + public class Position3D + { + public float x { get; set; } + public float y { get; set; } + public float z { get; set; } + + /// Rotation en degrés autour de Y. Sert à orienter un panneau vers l'allée. + public float? rotationY { get; set; } + } + // SectionMap "normal" comme avant => Via geopoints. Si il y a des guidedPath lié à la sectionMap alors il y a un parcours lié à la sectionMap, tu peux aussi avoir des geopoints classique mis là + des parcours. et aussi lier un guidedstep à un geopoint (pour le trigger et aussi pour "afficher plus") } diff --git a/ManagerService/Data/SubSection/SectionScene3D.cs b/ManagerService/Data/SubSection/SectionScene3D.cs new file mode 100644 index 0000000..f48f1cf --- /dev/null +++ b/ManagerService/Data/SubSection/SectionScene3D.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using System.Linq; +using Manager.DTOs; +using ManagerService.DTOs; +using static ManagerService.Data.SectionText; + +namespace ManagerService.Data.SubSection +{ + /// Objet manipulé ou décor habité — voir . + public enum Scene3DMode + { + // ⚠️ Persisté en int : toute valeur nouvelle s'ajoute à la fin. + Asset = 0, + Scene = 1 + } + + /// + /// Une scène 3D : un modèle glTF et ses points d'intérêt — lot XR-4, + /// items E5 (le décor) et E7 (l'objet). + /// + /// Nommé « Scene3D » et pas « Model3D » alors qu'il ne porte aujourd'hui qu'un + /// modèle et des points : le discriminateur TPH est une chaîne persistée en base. + /// Le renommer tant que rien n'est en production coûte zéro ; le renommer après la + /// première maquette livrée coûte une migration de données. Les objets posés, les + /// personnages et la navigation prévus au §S4 de la conception viendront ici, en + /// colonnes nullables — pas dans un second type à faire cohabiter. + /// + /// Pourquoi un type à part et pas une augmentée : une + /// maquette n'est pas une carte. Les deux se ressemblent parce qu'elles portent des + /// points d'intérêt, mais une carte a un fond cartographique, un zoom, un fournisseur + /// et des coordonnées terrestres — dont une maquette n'a que faire. Tordre la Map + /// aurait donné un type qui ment sur la moitié de ses champs. + /// + /// Ce qu'on réutilise en revanche : le . Il portait déjà + /// deux rattachements (SectionMapId, SectionEventId) ; un troisième suit + /// le même patron. Un point garde ainsi son titre, sa description, son image, ses + /// contenus audio et son multilingue — et l'éditeur de points du back-office avec. + /// Seule sa change de nature. + /// + public class SectionScene3D : Section + { + /// Ressource Model3D (glTF binaire) affichée par cette section. + public string Model3DResourceId { get; set; } + + /// + /// Ce que le visiteur fait de cette scène — et c'est une opposition franche + /// (§4bis de DOCS/v2/immersif-frontiere-plan.md, arrêté le 11/09) : + /// + /// + /// Asset — un objet posé devant lui, qu'il manipule : caméra + /// orbitale, on tourne autour, les points tournent avec l'objet. L'épée du + /// roi, une pièce de collection. + /// Scene — un décor dans lequel il est : caméra fixe, on + /// regarde autour, les points restent où ils sont. Une salle reconstituée. + /// + /// + /// Même GLB, même modèle de point, même éditeur : ce qui change, c'est où l'on met + /// le visiteur — et aucun fichier ne peut le deviner, d'où ce champ. + /// + public Scene3DMode Mode { get; set; } = Scene3DMode.Asset; + + /// + /// URL du modèle, dupliquée comme l'est à côté + /// de . Ce n'est pas de la redondance gratuite : le + /// viewer et le casque ont besoin de l'URL sans faire de jointure, et l'export + /// hors ligne la réécrit en chemin local. + /// + public string Model3DSource { get; set; } + + /// + /// Points posés sur le modèle. Même collection que celle d'une Map, même éditeur, + /// même contenu — seule la position diffère. + /// + public List Points { get; set; } + + public override string GetEmbeddableText(string language) => + JoinText(new[] { BaseText(language) } + .Concat((Points ?? new List()) + .Select(p => p.GetEmbeddableText(language))) + .ToArray()); + + /// + /// Le modèle compte parmi les ressources de la visite : sans lui, un téléchargement + /// hors ligne rapporterait les points d'une maquette absente. + /// + public override IEnumerable GetReferencedResourceIds(string language = null) => + BaseResourceIds() + .Concat(ResourceId(Model3DResourceId)) + .Concat((Points ?? new List()) + .SelectMany(p => p.GetReferencedResourceIds())); + + public Scene3DDTO ToDTO() + { + return new Scene3DDTO() + { + id = Id, + label = Label, + title = Title.ToList(), + description = Description.ToList(), + order = Order, + type = Type, + imageId = ImageId, + imageSource = ImageSource, + configurationId = ConfigurationId, + isSubSection = IsSubSection, + parentId = ParentId, + isActive = IsActive, + dateCreation = DateCreation, + instanceId = InstanceId, + latitude = Latitude, + longitude = Longitude, + meterZoneGPS = MeterZoneGPS, + isBeacon = IsBeacon, + beaconId = BeaconId, + model3DResourceId = Model3DResourceId, + model3DSource = Model3DSource, + mode = Mode, + points = (Points ?? new List()).Select(p => p.ToDTO()).ToList() + }; + } + } +} diff --git a/ManagerService/Migrations/20260911135527_AddAppTypeToDevice.Designer.cs b/ManagerService/Migrations/20260911135527_AddAppTypeToDevice.Designer.cs new file mode 100644 index 0000000..876b80b --- /dev/null +++ b/ManagerService/Migrations/20260911135527_AddAppTypeToDevice.Designer.cs @@ -0,0 +1,1937 @@ +// +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("20260911135527_AddAppTypeToDevice")] + partial class AddAppTypeToDevice + { + /// + 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("AppName") + .HasColumnType("jsonb"); + + b.Property("AppStoreUrl") + .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.Property("IsQRCodeEnabled") + .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("PlayStoreUrl") + .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("AppType") + .HasColumnType("integer"); + + 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/20260911135527_AddAppTypeToDevice.cs b/ManagerService/Migrations/20260911135527_AddAppTypeToDevice.cs new file mode 100644 index 0000000..bd430da --- /dev/null +++ b/ManagerService/Migrations/20260911135527_AddAppTypeToDevice.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ManagerService.Migrations +{ + /// + public partial class AddAppTypeToDevice : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // defaultValue = 1 (AppType.Tablet), PAS 0 (Mobile) : jusqu'ici la table n'était + // peuplée que par tablet-app. Un backfill à 0 sortirait toutes les tablettes + // existantes de l'onglet Kiosk, qui filtre désormais sur AppType. + // Ce défaut ne sert qu'au backfill : à l'insert, EF envoie toujours la valeur + // explicitement (Device.AppType a son propre défaut C#). Il n'est donc pas + // déclaré dans le modèle, ce qui évite le piège ValueGeneratedOnAdd documenté + // dans MyInfoMateDbContext (une colonne à defaultValue devient read-only). + migrationBuilder.AddColumn( + name: "AppType", + table: "Devices", + type: "integer", + nullable: false, + defaultValue: 1); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AppType", + table: "Devices"); + } + } +} diff --git a/ManagerService/Migrations/20260912192855_AddScene3DSectionAndImmersiveAddon.Designer.cs b/ManagerService/Migrations/20260912192855_AddScene3DSectionAndImmersiveAddon.Designer.cs new file mode 100644 index 0000000..710edd1 --- /dev/null +++ b/ManagerService/Migrations/20260912192855_AddScene3DSectionAndImmersiveAddon.Designer.cs @@ -0,0 +1,1976 @@ +// +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("20260912192855_AddScene3DSectionAndImmersiveAddon")] + partial class AddScene3DSectionAndImmersiveAddon + { + /// + 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("AppName") + .HasColumnType("jsonb"); + + b.Property("AppStoreUrl") + .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.Property("IsQRCodeEnabled") + .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("PlayStoreUrl") + .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("AppType") + .HasColumnType("integer"); + + 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("HasImmersiveContent") + .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("LocalTransform") + .HasColumnType("jsonb"); + + 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("SectionScene3DId") + .HasColumnType("text"); + + b.Property("Site") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("SectionMapId"); + + b.HasIndex("SectionScene3DId"); + + 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.SectionScene3D", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Model3DResourceId") + .HasColumnType("text"); + + b.Property("Model3DSource") + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Model3D"); + }); + + 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.HasOne("ManagerService.Data.SubSection.SectionScene3D", "SectionScene3D") + .WithMany("Points") + .HasForeignKey("SectionScene3DId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionMap"); + + b.Navigation("SectionScene3D"); + }); + + 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"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionScene3D", b => + { + b.Navigation("Points"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ManagerService/Migrations/20260912192855_AddScene3DSectionAndImmersiveAddon.cs b/ManagerService/Migrations/20260912192855_AddScene3DSectionAndImmersiveAddon.cs new file mode 100644 index 0000000..b6ebc35 --- /dev/null +++ b/ManagerService/Migrations/20260912192855_AddScene3DSectionAndImmersiveAddon.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ManagerService.Migrations +{ + /// + public partial class AddScene3DSectionAndImmersiveAddon : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Mode", + table: "Sections", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "Model3DResourceId", + table: "Sections", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "Model3DSource", + table: "Sections", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "HasImmersiveContent", + table: "Instances", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LocalTransform", + table: "GeoPoints", + type: "jsonb", + nullable: true); + + migrationBuilder.AddColumn( + name: "SectionScene3DId", + table: "GeoPoints", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_GeoPoints_SectionScene3DId", + table: "GeoPoints", + column: "SectionScene3DId"); + + migrationBuilder.AddForeignKey( + name: "FK_GeoPoints_Sections_SectionScene3DId", + table: "GeoPoints", + column: "SectionScene3DId", + principalTable: "Sections", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_GeoPoints_Sections_SectionScene3DId", + table: "GeoPoints"); + + migrationBuilder.DropIndex( + name: "IX_GeoPoints_SectionScene3DId", + table: "GeoPoints"); + + migrationBuilder.DropColumn( + name: "Mode", + table: "Sections"); + + migrationBuilder.DropColumn( + name: "Model3DResourceId", + table: "Sections"); + + migrationBuilder.DropColumn( + name: "Model3DSource", + table: "Sections"); + + migrationBuilder.DropColumn( + name: "HasImmersiveContent", + table: "Instances"); + + migrationBuilder.DropColumn( + name: "LocalTransform", + table: "GeoPoints"); + + migrationBuilder.DropColumn( + name: "SectionScene3DId", + table: "GeoPoints"); + } + } +} diff --git a/ManagerService/Migrations/20260912210427_AddImmersiveBackground.Designer.cs b/ManagerService/Migrations/20260912210427_AddImmersiveBackground.Designer.cs new file mode 100644 index 0000000..86b0cc0 --- /dev/null +++ b/ManagerService/Migrations/20260912210427_AddImmersiveBackground.Designer.cs @@ -0,0 +1,2027 @@ +// +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("20260912210427_AddImmersiveBackground")] + partial class AddImmersiveBackground + { + /// + 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("AppName") + .HasColumnType("jsonb"); + + b.Property("AppStoreUrl") + .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.Property("IsQRCodeEnabled") + .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("PlayStoreUrl") + .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("AppType") + .HasColumnType("integer"); + + 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("HasImmersiveContent") + .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("LocalTransform") + .HasColumnType("jsonb"); + + 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("SectionScene3DId") + .HasColumnType("text"); + + b.Property("Site") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Title") + .IsRequired() + .HasColumnType("jsonb"); + + b.HasKey("Id"); + + b.HasIndex("SectionEventId"); + + b.HasIndex("SectionMapId"); + + b.HasIndex("SectionScene3DId"); + + 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.SectionScene3D", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Model3DResourceId") + .HasColumnType("text"); + + b.Property("Model3DSource") + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Model3D"); + }); + + 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.OwnsOne("ManagerService.Data.ImmersiveBackground", "ImmersiveBackground", b1 => + { + b1.Property("ApplicationInstanceId") + .HasColumnType("text"); + + b1.Property("FallbackResourceId") + .HasColumnType("text"); + + b1.Property("Kind") + .HasColumnType("integer"); + + b1.Property("ResourceId") + .HasColumnType("text"); + + b1.HasKey("ApplicationInstanceId"); + + b1.ToTable("ApplicationInstances"); + + b1.WithOwner() + .HasForeignKey("ApplicationInstanceId"); + }); + + b.Navigation("ImmersiveBackground"); + + b.Navigation("SectionEvent"); + }); + + modelBuilder.Entity("ManagerService.Data.Configuration", b => + { + b.OwnsOne("ManagerService.Data.ImmersiveBackground", "ImmersiveBackground", b1 => + { + b1.Property("ConfigurationId") + .HasColumnType("text"); + + b1.Property("FallbackResourceId") + .HasColumnType("text"); + + b1.Property("Kind") + .HasColumnType("integer"); + + b1.Property("ResourceId") + .HasColumnType("text"); + + b1.HasKey("ConfigurationId"); + + b1.ToTable("Configurations"); + + b1.WithOwner() + .HasForeignKey("ConfigurationId"); + }); + + b.Navigation("ImmersiveBackground"); + }); + + 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.HasOne("ManagerService.Data.SubSection.SectionScene3D", "SectionScene3D") + .WithMany("Points") + .HasForeignKey("SectionScene3DId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("SectionEvent"); + + b.Navigation("SectionMap"); + + b.Navigation("SectionScene3D"); + }); + + 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"); + }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionScene3D", b => + { + b.Navigation("Points"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/ManagerService/Migrations/20260912210427_AddImmersiveBackground.cs b/ManagerService/Migrations/20260912210427_AddImmersiveBackground.cs new file mode 100644 index 0000000..00333ce --- /dev/null +++ b/ManagerService/Migrations/20260912210427_AddImmersiveBackground.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace ManagerService.Migrations +{ + /// + public partial class AddImmersiveBackground : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ImmersiveBackground_FallbackResourceId", + table: "Configurations", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ImmersiveBackground_Kind", + table: "Configurations", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ImmersiveBackground_ResourceId", + table: "Configurations", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ImmersiveBackground_FallbackResourceId", + table: "ApplicationInstances", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "ImmersiveBackground_Kind", + table: "ApplicationInstances", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ImmersiveBackground_ResourceId", + table: "ApplicationInstances", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ImmersiveBackground_FallbackResourceId", + table: "Configurations"); + + migrationBuilder.DropColumn( + name: "ImmersiveBackground_Kind", + table: "Configurations"); + + migrationBuilder.DropColumn( + name: "ImmersiveBackground_ResourceId", + table: "Configurations"); + + migrationBuilder.DropColumn( + name: "ImmersiveBackground_FallbackResourceId", + table: "ApplicationInstances"); + + migrationBuilder.DropColumn( + name: "ImmersiveBackground_Kind", + table: "ApplicationInstances"); + + migrationBuilder.DropColumn( + name: "ImmersiveBackground_ResourceId", + table: "ApplicationInstances"); + } + } +} diff --git a/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs b/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs index d91ac34..2b43c50 100644 --- a/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs +++ b/ManagerService/Migrations/MyInfoMateDbContextModelSnapshot.cs @@ -374,6 +374,9 @@ namespace ManagerService.Migrations b.Property("Id") .HasColumnType("text"); + b.Property("AppType") + .HasColumnType("integer"); + b.Property("AppVersion") .HasColumnType("text"); @@ -471,6 +474,9 @@ namespace ManagerService.Migrations b.Property("HasAdvancedStats") .HasColumnType("boolean"); + b.Property("HasImmersiveContent") + .HasColumnType("boolean"); + b.Property("HasStats") .HasColumnType("boolean"); @@ -888,6 +894,9 @@ namespace ManagerService.Migrations b.Property("ImageUrl") .HasColumnType("text"); + b.Property("LocalTransform") + .HasColumnType("jsonb"); + b.Property("Phone") .IsRequired() .HasColumnType("jsonb"); @@ -909,6 +918,9 @@ namespace ManagerService.Migrations b.Property("SectionMapId") .HasColumnType("text"); + b.Property("SectionScene3DId") + .HasColumnType("text"); + b.Property("Site") .IsRequired() .HasColumnType("jsonb"); @@ -923,6 +935,8 @@ namespace ManagerService.Migrations b.HasIndex("SectionMapId"); + b.HasIndex("SectionScene3DId"); + b.ToTable("GeoPoints"); }); @@ -1588,6 +1602,22 @@ namespace ManagerService.Migrations b.HasDiscriminator().HasValue("Quiz"); }); + modelBuilder.Entity("ManagerService.Data.SubSection.SectionScene3D", b => + { + b.HasBaseType("ManagerService.Data.Section"); + + b.Property("Mode") + .HasColumnType("integer"); + + b.Property("Model3DResourceId") + .HasColumnType("text"); + + b.Property("Model3DSource") + .HasColumnType("text"); + + b.HasDiscriminator().HasValue("Model3D"); + }); + modelBuilder.Entity("ManagerService.Data.SubSection.SectionSlider", b => { b.HasBaseType("ManagerService.Data.Section"); @@ -1679,9 +1709,60 @@ namespace ManagerService.Migrations .WithMany() .HasForeignKey("SectionEventId"); + b.OwnsOne("ManagerService.Data.ImmersiveBackground", "ImmersiveBackground", b1 => + { + b1.Property("ApplicationInstanceId") + .HasColumnType("text"); + + b1.Property("FallbackResourceId") + .HasColumnType("text"); + + b1.Property("Kind") + .HasColumnType("integer"); + + b1.Property("ResourceId") + .HasColumnType("text"); + + b1.HasKey("ApplicationInstanceId"); + + b1.ToTable("ApplicationInstances"); + + b1.WithOwner() + .HasForeignKey("ApplicationInstanceId"); + }); + + b.Navigation("ImmersiveBackground"); + b.Navigation("SectionEvent"); }); + modelBuilder.Entity("ManagerService.Data.Configuration", b => + { + b.OwnsOne("ManagerService.Data.ImmersiveBackground", "ImmersiveBackground", b1 => + { + b1.Property("ConfigurationId") + .HasColumnType("text"); + + b1.Property("FallbackResourceId") + .HasColumnType("text"); + + b1.Property("Kind") + .HasColumnType("integer"); + + b1.Property("ResourceId") + .HasColumnType("text"); + + b1.HasKey("ConfigurationId"); + + b1.ToTable("Configurations"); + + b1.WithOwner() + .HasForeignKey("ConfigurationId"); + }); + + b.Navigation("ImmersiveBackground"); + }); + modelBuilder.Entity("ManagerService.Data.Device", b => { b.HasOne("ManagerService.Data.Configuration", "Configuration") @@ -1746,9 +1827,16 @@ namespace ManagerService.Migrations .WithMany("MapPoints") .HasForeignKey("SectionMapId"); + b.HasOne("ManagerService.Data.SubSection.SectionScene3D", "SectionScene3D") + .WithMany("Points") + .HasForeignKey("SectionScene3DId") + .OnDelete(DeleteBehavior.Cascade); + b.Navigation("SectionEvent"); b.Navigation("SectionMap"); + + b.Navigation("SectionScene3D"); }); modelBuilder.Entity("ManagerService.Data.SubSection.GuidedPath", b => @@ -1925,6 +2013,11 @@ namespace ManagerService.Migrations { b.Navigation("QuizQuestions"); }); + + modelBuilder.Entity("ManagerService.Data.SubSection.SectionScene3D", b => + { + b.Navigation("Points"); + }); #pragma warning restore 612, 618 } } diff --git a/ManagerService/Security/RequireAppKeyAttribute.cs b/ManagerService/Security/RequireAppKeyAttribute.cs new file mode 100644 index 0000000..59a9297 --- /dev/null +++ b/ManagerService/Security/RequireAppKeyAttribute.cs @@ -0,0 +1,56 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace ManagerService.Security +{ + /// + /// Exige une clé d'API valide (X-Api-Key) ou un utilisateur du manager + /// déjà authentifié. À poser sur les routes que les apps visiteur consomment. + /// + /// Pourquoi un filtre et pas [Authorize(AppReadAccess)]. ASP.NET Core + /// combine les [Authorize] de la classe et de l'action : sur un + /// contrôleur qui exige ContentEditor, ajouter une policy plus permissive sur + /// l'action ne l'assouplit pas — la clé authentifierait la requête, puis l'autorisation + /// la refuserait, et l'app recevrait un 403 sans corps. Seul [AllowAnonymous] + /// court-circuite la policy de classe. L'action garde donc son [AllowAnonymous], + /// et ce filtre redevient le contrôle d'accès. + /// + /// C'est exactement ce que ConfigurationController.Export faisait à la main + /// depuis le commit 9cc45c5 ; ceci le rend réutilisable au lieu de le recopier + /// vingt-quatre fois. + /// + /// ⚠️ Ce que ce filtre ne fait pas : le cloisonnement. Il vérifie qu'une clé est + /// valide, pas qu'elle donne accès à cette ressource-là — ça demande de résoudre + /// l'instance de la ressource, ce qui diffère à chaque endpoint. Les routes qui savent + /// le faire le font en plus, dans leur corps (voir Export et + /// InstanceController.GetDetail). Et la clé s'obtient anonymement, par le slug + /// ou par le pincode : ceci ferme l'énumération par identifiant et rend l'accès + /// révocable, ça ne rend pas le contenu confidentiel. + /// + [AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)] + public class RequireAppKeyAttribute : Attribute, IAsyncAuthorizationFilter + { + public const string ApiKeyScheme = "ApiKey"; + + public async Task OnAuthorizationAsync(AuthorizationFilterContext context) + { + var http = context.HttpContext; + + // Le schéma ApiKey n'est pas le schéma par défaut : sur une action + // [AllowAnonymous] il faut le déclencher explicitement. + var apiKey = await http.AuthenticateAsync(ApiKeyScheme); + if (apiKey.Succeeded) return; + + // Le back-office appelle les mêmes routes avec son jeton. + if (http.User?.Identity?.IsAuthenticated == true + && http.User.HasClaim(Service.Security.ClaimTypes.Permission, + Service.Security.Permissions.Viewer)) + return; + + context.Result = new ObjectResult("Authentication required") { StatusCode = 401 }; + } + } +} diff --git a/ManagerService/Services/SectionFactory.cs b/ManagerService/Services/SectionFactory.cs index d9ddd59..99cc72c 100644 --- a/ManagerService/Services/SectionFactory.cs +++ b/ManagerService/Services/SectionFactory.cs @@ -1,4 +1,4 @@ -using Manager.DTOs; +using Manager.DTOs; using ManagerService.Data; using ManagerService.Data.SubSection; using ManagerService.DTOs; @@ -55,6 +55,7 @@ namespace ManagerService.Services SectionType.Weather => new SectionWeather(), SectionType.Web => new SectionWeb { WebSource = "" }, SectionType.Parcours => new SectionParcours { GuidedPaths = new List() }, + SectionType.Scene3D => new SectionScene3D { Points = new List() }, _ => throw new NotImplementedException($"Section type not handled: {type}") }; @@ -73,6 +74,7 @@ namespace ManagerService.Services WeatherDTO weatherDTO = new WeatherDTO(); WebDTO webDTO = new WebDTO(); ParcoursDTO parcoursDTO = new ParcoursDTO(); + Scene3DDTO model3DDTO = new Scene3DDTO(); switch (dto.type) { @@ -118,6 +120,9 @@ namespace ManagerService.Services case SectionType.Parcours: parcoursDTO = JsonConvert.DeserializeObject(jsonElement.ToString()); break; + case SectionType.Scene3D: + model3DDTO = JsonConvert.DeserializeObject(jsonElement.ToString()); + break; } return dto.type switch @@ -439,6 +444,32 @@ namespace ManagerService.Services ShowMap = parcoursDTO.showMap, BaseSectionMapId = parcoursDTO.baseSectionMapId, }, + SectionType.Scene3D => new SectionScene3D + { + Id = dto.id, + DateCreation = dto.dateCreation.Value, + ConfigurationId = dto.configurationId, + InstanceId = dto.instanceId, + Label = dto.label, + Title = dto.title, + Description = dto.description, + Order = dto.order.Value, + ImageId = dto.imageId, + ImageSource = dto.imageSource, + IsSubSection = dto.isSubSection, + ParentId = dto.parentId, + IsBeacon = dto.isBeacon, + BeaconId = dto.beaconId, + Latitude = dto.latitude, + Longitude = dto.longitude, + MeterZoneGPS = dto.meterZoneGPS, + Type = dto.type, + Model3DResourceId = model3DDTO.model3DResourceId, + Model3DSource = model3DDTO.model3DSource, + Mode = model3DDTO.mode, + // Les points arrivent par l'éditeur de points, pas par la création de + // la section — comme pour une Map. + }, _ => throw new NotImplementedException("Section type not handled") }; } @@ -781,6 +812,7 @@ namespace ManagerService.Services baseSectionMapId = parcours.BaseSectionMapId, // guidedPaths chargés spécifiquement dans GetFromConfigurationDetail }, + SectionScene3D model => model.ToDTO(), _ => throw new NotImplementedException("Section type not handled") }; }