mymuseum-visitapp appelle GET /api/Instance/{id} au démarrage pour lire la voix
du guide, et recevait un 403 : tout InstanceController porte [Authorize(SuperAdmin)]
et GetDetail n'avait pas d'exception, contrairement à slug, byPin et app-key.
Une clé API donne désormais accès à SON instance seulement — clé croisée = 403 —
et à une vue réduite. StripCommercialFields retire le plan, les quotas, l'usage
IA, l'essai, la TVA, la facturation et le pinCode, qui ouvre l'appairage des
tablettes. Un utilisateur du manager continue de tout voir.
⚠️ Le test « est-ce un utilisateur du manager » ne peut PAS se baser sur un claim
de permission. AuthorizationMiddleware authentifie avec les schémas de la policy
du contrôleur — JwtBearer ET ApiKey — et peuple HttpContext.User AVANT de
court-circuiter sur [AllowAnonymous]. Une clé API produit donc un User
authentifié auquel le handler pose le claim Viewer : la première version du
correctif laissait passer tout le monde, et ne se voyait pas sur une instance
dont les champs sensibles sont naturellement nuls. Le test porte donc sur le
schéma d'authentification.
Vérifié sur la préprod, cinq cas : 401 sans clé, 200 sur sa propre instance,
403 en croisé dans les deux sens, 200 pour le manager avec le DTO complet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
601 lines
26 KiB
C#
601 lines
26 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>
|
|
/// 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;
|
|
|
|
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.
|
|
if (instance.SubscriptionPlanId != previousPlanId)
|
|
ApplyPlanQuotas(instance);
|
|
|
|
//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();
|
|
|
|
return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()));
|
|
}
|
|
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();
|
|
|
|
return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()));
|
|
}
|
|
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 };
|
|
}
|
|
}
|
|
}
|
|
}
|