Thomas Fransolet b87e49d808 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>
2026-09-13 16:54:10 +02:00

238 lines
9.7 KiB
C#

using ManagerService.DTOs;
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace ManagerService.Data
{
/// <summary>
/// Instance Information
/// </summary>
public class Instance : IAuditableEntity
{
[Key]
[Required]
public string Id { get; set; }
[Required]
public string Name { get; set; } // UNIQUE !..
public DateTime DateCreation { get; set; }
public DateTime DateUpdate { get; set; }
public string PinCode { get; set; }
public bool IsPushNotification { get; set; }
public bool IsMobile { get; set; }
public bool IsTablet { get; set; }
public bool IsWeb { get; set; }
public bool IsVR { get; set; }
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.
/// </summary>
public bool IsImageWatermark { get; set; }
/// <summary>Nom du guide affiché au visiteur, libre. Ex: "Léon".</summary>
public string? GuideName { get; set; }
/// <summary>
/// Personnalité du guide : instruction envoyée au modèle, jamais affichée.
/// Écrite dans une seule langue — le modèle répond dans celle du visiteur.
/// </summary>
public string? GuidePersonaPrompt { get; set; }
/// <summary>Voix Gemini TTS : "Sulafat" (Viva, féminine) ou "Umbriel" (Marco, masculine).</summary>
public string? GuideVoiceId { get; set; }
/// <summary>
/// Le client autorise-t-il l'enregistrement des questions posées par ses visiteurs ?
///
/// ⚠️ **C'est lui le responsable de traitement**, pas nous : il doit pouvoir refuser
/// la collecte. Elle était inconditionnelle dès que l'assistant était actif.
///
/// Défaut à <c>true</c> : couvert par les CGU §8, et le désactiver par défaut viderait
/// l'onglet « Ce que demandent vos visiteurs » et le rapport de trous de contenu chez
/// tout le monde — une fonction qu'il paie, perdue sans l'avoir demandé.
///
/// ⚠️ Couper la collecte n'efface rien : les questions déjà enregistrées vivent
/// jusqu'à leur purge à 90 jours, et les agrégats de thèmes leur survivent.
/// </summary>
public bool IsVisitorQuestionCollectionEnabled { get; set; } = true;
/// <summary>
/// Formulations utilisées quand le guide ne trouve pas de réponse.
/// Liste à plat : plusieurs entrées peuvent partager la même langue,
/// une est tirée au hasard parmi celles de la langue du visiteur.
/// </summary>
public List<TranslationDTO>? GuideFallbackMessages { get; set; }
public string? WebSlug { get; set; }
public string? PublicApiKey { get; set; }
public string? SubscriptionPlanId { get; set; }
[ForeignKey("SubscriptionPlanId")]
public SubscriptionPlan? SubscriptionPlan { get; set; }
public long AiTokensThisMonth { get; set; } = 0;
public string AiUsageMonthKey { get; set; } = "";
public long StorageQuotaBytes { get; set; } = 0;
public long AiTokensPerMonth { get; set; } = 0;
public bool HasStats { get; set; } = false;
public int StatsHistoryDays { get; set; } = 30;
public bool HasAdvancedStats { get; set; } = false;
public bool IsActive { get; set; } = true;
public bool IsTrialActive { get; set; } = false;
public DateTime? TrialEndsAt { get; set; }
public long TrialAiTokensUsed { get; set; } = 0;
public bool TrialCheckInEmailSent { get; set; } = false;
public bool TrialReminderEmailSent { get; set; } = false;
public bool TrialLastDayEmailSent { get; set; } = false;
public string? BillingAddress { get; set; }
public string? BillingCountry { get; set; }
public string? VatNumber { get; set; }
public decimal? VatRate { get; set; }
public string? StripeCustomerId { get; set; }
public string? StripeSubscriptionId { get; set; }
public InstanceDTO ToDTO(List<ApplicationInstanceDTO> applicationInstanceDTOs)
{
return new InstanceDTO()
{
id = Id,
name = Name,
dateCreation = DateCreation,
pinCode = PinCode,
isPushNotification = IsPushNotification,
isMobile = IsMobile,
isTablet = IsTablet,
isWeb = IsWeb,
isVR = IsVR,
isAssistant = IsAssistant,
hasImmersiveContent = HasImmersiveContent,
isImageWatermark = IsImageWatermark,
guideName = GuideName,
guidePersonaPrompt = GuidePersonaPrompt,
guideVoiceId = GuideVoiceId,
isVisitorQuestionCollectionEnabled = IsVisitorQuestionCollectionEnabled,
guideFallbackMessages = GuideFallbackMessages,
webSlug = WebSlug,
publicApiKey = PublicApiKey,
subscriptionPlanId = SubscriptionPlanId,
subscriptionPlan = SubscriptionPlan?.ToDTO(),
aiTokensThisMonth = AiTokensThisMonth,
aiUsageMonthKey = AiUsageMonthKey,
storageQuotaBytes = StorageQuotaBytes,
aiTokensPerMonth = AiTokensPerMonth,
hasStats = HasStats,
statsHistoryDays = StatsHistoryDays,
hasAdvancedStats = HasAdvancedStats,
isTrialActive = IsTrialActive,
trialEndsAt = TrialEndsAt,
trialAiTokensUsed = TrialAiTokensUsed,
billingAddress = BillingAddress,
billingCountry = BillingCountry,
vatNumber = VatNumber,
vatRate = VatRate,
applicationInstanceDTOs = applicationInstanceDTOs
};
}
public Instance FromDTO(InstanceDTO instanceDTO)
{
Name = instanceDTO.name;
DateCreation = instanceDTO.dateCreation != null ? instanceDTO.dateCreation.Value : DateTime.Now.ToUniversalTime();
PinCode = instanceDTO.pinCode;
IsPushNotification = instanceDTO.isPushNotification ?? false;
IsMobile = instanceDTO.isMobile ?? false;
IsTablet = instanceDTO.isTablet ?? false;
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;
if (instanceDTO.guidePersonaPrompt != null)
GuidePersonaPrompt = instanceDTO.guidePersonaPrompt;
if (instanceDTO.guideVoiceId != null)
GuideVoiceId = instanceDTO.guideVoiceId;
if (instanceDTO.isVisitorQuestionCollectionEnabled != null)
IsVisitorQuestionCollectionEnabled = instanceDTO.isVisitorQuestionCollectionEnabled.Value;
if (instanceDTO.guideFallbackMessages != null)
GuideFallbackMessages = instanceDTO.guideFallbackMessages;
if (instanceDTO.webSlug != null)
WebSlug = instanceDTO.webSlug;
if (instanceDTO.publicApiKey != null)
PublicApiKey = instanceDTO.publicApiKey;
if (instanceDTO.subscriptionPlanId != null)
SubscriptionPlanId = instanceDTO.subscriptionPlanId;
if (instanceDTO.storageQuotaBytes.HasValue)
StorageQuotaBytes = instanceDTO.storageQuotaBytes.Value;
if (instanceDTO.aiTokensPerMonth.HasValue)
AiTokensPerMonth = instanceDTO.aiTokensPerMonth.Value;
if (instanceDTO.hasStats.HasValue)
HasStats = instanceDTO.hasStats.Value;
if (instanceDTO.statsHistoryDays.HasValue)
StatsHistoryDays = instanceDTO.statsHistoryDays.Value;
if (instanceDTO.hasAdvancedStats.HasValue)
HasAdvancedStats = instanceDTO.hasAdvancedStats.Value;
if (instanceDTO.billingAddress != null)
BillingAddress = instanceDTO.billingAddress;
if (instanceDTO.billingCountry != null)
BillingCountry = instanceDTO.billingCountry;
if (instanceDTO.vatNumber != null)
VatNumber = instanceDTO.vatNumber;
if (instanceDTO.vatRate.HasValue)
VatRate = instanceDTO.vatRate.Value;
return this;
}
}
}