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

665 lines
30 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Hangfire;
using Manager.Services;
using ManagerService.Data;
using ManagerService.DTOs;
using ManagerService.Helpers;
using ManagerService.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.EntityFrameworkCore;
using NSwag.Annotations;
namespace ManagerService.Controllers
{
[Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)]
[ApiController, Route("api/[controller]")]
[OpenApiTag("Instance", Description = "Instance management")]
public class InstanceController : ControllerBase
{
private readonly MyInfoMateDbContext _myInfoMateDbContext;
private InstanceDatabaseService _instanceService;
private UserDatabaseService _userService;
private readonly ILogger<InstanceController> _logger;
private readonly ProfileLogic _profileLogic;
private readonly ApiKeyDatabaseService _apiKeyService;
// Injecté plutôt qu'appelé via la façade statique BackgroundJob : celle-ci lève
// sans JobStorage.Current, donc dans tout test qui touche cet endpoint.
private readonly IBackgroundJobClient _jobs;
IHexIdGeneratorService idService = new HexIdGeneratorService();
public InstanceController(ILogger<InstanceController> logger, InstanceDatabaseService instanceService, UserDatabaseService userService, ProfileLogic profileLogic, MyInfoMateDbContext myInfoMateDbContext, ApiKeyDatabaseService apiKeyService, IBackgroundJobClient jobs)
{
_logger = logger;
_instanceService = instanceService;
_userService = userService;
_profileLogic = profileLogic;
_myInfoMateDbContext = myInfoMateDbContext;
_apiKeyService = apiKeyService;
_jobs = jobs;
}
/// <summary>
/// Get a list of instance
/// </summary>
[ProducesResponseType(typeof(List<Instance>), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet]
public ObjectResult Get()
{
try
{
//List<OldInstance> instances = _instanceService.GetAll();
List<Instance> instances = _myInfoMateDbContext.Instances.ToList();
return new OkObjectResult(instances);
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Get a specific instance
/// </summary>
/// <param name="id">id instance</param>
/// <remarks>
/// Ouverte aux apps visiteur par <c>X-Api-Key</c> : mymuseum-visitapp appelle cette
/// route au démarrage pour lire la voix du guide, et recevait un 403 — tout le
/// contrôleur exige SuperAdmin. Une clé API ne donne accès qu'à SON instance, et à
/// une vue réduite : plan, quotas, TVA, facturation et pinCode ne sortent que pour
/// un utilisateur du manager.
/// </remarks>
[AllowAnonymous]
[ProducesResponseType(typeof(InstanceDTO), 200)]
[ProducesResponseType(typeof(string), 401)]
[ProducesResponseType(typeof(string), 403)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{id}")]
public async Task<ObjectResult> GetDetail(string id)
{
try
{
// Le schéma ApiKey n'est pas le schéma par défaut : sur une action
// [AllowAnonymous] il faut le déclencher explicitement.
var apiKeyAuth = await HttpContext.AuthenticateAsync("ApiKey");
var keyInstanceId = apiKeyAuth.Succeeded
? apiKeyAuth.Principal?.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value
: null;
// ⚠️ Ne PAS déduire « utilisateur du manager » d'un claim de permission.
// AuthorizationMiddleware authentifie avec les schémas de la policy du
// contrôleur — JwtBearer ET ApiKey (Startup.cs:133) — et peuple
// HttpContext.User AVANT de court-circuiter sur [AllowAnonymous]. Une clé
// API donne donc un User authentifié, et le handler lui pose le claim
// Viewer : le test « a Viewer » était vrai pour tout le monde.
var isManager = !apiKeyAuth.Succeeded
&& User?.Identity?.IsAuthenticated == true
&& User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission,
ManagerService.Service.Security.Permissions.Viewer);
if (!isManager && keyInstanceId == null)
return new ObjectResult("Authentication required") { StatusCode = 401 };
if (!isManager && keyInstanceId != id)
return new ObjectResult("This API key does not grant access to this instance") { StatusCode = 403 };
Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == id);
//OldInstance instance = _instanceService.GetById(id);
if (instance == null)
throw new KeyNotFoundException("This instance was not found");
var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList();
var dto = instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList());
if (!isManager)
StripCommercialFields(dto);
return new OkObjectResult(dto);
}
catch (KeyNotFoundException ex)
{
return new NotFoundObjectResult(ex.Message) {};
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Recopie sur l'instance les valeurs portées par son plan. Elles sont dupliquées
/// volontairement — un client peut recevoir un geste commercial sans changer de plan —
/// mais elles doivent repartir du plan à chaque changement, sinon l'instance garde
/// les quotas de l'ancien.
/// </summary>
private void ApplyPlanQuotas(Instance instance)
{
if (instance.SubscriptionPlanId == null)
return;
var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId);
if (plan == null)
return;
instance.StorageQuotaBytes = plan.StorageQuotaBytes;
instance.AiTokensPerMonth = plan.AiTokensPerMonth;
instance.HasStats = plan.HasStats;
instance.StatsHistoryDays = plan.StatsHistoryDays;
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>
/// <param name="newInstance">New instance info</param>
//[AllowAnonymous]
[ProducesResponseType(typeof(InstanceDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 409)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost]
public ObjectResult CreateInstance([FromBody] InstanceDTO newInstance)
{
try
{
if (newInstance == null)
throw new ArgumentNullException("instance param is null");
Instance instance = new Instance().FromDTO(newInstance);
instance.DateCreation = DateTime.Now.ToUniversalTime();
instance.Id = idService.GenerateHexId();
// Copier les valeurs du plan comme valeurs par défaut
ApplyPlanQuotas(instance);
/*List<OldInstance> instances = _instanceService.GetAll();
Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == id);*/
if (_myInfoMateDbContext.Instances.Any(i => i.Name == instance.Name))
throw new InvalidOperationException("This name is already used");
instance.WebSlug = SlugHelper.GenerateUniqueSlug(_myInfoMateDbContext, instance.Name);
instance.PublicApiKey = "ap_" + Convert.ToBase64String(
System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
_myInfoMateDbContext.Instances.Add(instance);
_myInfoMateDbContext.SaveChanges();
var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList();
return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()));
}
catch (ArgumentNullException ex)
{
return new BadRequestObjectResult(ex.Message) {};
}
catch (InvalidOperationException ex)
{
return new ConflictObjectResult(ex.Message) {};
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Update an instance
/// </summary>
/// <param name="updatedinstance">instance to update</param>
[ProducesResponseType(typeof(InstanceDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPut]
public ObjectResult Updateinstance([FromBody] InstanceDTO updatedInstance)
{
try
{
if (updatedInstance == null)
throw new ArgumentNullException("instance param is null");
Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == updatedInstance.id);
//OldInstance instance = _instanceService.GetById(updatedInstance.Id);
if (instance == null)
throw new KeyNotFoundException("instance does not exist");
instance.DateCreation = updatedInstance.dateCreation ?? instance.DateCreation;
instance.Name = updatedInstance.name ?? instance.Name;
instance.PinCode = updatedInstance.pinCode ?? instance.PinCode;
instance.IsPushNotification = updatedInstance.isPushNotification ?? instance.IsPushNotification;
instance.IsMobile = updatedInstance.isMobile ?? instance.IsMobile;
instance.IsTablet = updatedInstance.isTablet ?? instance.IsTablet;
instance.IsWeb = updatedInstance.isWeb ?? instance.IsWeb;
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)
instance.GuidePersonaPrompt = updatedInstance.guidePersonaPrompt;
if (updatedInstance.guideVoiceId != null)
instance.GuideVoiceId = updatedInstance.guideVoiceId;
if (updatedInstance.isVisitorQuestionCollectionEnabled != null)
instance.IsVisitorQuestionCollectionEnabled = updatedInstance.isVisitorQuestionCollectionEnabled.Value;
if (updatedInstance.guideFallbackMessages != null)
instance.GuideFallbackMessages = updatedInstance.guideFallbackMessages;
var previousPlanId = instance.SubscriptionPlanId;
var previousAiTokens = instance.AiTokensPerMonth;
if (updatedInstance.subscriptionPlanId == "")
instance.SubscriptionPlanId = null;
else if (updatedInstance.subscriptionPlanId != null)
instance.SubscriptionPlanId = updatedInstance.subscriptionPlanId;
// CreateInstance recopie les valeurs du plan, pas Update : changer un client de
// Starter à Premium ne lui donnait donc ni stockage ni jetons IA supplémentaires.
// 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.
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();
// Le contenu déjà créé n'a jamais été indexé tant que l'instance n'avait pas
// droit à l'IA : sans ce rattrapage, le client paie un guide qui ne connaît rien.
if (previousAiTokens <= 0 && instance.AiTokensPerMonth > 0)
{
var backfilledInstanceId = instance.Id;
_jobs.Enqueue<IIngestionService>(s => s.BackfillInstanceAsync(backfilledInstanceId));
}
var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList();
return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()));
}
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>
/// Get Instance by web slug (public, used by visitapp-web)
/// </summary>
/// <param name="slug">Web slug of the instance</param>
[AllowAnonymous]
[ProducesResponseType(typeof(InstanceDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("slug/{slug}")]
public ObjectResult GetInstanceBySlug(string slug)
{
try
{
Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.WebSlug == slug);
if (instance == null)
throw new KeyNotFoundException("Instance was not found");
var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).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)
{
return new NotFoundObjectResult(ex.Message) { };
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Get Instance by pincode
/// </summary>
/// <param name="pinCode">Code pin</param>
[AllowAnonymous]
[ProducesResponseType(typeof(InstanceDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("byPin")]
public ObjectResult GetInstanceByPinCode([FromQuery] string pinCode)
{
try
{
//OldInstance instance = _instanceService.GetByPinCode(pinCode);
Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.PinCode == pinCode);
if (instance == null)
throw new KeyNotFoundException("Instance was not found");
var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).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)
{
return new NotFoundObjectResult(ex.Message) { };
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Bootstrap: get (or create) an API key for a Flutter app by PIN code
/// </summary>
/// <param name="pinCode">Instance PIN code</param>
/// <param name="appType">App type (VisitApp, TabletApp, Other)</param>
/// <summary>
/// Retire d'un <see cref="InstanceDTO"/> tout ce qui ne regarde pas une app visiteur.
///
/// Ce que l'app garde : identité, drapeaux de canal, réglages du guide, webSlug,
/// publicApiKey (elle la détient déjà pour appeler) et les ApplicationInstances.
/// Ce qui part : le commercial et le pinCode, qui ouvre l'appairage des tablettes.
/// </summary>
private static void StripCommercialFields(InstanceDTO dto)
{
dto.pinCode = null;
dto.subscriptionPlanId = null;
dto.subscriptionPlan = null;
dto.aiTokensThisMonth = null;
dto.aiUsageMonthKey = null;
dto.storageQuotaBytes = null;
dto.aiTokensPerMonth = null;
dto.hasStats = null;
dto.statsHistoryDays = null;
dto.hasAdvancedStats = null;
dto.isTrialActive = null;
dto.trialEndsAt = null;
dto.trialAiTokensUsed = null;
dto.billingAddress = null;
dto.billingCountry = null;
dto.vatNumber = null;
dto.vatRate = null;
}
[AllowAnonymous]
[ProducesResponseType(typeof(object), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("app-key")]
public async Task<ObjectResult> GetAppKeyByPin([FromQuery] string pinCode, [FromQuery] ApiKeyAppType appType)
{
try
{
var instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.PinCode == pinCode);
if (instance == null)
return new NotFoundObjectResult("Instance not found");
var key = await _apiKeyService.GetOrCreateByPinAsync(instance.Id, appType);
return new OkObjectResult(new { key, instanceId = instance.Id });
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
/// <summary>
/// Get quota usage for an instance
/// </summary>
/// <param name="id">Id instance</param>
[Authorize(Policy = ManagerService.Service.Security.Policies.Viewer)]
[ProducesResponseType(typeof(InstanceQuotaDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{id}/quota")]
public ObjectResult GetQuota(string id)
{
try
{
var instance = _myInfoMateDbContext.Instances
.FirstOrDefault(i => i.Id == id);
if (instance == null)
return new NotFoundObjectResult("Instance not found");
var storageUsed = _myInfoMateDbContext.Resources
.Where(r => r.InstanceId == id)
.Sum(r => (long?)r.SizeBytes) ?? 0;
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
var aiUsed = instance.AiUsageMonthKey == monthKey ? instance.AiTokensThisMonth : 0;
var storageQuota = instance.StorageQuotaBytes;
var aiQuota = instance.AiTokensPerMonth;
if ((storageQuota == 0 || aiQuota == 0) && instance.SubscriptionPlanId != null)
{
var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId);
if (plan != null)
{
// Même résolveur que le pré-vol de ResourceController : le chiffre
// affiché ici et celui qui bloque un téléversement doivent être le même.
storageQuota = StorageQuota.Resolve(storageQuota, plan.StorageQuotaBytes);
if (aiQuota == 0) aiQuota = plan.AiTokensPerMonth;
}
}
return new OkObjectResult(new InstanceQuotaDTO
{
storageUsedBytes = storageUsed,
storageQuotaBytes = storageQuota,
aiTokensUsed = aiUsed,
aiTokensPerMonth = aiQuota,
aiTokensPerQuestion = ResolveTokensPerQuestion(id)
});
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
private const long DefaultAiTokensPerQuestion = 10_000;
private const int MinQuestionSampleSize = 20;
/// <summary>
/// Coût moyen d'une question, en jetons. Mesuré sur les questions réellement posées par
/// l'instance ; à défaut, l'hypothèse de la grille tarifaire (Premium = 20 M de jetons
/// pour ~2 000 questions, cf. le seed de MyInfoMateDbContext).
///
/// ⚠️ Le seuil d'échantillon n'est pas de la prudence gratuite : une seule question dont
/// la réponse cite un long article suffirait à doubler la moyenne, et le gestionnaire
/// verrait son crédit restant changer de moitié d'un rafraîchissement à l'autre.
/// </summary>
private long ResolveTokensPerQuestion(string instanceId)
{
var sample = _myInfoMateDbContext.VisitorQuestions
.Where(q => q.InstanceId == instanceId && q.TokensUsed > 0)
.Select(q => q.TokensUsed)
.ToList();
if (sample.Count < MinQuestionSampleSize)
return DefaultAiTokensPerQuestion;
return (long)Math.Round(sample.Average());
}
/// <summary>
/// Delete an instance
/// </summary>
/// <param name="id">Id of instance to delete</param>
[ProducesResponseType(typeof(string), 202)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpDelete("{id}")]
public ObjectResult DeleteInstance(string id)
{
try
{
if (id == null)
throw new ArgumentNullException("instance param is null");
//OldInstance instance = _instanceService.GetById(id);
Instance instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
throw new KeyNotFoundException("instance does not exist");
// Delete all user in instance
//List<OldUser> users = _userService.GetByInstanceId(instance.Id);
List<User> users = _myInfoMateDbContext.Users.Where(u => u.InstanceId == instance.Id).ToList();
foreach (var user in users)
{
//_userService.Remove(user.Id);
_myInfoMateDbContext.Users.Remove(user);
}
//_instanceService.Remove(id);
_myInfoMateDbContext.Instances.Remove(instance);
_myInfoMateDbContext.SaveChanges();
return new ObjectResult("The instance has been deleted") { StatusCode = 202 };
}
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>
/// Generate (or regenerate) WebSlug and PublicApiKey for an existing instance
/// </summary>
/// <param name="id">Id of the instance</param>
[ProducesResponseType(typeof(InstanceDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
[HttpPost("{id}/generate-web-keys")]
public ObjectResult GenerateWebKeys(string id)
{
try
{
var instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == id);
if (instance == null)
return new NotFoundObjectResult("Instance not found");
if (string.IsNullOrEmpty(instance.WebSlug))
instance.WebSlug = SlugHelper.GenerateUniqueSlug(_myInfoMateDbContext, instance.Name);
instance.PublicApiKey = "ap_" + Convert.ToBase64String(
System.Security.Cryptography.RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
_myInfoMateDbContext.SaveChanges();
var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList();
return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()));
}
catch (Exception ex)
{
return new ObjectResult(ex.Message) { StatusCode = 500 };
}
}
}
}