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.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 _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 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; } /// /// Get a list of instance /// [ProducesResponseType(typeof(List), 200)] [ProducesResponseType(typeof(string), 500)] [HttpGet] public ObjectResult Get() { try { //List instances = _instanceService.GetAll(); List instances = _myInfoMateDbContext.Instances.ToList(); return new OkObjectResult(instances); } catch (Exception ex) { return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Get a specific instance /// /// id instance [ProducesResponseType(typeof(InstanceDTO), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpGet("{id}")] public ObjectResult GetDetail(string id) { try { 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(); 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 }; } } /// /// 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. /// 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; } /// /// Create an instance /// /// New instance info //[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 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 }; } } /// /// Update an instance /// /// instance to update [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 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(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 }; } } /// /// Get Instance by web slug (public, used by visitapp-web) /// /// Web slug of the instance [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 }; } } /// /// Get Instance by pincode /// /// Code pin [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 }; } } /// /// Bootstrap: get (or create) an API key for a Flutter app by PIN code /// /// Instance PIN code /// App type (VisitApp, TabletApp, Other) [AllowAnonymous] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpGet("app-key")] public async Task 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 }; } } /// /// Get quota usage for an instance /// /// Id instance [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; /// /// 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. /// 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()); } /// /// Delete an instance /// /// Id of instance to delete [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 users = _userService.GetByInstanceId(instance.Id); List 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 }; } } /// /// Generate (or regenerate) WebSlug and PublicApiKey for an existing instance /// /// Id of the instance [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 }; } } } }