Cle d'API sur les routes visiteur, canal VR et contenu immersif

Securite
- RequireAppKey : filtre qui exige une cle d'API valide ou un utilisateur du
  manager. Pose sur les routes de contenu consommees par les apps visiteur
  (Configuration, Section, Resource, SectionMap, SectionEvent, SectionAgenda,
  SectionParcours, SectionQuiz, ApplicationInstance). Un [Authorize] d'action
  ne peut pas assouplir celui de la classe ; seul [AllowAnonymous] le
  court-circuite, et le filtre redevient le controle d'acces. Il ferme
  l'enumeration par identifiant, pas la confidentialite : la cle s'obtient
  par le slug ou le pincode.
- Instance/slug/{slug} rendait le pinCode, l'adresse de facturation, la TVA et
  les quotas a qui lit l'URL du site visiteur. StripCommercialFields est
  desormais applique sur slug et byPin ; isTrialActive reste expose pour le
  filigrane d'essai.
- Device.Create et Device/{id}/detail repondaient 403 a toute tablette depuis
  a452f4a (13/03) : la classe exige InstanceAdmin, une cle ne porte que
  AppRead. Ouverts a la cle, le cloisonnement par instance etait deja ecrit.

Canal VR
- Device.AppType (defaut Tablet, backfill a 1) ; Create resout
  l'ApplicationInstance sur ce type au lieu de Tablet en dur.
- Get filtre optionnellement par appType ; DeviceDetailDTO expose appVersion
  et lastSeen.
- PUT Device/{id}/heartbeat : batterie, version, connexion. Volontairement
  etroit, une app ne peut ni se renommer ni changer d'instance.
- ApiKeyAppType.VrApp en fin d'enum.

Contenu immersif
- ResourceType : Image360 (11), Video360 (12), Model3D (13), en fin d'enum.
- Section Scene3D : un modele GLB et ses points d'interet, en objet manipule
  ou en decor habite. Les points sont des GeoPoint, avec une LocalTransform
  en jsonb (convention glTF) ; CRUD dans SectionScene3DController.
- Instance.HasImmersiveContent : l'add-on ajoute 100 Go au quota de stockage,
  repose apres un changement de plan et retire a la desactivation.
- ImmersiveBackground (owned) sur Configuration et ApplicationInstance, avec
  une image de repli pour les canaux qui ne rendent pas l'immersif.

Export de configuration
- exportVersion (1) et generatedAt : le JSON devient un contrat, lu tel quel
  par l'app Unity.
- Section.ToDTO() n'etant pas virtuelle, l'export ne portait aucun champ
  specifique de sous-type. Passe par SectionFactory.ToDTO, et charge les
  points des Map et des Scene3D.
- Le fond immersif et son repli partent avec les ressources, URL resolues.

Migrations : AddAppTypeToDevice, AddScene3DSectionAndImmersiveAddon,
AddImmersiveBackground.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-09-13 16:54:10 +02:00
parent 4e17c572ba
commit b87e49d808
48 changed files with 7898 additions and 34 deletions

View File

@ -43,6 +43,67 @@ namespace ManagerService.Tests.Controllers
},
};
/// <summary>
/// ⚠️ <b>Le second trou du même endroit, trouvé le 12/09.</b> Les ressources
/// partaient bien, mais <b>pas les champs qui les référencent</b> :
/// <c>Section.ToDTO()</c> n'est pas virtuelle, et l'export appelait
/// <c>s.ToDTO()</c> sur une variable de type <c>Section</c>. 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 <c>SectionFactory.ToDTO</c> fait
/// le sous-type réel.
/// </summary>
[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<TranslationDTO>();
section.Description = new List<TranslationDTO>();
var dto = ManagerService.Services.SectionFactory.ToDTO(section);
Assert.IsType(expectedDto, dto);
Assert.IsAssignableFrom<SectionDTO>(dto);
}
[Fact]
public void Export_KeepsModel3DFields()
{
var section = new SectionScene3D
{
Id = "s1",
Title = new List<TranslationDTO>(),
Description = new List<TranslationDTO>(),
Model3DResourceId = "res-glb",
Model3DSource = "https://cdn/model.glb",
Points = new List<GeoPoint>
{
new GeoPoint
{
Id = 1,
Title = new List<TranslationDTO>(),
Description = new List<TranslationDTO>(),
Contents = new List<ContentDTO>(),
LocalTransform = new Position3D { x = 1.5f, y = 0.2f, z = -3f }
}
}
};
var dto = Assert.IsType<Scene3DDTO>(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()
{

View File

@ -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<TranslationDTO>() });
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<OkObjectResult>(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<NotFoundObjectResult>(result);
}
// ── CREATE ───────────────────────────────────────────────────────────
[Fact]
@ -132,5 +214,84 @@ namespace ManagerService.Tests.Controllers
Assert.IsType<NotFoundObjectResult>(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<OkObjectResult>(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<TranslationDTO>() });
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<NotFoundObjectResult>(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<OkObjectResult>(BuildController(db).Get(null, AppType.VR));
Assert.Equal("quest-1", Assert.Single((System.Collections.Generic.IEnumerable<DeviceDTO>)vr.Value).identifier);
// Sans filtre : comportement historique, tous les appareils.
var all = Assert.IsType<OkObjectResult>(BuildController(db).Get(null));
Assert.Equal(2, ((System.Collections.Generic.IEnumerable<DeviceDTO>)all.Value).Count());
}
}
}

View File

@ -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
{
/// <summary>
/// L'add-on « Contenu immersif » — §6 de <c>DOCS/v2/vr-quest-unity-plan.md</c>.
///
/// ⚠️ Ces tests existent pour une raison précise : la migration pose un
/// <c>defaultValue</c>, ce qui amène EF Core à traiter la colonne comme
/// <c>ValueGeneratedOnAdd</c> — <b>read-only après insert</b>. Le piège est déjà
/// documenté dans <c>MyInfoMateDbContext</c> pour les quotas, et il a coûté une
/// migration corrigée à la main sur <c>Device.AppType</c>. Sans
/// <c>ValueGeneratedNever</c>, l'add-on ne s'activerait jamais — silencieusement.
/// </summary>
public class InstanceAddonTests
{
private InstanceController BuildController(MyInfoMateDbContext db)
{
var cfg = FakeMongoConfig.Create();
return new InstanceController(
NullLogger<InstanceController>.Instance,
new InstanceDatabaseService(cfg),
new UserDatabaseService(cfg),
new ProfileLogic(NullLogger<ProfileLogic>.Instance),
db,
new ApiKeyDatabaseService(db),
new Mock<IBackgroundJobClient>().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<OkObjectResult>(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);
}
}
}

View File

@ -69,6 +69,77 @@ namespace ManagerService.Tests.Controllers
Assert.IsType<BadRequestObjectResult>(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<OkObjectResult>(BuildController(db).GetInstanceBySlug("musee"));
var dto = Assert.IsType<InstanceDTO>(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<OkObjectResult>(BuildController(db).GetInstanceBySlug("musee"));
var dto = Assert.IsType<InstanceDTO>(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<OkObjectResult>(BuildController(db).GetInstanceByPinCode("4821"));
var dto = Assert.IsType<InstanceDTO>(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]

View File

@ -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)!;

View File

@ -0,0 +1,35 @@
using ManagerService.Data;
using Xunit;
namespace ManagerService.Tests.Data
{
/// <summary>
/// <c>ResourceType</c> est persisté <b>en int</b> dans une colonne <c>integer</c> :
/// 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.
/// </summary>
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);
}
}
}

View File

@ -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
{
/// <summary>
/// Les tests des contrôleurs appellent les méthodes <b>directement</b> : 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.
/// </summary>
public class RequireAppKeyAttributeTests
{
private static AuthorizationFilterContext BuildContext(
bool apiKeyValid, ClaimsPrincipal user = null)
{
var authentication = new Mock<IAuthenticationService>();
authentication
.Setup(a => a.AuthenticateAsync(It.IsAny<HttpContext>(),
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<IFilterMetadata>());
}
[Fact]
public async Task NoKeyNoUser_Returns401()
{
var context = BuildContext(apiKeyValid: false);
await new RequireAppKeyAttribute().OnAuthorizationAsync(context);
var result = Assert.IsType<ObjectResult>(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<ObjectResult>(context.Result);
Assert.Equal(401, result.StatusCode);
}
}
}

View File

@ -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
/// </summary>
/// <param name="applicationInstanceId">application instance id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<AppConfigurationLinkDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{applicationInstanceId}/application-link")]

View File

@ -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
/// </summary>
/// <param name="id">id instance</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<ConfigurationDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet]
@ -116,6 +119,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="id">id configuration</param>
[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<Section> sections = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == configuration.Id).ToList();
List<SectionDTO> 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<SectionMap>()
.Include(s => s.MapPoints)
.Where(s => s.ConfigurationId == configurationId).ToList();
_myInfoMateDbContext.Sections.OfType<SectionScene3D>()
.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<SectionDTO> sectionDTOs = sections
.Select(s => (SectionDTO)SectionFactory.ToDTO(s))
.ToList();
List<ResourceDTO> resourceDTOs = new List<ResourceDTO>();
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);

View File

@ -43,11 +43,12 @@ namespace ManagerService.Controllers
/// <summary>
/// Get a list of all devices
/// </summary>
/// <param name="id">id instance</param>
/// <param name="instanceId">id instance</param>
/// <param name="appType">Canal à filtrer. Absent = tous les appareils, comportement historique.</param>
[ProducesResponseType(typeof(List<DeviceDTO>), 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
/// </summary>
/// <param name="id">id device</param>
/// <remarks>
/// ⚠️ <b>C'est le premier appel d'une tablette qui démarre</b> :
/// <c>tablet-app/lib/main.dart:38</c> reconstruit son client avec l'hôte mémorisé,
/// puis demande son propre détail pour savoir quelle configuration afficher. Sans
/// exception à la policy <c>InstanceAdmin</c> de la classe, cet appel répondait
/// 403 et <b>la tablette ne retrouvait plus son contenu au démarrage</b> — même
/// cause que l'appairage cassé depuis le 13/03/2026 (voir <see cref="Create"/>).
///
/// 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.
/// </remarks>
[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
/// </summary>
/// <param name="newDevice">New device info</param>
/// <remarks>
/// ⚠️ <b>C'est une app qui appelle cette route, pas un humain</b> : une tablette
/// qui s'appaire avec un code PIN, et bientôt un casque. Or le contrôleur exige
/// <c>InstanceAdmin</c>, qu'une clé d'API n'a pas — elle ne porte que
/// <c>AppRead</c> et <c>Viewer</c> — et <c>tablet-app</c> ne s'authentifie
/// jamais autrement. Depuis que la policy a été posée sur la classe (commit
/// <c>a452f4a</c>, 13/03/2026, « need to be tested »), <b>l'appairage d'une
/// nouvelle tablette répondait 403</b> ; 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 <b>son</b> instance.
/// </remarks>
[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
}
}
/// <summary>
/// Heartbeat sent by a running app: battery, app version, last seen.
/// </summary>
/// <param name="id">Device id</param>
/// <param name="beat">What the app knows about itself</param>
/// <remarks>
/// Lot <c>XR-5</c>. L'onglet XR affiche batterie, version et dernier vu depuis
/// le 12/09 — mais <b>rien ne les alimentait</b> : <c>Update</c> n'écrit ni
/// <c>AppVersion</c> ni <c>LastSeen</c>, 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.
/// </remarks>
[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 };
}
}
/// <summary>
/// Update device main info
/// </summary>
/// <param name="updatedDevice">Device to update</param>
/// <param name="updatedDevice">Device to update</param>
[ProducesResponseType(typeof(DeviceDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]

View File

@ -161,6 +161,36 @@ namespace ManagerService.Controllers
instance.HasAdvancedStats = plan.HasAdvancedStats;
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Create an instance
/// </summary>
@ -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
/// <summary>
/// Get Instance by pincode
/// </summary>
/// <param name="pinCode">Code pin</param>
/// <param name="pinCode">Code pin</param>
[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)
{

View File

@ -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
/// </summary>
/// <param name="id">id resource</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(ResourceDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -161,6 +163,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="id">id resource</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(FileResult), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]

View File

@ -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
/// </summary>
/// <param name="sectionAgendaId">Section id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<EventAgendaDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -80,6 +82,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="sectionAgendaId">Section id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<EventAgendaDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]

View File

@ -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
/// </summary>
/// <param name="id">configuration id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<SectionDTO>), 200)]
[ProducesResponseType(typeof(string), 500)]
[ProducesResponseType(typeof(string), 400)]
@ -172,6 +174,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="id">configuration id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<object>), 200)]
[ProducesResponseType(typeof(string), 500)]
[ProducesResponseType(typeof(string), 400)]
@ -453,6 +456,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="id">section id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(object), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -485,6 +489,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="instanceId">Instance id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<SectionDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]

View File

@ -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
/// </summary>
/// <param name="sectionEventId">Section id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<ProgrammeBlock>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -210,6 +212,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="programBlockId">Program block id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<MapAnnotationDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -387,6 +390,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="sectionEventId">Section event id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<MapAnnotationDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]

View File

@ -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
/// </summary>
/// <param name="sectionId">Section id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<GeoPointDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -330,6 +332,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="sectionMapId">Section id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<GuidedPathDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -541,6 +544,7 @@ namespace ManagerService.Controllers
/// </summary>
/// <param name="guidedPathId">Guided path id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<GuidedStepDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]

View File

@ -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
/// </summary>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<GuidedPathDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@ -235,6 +237,7 @@ namespace ManagerService.Controllers
/// Get all steps from a guided path
/// </summary>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<GuidedStepDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]

View File

@ -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
/// </summary>
/// <param name="sectionId">Section id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<QuestionDTO>), 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 déplacée
// Retirer la question d<EFBFBD>plac<EFBFBD>e
questions.RemoveAll(q => q.Id == existingQuestion.Id);
// Insérer à la nouvelle position (déjà en 0-based)
// Ins<EFBFBD>rer <20> la nouvelle position (d<>j<EFBFBD> en 0-based)
int newIndex = questionDTO.order.Value;
newIndex = Math.Clamp(newIndex, 0, questions.Count);
questions.Insert(newIndex, existingQuestion);
// Réassigner les ordres en 0-based
// R<EFBFBD>assigner les ordres en 0-based
for (int i = 0; i < questions.Count; i++)
{
questions[i].Order = i;

View File

@ -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
{
/// <summary>
/// Les points d'intérêt d'une <b>maquette 3D</b> — item E7 du lot XR-4.
///
/// <b>Pourquoi un contrôleur à part.</b> Le CRUD des points existe déjà, mais il est
/// écrit sur <c>SectionMap</c> : <c>OfType&lt;SectionMap&gt;()</c>, <c>MapPoints</c>.
/// Une maquette a les mêmes points — c'est le même <c>GeoPoint</c> — sur une autre
/// collection. Élargir <c>SectionMapController</c> 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
/// <c>LocalTransform</c> (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.
/// </summary>
[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<SectionScene3DController> _logger;
private readonly MyInfoMateDbContext _myInfoMateDbContext;
public SectionScene3DController(ILogger<SectionScene3DController> logger,
MyInfoMateDbContext myInfoMateDbContext)
{
_logger = logger;
_myInfoMateDbContext = myInfoMateDbContext;
}
/// <summary>Get all points of interest of a 3D model section</summary>
/// <param name="sectionId">Section id</param>
[AllowAnonymous]
[RequireAppKey]
[ProducesResponseType(typeof(List<GeoPointDTO>), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{sectionId}/points")]
public ObjectResult GetAllPointsFromSection(string sectionId)
{
try
{
var section = _myInfoMateDbContext.Sections
.OfType<SectionScene3D>()
.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<GeoPoint>()).Select(p => p.ToDTO()).ToList());
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message) { };
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>Add a point of interest to a 3D model section</summary>
/// <param name="sectionId">Section id</param>
/// <param name="geoPointDTO">Point to create</param>
[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<SectionScene3D>()
.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<GeoPoint>();
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 };
}
}
/// <summary>Update a point of interest, position included</summary>
/// <param name="geoPointDTO">Point to update</param>
[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 };
}
}
/// <summary>Delete a point of interest</summary>
/// <param name="id">Point id</param>
[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 };
}
}
}
}

View File

@ -49,5 +49,8 @@ namespace ManagerService.DTOs
public string? appStoreUrl { get; set; }
public string? playStoreUrl { get; set; }
/// <summary>Fond du menu général, spécifique VR.</summary>
public ImmersiveBackgroundDTO? immersiveBackground { get; set; }
}
}

View File

@ -19,5 +19,8 @@ namespace ManagerService.DTOs
public List<string> sectionIds { get; set; }
public string loaderImageId { get; set; } // == ResourceId
public string loaderImageUrl { get; set; } // == Image url
/// <summary>Fond immersif de la visite, null si elle n'en a pas.</summary>
public ImmersiveBackgroundDTO immersiveBackground { get; set; }
}
}

View File

@ -15,6 +15,9 @@ namespace ManagerService.DTOs
public DateTime? dateCreation{ get; set; }
public DateTime? dateUpdate { get; set; }
public string instanceId { get; set; }
/// <summary>Canal de l'appareil. Absent de la requête = tablette, pour ne pas casser tablet-app.</summary>
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; }
}
}

View File

@ -0,0 +1,22 @@
namespace ManagerService.DTOs
{
/// <summary>
/// Ce qu'une app en fonctionnement dit d'elle-même — lot <c>XR-5</c>.
///
/// 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 <c>Connected</c> et <c>LastSeen</c>, puisque recevoir le
/// battement est la preuve.
/// </summary>
public class DeviceHeartbeatDTO
{
/// <summary>Niveau de batterie, tel que l'app le lit (« 87 »).</summary>
public string batteryLevel { get; set; }
/// <summary>Version de l'app installée sur l'appareil.</summary>
public string appVersion { get; set; }
/// <summary>Qualité de connexion, quand l'app sait la mesurer.</summary>
public string connectionLevel { get; set; }
}
}

View File

@ -1,11 +1,47 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
namespace ManagerService.DTOs
{
/// <summary>
/// Le contenu complet d'une configuration, en un seul appel — le <b>contrat public</b>
/// 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 <c>mymuseum-visitapp</c>, 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.
///
/// <b>Règles de compatibilité</b>, à tenir tant que le numéro ne change pas :
/// <list type="bullet">
/// <item>on <b>ajoute</b> des champs, on n'en retire ni n'en renomme aucun ;</item>
/// <item>les valeurs d'enum s'ajoutent <b>en fin</b> — elles sont persistées en int
/// et un client plus ancien doit pouvoir ignorer ce qu'il ne connaît pas ;</item>
/// <item>un champ absent vaut « pas de valeur », jamais « valeur par défaut »
/// différente de celle du client.</item>
/// </list>
///
/// Documentation complète : <c>DOCS/export-contract.md</c>.
/// </summary>
public class ExportConfigurationDTO : ConfigurationDTO
{
/// <summary>
/// Version du <b>format</b>, 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 <c>ManifestReader</c> côté casque.
/// </summary>
public int exportVersion { get; set; } = CurrentExportVersion;
/// <summary>Quand ce JSON a été produit. Sert au diagnostic d'un cache périmé.</summary>
public DateTime generatedAt { get; set; }
public List<SectionDTO> sections { get; set; }
public List<ResourceDTO> resources { get; set; }
}
}
/// <summary>
/// <b>1</b> — 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.
/// </summary>
public const int CurrentExportVersion = 1;
}
}

View File

@ -0,0 +1,27 @@
using ManagerService.Data;
namespace ManagerService.DTOs
{
/// <summary>
/// Le fond immersif d'une visite ou du menu VR. Voir <see cref="ImmersiveBackground"/>
/// pour ce que porte chaque champ et pourquoi le repli n'est pas optionnel.
/// </summary>
public class ImmersiveBackgroundDTO
{
public string resourceId { get; set; }
public ImmersiveBackgroundKind kind { get; set; }
public string fallbackResourceId { get; set; }
/// <summary>
/// URL de la ressource, remplie à la lecture comme <c>imageSource</c>. 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.
/// </summary>
public string resourceUrl { get; set; }
/// <summary>URL du repli, remplie de la même façon.</summary>
public string fallbackUrl { get; set; }
}
}

View File

@ -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; }
/// <summary>Add-on « Contenu immersif » : 360 et modeles 3D.</summary>
public bool? hasImmersiveContent { get; set; }
public bool? isImageWatermark { get; set; }
public string? guideName { get; set; }

View File

@ -14,6 +14,13 @@
Agenda,
Weather,
Event,
Parcours
Parcours,
/// <summary>
/// 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é <b>en fin</b>,
/// jamais au milieu.
/// </summary>
Scene3D
}
}

View File

@ -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; }
/// <summary>Position sur une maquette 3D, nulle sur un point de carte.</summary>
public ManagerService.Data.SubSection.Position3D localTransform { get; set; }
}
public class CategorieDTO

View File

@ -0,0 +1,25 @@
using System.Collections.Generic;
using ManagerService.DTOs;
namespace Manager.DTOs
{
/// <summary>
/// Une maquette 3D et ses points d'intérêt — item <c>E7</c> du lot XR-4.
///
/// Le modèle arrive par un id de ressource (<c>ResourceType.Model3D</c>), 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.
/// </summary>
public class Scene3DDTO : SectionDTO
{
public string model3DResourceId { get; set; }
/// <summary>URL du modèle, remplie à la lecture comme <c>imageSource</c>.</summary>
public string model3DSource { get; set; }
/// <summary>Objet manipulé (0) ou décor habité (1).</summary>
public ManagerService.Data.SubSection.Scene3DMode mode { get; set; }
public List<GeoPointDTO> points { get; set; }
}
}

View File

@ -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 }
}

View File

@ -63,6 +63,12 @@ namespace ManagerService.Data
public string? PlayStoreUrl { get; set; } // Specific Mobile
/// <summary>
/// Fond du menu général — spécifique VR. C'est le pendant immersif de
/// <c>MainImageId</c> : sans lui, le menu du casque flotte dans le noir.
/// </summary>
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;
}

View File

@ -55,6 +55,12 @@ namespace ManagerService.Data
public bool IsSearchNumber { get; set; } // True if we want to have search box (number type), false otherwise
/// <summary>
/// Le fond de cette visite, null si elle n'en a pas. Voir
/// <see cref="Data.ImmersiveBackground"/> — le même type sert au menu VR.
/// </summary>
public ImmersiveBackground ImmersiveBackground { get; set; }
public ConfigurationDTO ToDTO(List<string> 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<SectionDTO> sections, List<ResourceDTO> 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()

View File

@ -7,7 +7,7 @@ using Microsoft.EntityFrameworkCore;
namespace ManagerService.Data
{
/// <summary>
/// Device Information (Tablet)
/// Device Information (tablette kiosk, casque VR, ...)
/// </summary>
[Index(nameof(InstanceId))]
public class Device : IAuditableEntity
@ -77,6 +77,12 @@ namespace ManagerService.Data
public DateTime? LastSeen { get; set; }
/// <summary>
/// Canal de l'appareil. Défaut <see cref="Data.AppType.Tablet"/> : jusqu'ici la table
/// n'était peuplée que par tablet-app, les lignes existantes ne doivent pas bouger.
/// </summary>
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;
}
}

View File

@ -0,0 +1,66 @@
using ManagerService.DTOs;
using Microsoft.EntityFrameworkCore;
namespace ManagerService.Data
{
/// <summary>
/// Le fond d'un lieu — §4 de <c>DOCS/v2/immersif-frontiere-plan.md</c>.
///
/// <b>Un type, deux porteurs.</b> Une <c>Configuration</c> le porte pour le fond d'une
/// visite, l'<c>ApplicationInstance</c> VR pour le fond du menu général. Poser un
/// <c>BackgroundResourceId</c> à plat sur chacun aurait donné quatre champs
/// « background » incohérents dans six mois — c'est le travers que ce type évite.
///
/// ⚠️ <b>Le repli n'est pas optionnel.</b> Trois canaux sur quatre ne savent pas rendre
/// un panorama : web le fait tourner, mobile et kiosk retombent sur une image plate.
/// Sans <c>FallbackResourceId</c>, activer un fond immersif rendrait l'écran noir
/// partout ailleurs que dans le casque, <i>en silence</i>. À défaut, c'est
/// <c>Configuration.ImageId</c> qui sert de repli.
/// </summary>
[Owned]
public class ImmersiveBackground
{
/// <summary>Ressource immersive : une 360, une vidéo 360 ou un GLB.</summary>
public string ResourceId { get; set; }
/// <summary>
/// ⚠️ Persisté en <b>int</b>, comme tous les enums du projet : on ajoute en fin,
/// on ne réordonne jamais.
/// </summary>
public ImmersiveBackgroundKind Kind { get; set; }
/// <summary>Image plate pour les canaux qui ne rendent pas l'immersif.</summary>
public string FallbackResourceId { get; set; }
public ImmersiveBackgroundDTO ToDTO() => new ImmersiveBackgroundDTO
{
resourceId = ResourceId,
kind = Kind,
fallbackResourceId = FallbackResourceId
};
/// <summary>
/// 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.
/// </summary>
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
}
}

View File

@ -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; }
/// <summary>
/// Add-on <b>« Contenu immersif »</b> : image 360, vidéo 360 et modèles 3D.
///
/// Un add-on est un <c>bool</c> sur l'instance — c'est déjà le patron d'
/// <see cref="IsAssistant"/>, et il tient parce que l'<c>Instance</c> recopie
/// localement les valeurs de son plan au lieu de les lire à travers : le
/// <c>SubscriptionPlan</c> n'est qu'un gabarit.
///
/// ⚠️ <b>Le stockage est le point de vigilance</b>, 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
/// <see cref="StorageQuotaBytes"/> (§6 de DOCS/v2/vr-quest-unity-plan.md).
/// </summary>
public bool HasImmersiveContent { get; set; }
/// <summary>
/// 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;

View File

@ -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<SectionVideo>("Video")
.HasValue<SectionWeather>("Weather")
.HasValue<SectionWeb>("Web")
.HasValue<SectionParcours>("Parcours");
.HasValue<SectionParcours>("Parcours")
.HasValue<SectionScene3D>("Model3D");
/*modelBuilder.Entity<GeoPoint>(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<GeoPoint>()
.HasOne(p => p.SectionScene3D)
.WithMany(s => s.Points)
.HasForeignKey(p => p.SectionScene3DId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Cascade);
// La position 3D est un petit objet, stocké tel quel : rien ne la requête, et
// trois colonnes de plus sur GeoPoint ne serviraient qu'aux maquettes.
modelBuilder.Entity<GeoPoint>()
.Property(p => p.LocalTransform)
.HasColumnType("jsonb")
.HasConversion(
v => JsonSerializer.Serialize(v, options),
v => JsonSerializer.Deserialize<Position3D>(v, options));
// MapAnnotation: global event-level annotations linked directly to SectionEvent
modelBuilder.Entity<MapAnnotation>()
.HasOne<SectionEvent>()
@ -564,6 +583,8 @@ namespace ManagerService.Data
.Property(i => i.StatsHistoryDays).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.HasAdvancedStats).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.HasImmersiveContent).ValueGeneratedNever();
modelBuilder.Entity<Instance>()
.Property(i => i.IsActive).ValueGeneratedNever();
modelBuilder.Entity<Instance>()

View File

@ -106,7 +106,22 @@ namespace ManagerService.Data
JSONUrl, // 7
Word, // 8
PowerPoint, // 9
Text // 10
Text, // 10
/// <summary>Photo équirectangulaire, affichée en skybox dans un casque.</summary>
Image360, // 11
/// <summary>Vidéo équirectangulaire, même usage.</summary>
Video360, // 12
/// <summary>
/// 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).
/// </summary>
Model3D // 13
}
public enum AiIndexStatus

View File

@ -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; }
/// <summary>
/// Position du point <b>sur un modèle 3D</b>, quand il en décore un — à côté du
/// <see cref="Geometry"/> 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.
/// </summary>
[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
};
}
}
/// <summary>
/// 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.
///
/// ⚠️ <b>Convention glTF</b>, celle du fichier, pas celle d'Unity : Y vers le haut,
/// <b>Z vers l'arrière</b>, main droite. La conversion vit dans un seul endroit côté
/// casque (<c>GltfSpace</c>), 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.
/// </summary>
public class Position3D
{
public float x { get; set; }
public float y { get; set; }
public float z { get; set; }
/// <summary>Rotation en degrés autour de Y. Sert à orienter un panneau vers l'allée.</summary>
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")
}

View File

@ -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
{
/// <summary>Objet manipulé ou décor habité — voir <see cref="SectionScene3D.Mode"/>.</summary>
public enum Scene3DMode
{
// ⚠️ Persisté en int : toute valeur nouvelle s'ajoute à la fin.
Asset = 0,
Scene = 1
}
/// <summary>
/// Une scène 3D : un modèle glTF et ses points d'intérêt — lot <b>XR-4</b>,
/// items <c>E5</c> (le décor) et <c>E7</c> (l'objet).
///
/// <b>Nommé « Scene3D » et pas « Model3D » alors qu'il ne porte aujourd'hui qu'un
/// modèle et des points</b> : 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.
///
/// <b>Pourquoi un type à part et pas une <see cref="SectionMap"/> augmentée</b> : 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.
///
/// <b>Ce qu'on réutilise en revanche : le <see cref="GeoPoint"/>.</b> Il portait déjà
/// deux rattachements (<c>SectionMapId</c>, <c>SectionEventId</c>) ; 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 <see cref="GeoPoint.LocalTransform"/> change de nature.
/// </summary>
public class SectionScene3D : Section
{
/// <summary>Ressource <c>Model3D</c> (glTF binaire) affichée par cette section.</summary>
public string Model3DResourceId { get; set; }
/// <summary>
/// Ce que le visiteur fait de cette scène — et c'est une opposition franche
/// (§4bis de <c>DOCS/v2/immersif-frontiere-plan.md</c>, arrêté le 11/09) :
///
/// <list type="bullet">
/// <item><b>Asset</b> — un objet posé devant lui, qu'il <b>manipule</b> : caméra
/// orbitale, on tourne autour, les points tournent avec l'objet. L'épée du
/// roi, une pièce de collection.</item>
/// <item><b>Scene</b> — un décor dans lequel il <b>est</b> : caméra fixe, on
/// regarde autour, les points restent où ils sont. Une salle reconstituée.</item>
/// </list>
///
/// 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.
/// </summary>
public Scene3DMode Mode { get; set; } = Scene3DMode.Asset;
/// <summary>
/// URL du modèle, dupliquée comme l'est <see cref="Section.ImageSource"/> à côté
/// de <see cref="Section.ImageId"/>. 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.
/// </summary>
public string Model3DSource { get; set; }
/// <summary>
/// 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.
/// </summary>
public List<GeoPoint> Points { get; set; }
public override string GetEmbeddableText(string language) =>
JoinText(new[] { BaseText(language) }
.Concat((Points ?? new List<GeoPoint>())
.Select(p => p.GetEmbeddableText(language)))
.ToArray());
/// <summary>
/// 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.
/// </summary>
public override IEnumerable<string> GetReferencedResourceIds(string language = null) =>
BaseResourceIds()
.Concat(ResourceId(Model3DResourceId))
.Concat((Points ?? new List<GeoPoint>())
.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<GeoPoint>()).Select(p => p.ToDTO()).ToList()
};
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddAppTypeToDevice : Migration
{
/// <inheritdoc />
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<int>(
name: "AppType",
table: "Devices",
type: "integer",
nullable: false,
defaultValue: 1);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AppType",
table: "Devices");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddScene3DSectionAndImmersiveAddon : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Mode",
table: "Sections",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Model3DResourceId",
table: "Sections",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Model3DSource",
table: "Sections",
type: "text",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "HasImmersiveContent",
table: "Instances",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "LocalTransform",
table: "GeoPoints",
type: "jsonb",
nullable: true);
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ManagerService.Migrations
{
/// <inheritdoc />
public partial class AddImmersiveBackground : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ImmersiveBackground_FallbackResourceId",
table: "Configurations",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImmersiveBackground_Kind",
table: "Configurations",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ImmersiveBackground_ResourceId",
table: "Configurations",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ImmersiveBackground_FallbackResourceId",
table: "ApplicationInstances",
type: "text",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ImmersiveBackground_Kind",
table: "ApplicationInstances",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ImmersiveBackground_ResourceId",
table: "ApplicationInstances",
type: "text",
nullable: true);
}
/// <inheritdoc />
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");
}
}
}

View File

@ -374,6 +374,9 @@ namespace ManagerService.Migrations
b.Property<string>("Id")
.HasColumnType("text");
b.Property<int>("AppType")
.HasColumnType("integer");
b.Property<string>("AppVersion")
.HasColumnType("text");
@ -471,6 +474,9 @@ namespace ManagerService.Migrations
b.Property<bool>("HasAdvancedStats")
.HasColumnType("boolean");
b.Property<bool>("HasImmersiveContent")
.HasColumnType("boolean");
b.Property<bool>("HasStats")
.HasColumnType("boolean");
@ -888,6 +894,9 @@ namespace ManagerService.Migrations
b.Property<string>("ImageUrl")
.HasColumnType("text");
b.Property<string>("LocalTransform")
.HasColumnType("jsonb");
b.Property<string>("Phone")
.IsRequired()
.HasColumnType("jsonb");
@ -909,6 +918,9 @@ namespace ManagerService.Migrations
b.Property<string>("SectionMapId")
.HasColumnType("text");
b.Property<string>("SectionScene3DId")
.HasColumnType("text");
b.Property<string>("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<int>("Mode")
.HasColumnType("integer");
b.Property<string>("Model3DResourceId")
.HasColumnType("text");
b.Property<string>("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<string>("ApplicationInstanceId")
.HasColumnType("text");
b1.Property<string>("FallbackResourceId")
.HasColumnType("text");
b1.Property<int>("Kind")
.HasColumnType("integer");
b1.Property<string>("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<string>("ConfigurationId")
.HasColumnType("text");
b1.Property<string>("FallbackResourceId")
.HasColumnType("text");
b1.Property<int>("Kind")
.HasColumnType("integer");
b1.Property<string>("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
}
}

View File

@ -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
{
/// <summary>
/// Exige <b>une clé d'API valide</b> (<c>X-Api-Key</c>) ou un utilisateur du manager
/// déjà authentifié. À poser sur les routes que les apps visiteur consomment.
///
/// <b>Pourquoi un filtre et pas <c>[Authorize(AppReadAccess)]</c>.</b> ASP.NET Core
/// <b>combine</b> les <c>[Authorize]</c> de la classe et de l'action : sur un
/// contrôleur qui exige <c>ContentEditor</c>, 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 <c>[AllowAnonymous]</c>
/// court-circuite la policy de classe. L'action garde donc son <c>[AllowAnonymous]</c>,
/// et <b>ce filtre redevient le contrôle d'accès</b>.
///
/// C'est exactement ce que <c>ConfigurationController.Export</c> faisait à la main
/// depuis le commit <c>9cc45c5</c> ; ceci le rend réutilisable au lieu de le recopier
/// vingt-quatre fois.
///
/// ⚠️ <b>Ce que ce filtre ne fait pas : le cloisonnement.</b> Il vérifie qu'une clé est
/// valide, pas qu'elle donne accès à <i>cette</i> 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 <c>Export</c> et
/// <c>InstanceController.GetDetail</c>). 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.
/// </summary>
[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 };
}
}
}

View File

@ -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<GuidedPath>() },
SectionType.Scene3D => new SectionScene3D { Points = new List<GeoPoint>() },
_ => 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<ParcoursDTO>(jsonElement.ToString());
break;
case SectionType.Scene3D:
model3DDTO = JsonConvert.DeserializeObject<Scene3DDTO>(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")
};
}