diff --git a/ManagerService/Controllers/MigrationController.cs b/ManagerService/Controllers/MigrationController.cs index a22e4d0..c8c37da 100644 --- a/ManagerService/Controllers/MigrationController.cs +++ b/ManagerService/Controllers/MigrationController.cs @@ -3,6 +3,7 @@ using Manager.Services; using ManagerService.Data; using ManagerService.Data.SubSection; using ManagerService.DTOs; +using ManagerService.Helpers; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -12,6 +13,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; +using System.Security.Cryptography; using System.Text.Json; using System.Threading.Tasks; @@ -65,6 +67,13 @@ namespace ManagerService.Controllers { var report = new MigrationReportDTO { DryRun = dryRun }; + // Écart (h). Neuf étapes, un SaveChanges chacune : un échec au milieu + // laissait la base à moitié remplie — des sections sans configuration, des + // configurations sans instance. Une transaction unique rend l'échec propre : + // soit tout est là, soit rien ne l'est, et on relance après correction. + // En dryRun rien n'est écrit, donc pas de transaction à ouvrir. + var transaction = dryRun ? null : await _db.Database.BeginTransactionAsync(); + try { await MigrateInstancesAsync(report, dryRun, instanceId); @@ -76,10 +85,26 @@ namespace ManagerService.Controllers await MigrateDevicesAsync(report, dryRun, instanceId); await LinkMenuSectionsAsync(report, dryRun); await EnrichPdfResourcesAsync(report, dryRun); + + if (transaction != null) + await transaction.CommitAsync(); } catch (Exception ex) { + if (transaction != null) + await transaction.RollbackAsync(); + report.FatalError = ex.Message; + + // Renvoyer 200 OK sur un échec fatal était le piège : un script de + // bascule qui teste le code HTTP concluait que tout s'était bien passé, + // l'erreur n'étant que dans le corps de la réponse. + return new ObjectResult(report) { StatusCode = 500 }; + } + finally + { + if (transaction != null) + await transaction.DisposeAsync(); } return new OkObjectResult(report); @@ -103,14 +128,36 @@ namespace ManagerService.Controllers continue; } + // Mongo ne contient que ces 4 champs — vérifié dans l'export : les ~31 + // autres colonnes sont nées avec Postgres v3. Il n'y a donc rien à + // mapper de plus, il faut générer, dériver et affecter. Sans ça + // l'instance arrive muette : PublicApiKey nul = les apps visiteur ne + // s'authentifient plus, plan nul = quotas à 0 = rien ne s'indexe. var entity = new Instance { Id = old.Id, Name = old.Name, DateCreation = old.DateCreation, PinCode = old.PinCode?.ToString(), + WebSlug = SlugHelper.GenerateUniqueSlug(_db, old.Name), + PublicApiKey = GeneratePublicApiKey(), + SubscriptionPlanId = PlanForInstance(old.Name), + IsActive = true, }; + // Les drapeaux de plateforme se déduisent des configurations, comme le + // fait déjà MigrateApplicationInstancesAsync pour créer les app + // instances. IsWeb reste faux : le canal web n'existait pas avant la + // bascule, c'est un réglage d'après. + var configs = _configSvc.GetAll(old.Id); + entity.IsTablet = configs.Any(c => c.IsTablet); + entity.IsMobile = configs.Any(c => c.IsMobile); + + ApplyPlanQuotas(entity); + // L'assistant ne s'allume que si le plan lui donne des jetons — + // sinon le client verrait une entrée de menu qui ne répond jamais. + entity.IsAssistant = entity.AiTokensPerMonth > 0; + if (!dryRun) _db.Instances.Add(entity); @@ -126,6 +173,50 @@ namespace ManagerService.Controllers if (!dryRun) await _db.SaveChangesAsync(); } + /// + /// Même schéma que l'inscription self-service (`OnboardingController`) : préfixe + /// `ap_`, 32 octets aléatoires cryptographiques, encodés en base64 URL-safe. + /// + private static string GeneratePublicApiKey() => + "ap_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)) + .Replace("+", "-").Replace("/", "_").TrimEnd('='); + + /// + /// Affectation décidée le 2026-08-11 pour les 4 clients existants — aucun n'est + /// sur Essentiel. Le rapprochement se fait par nom parce que c'est la seule + /// donnée lisible de l'export Mongo ; un id inconnu retombe sur Pro, le plan + /// des clients avec app mobile, jamais sur un plan qui offrirait l'IA. + /// ⚠️ Pro n'inclut pas l'assistant : MDLF et le Fort n'auront pas d'IA. Pour les + /// tests, soit les passer en Premium, soit leur poser un quota à la main. + /// + private static string PlanForInstance(string name) + { + if (name == null) return "plan-pro"; + if (name.Contains("MyInfoMate", StringComparison.OrdinalIgnoreCase)) return "plan-premium"; + if (name.Contains("VisitNamur", StringComparison.OrdinalIgnoreCase)) return "plan-premium"; + return "plan-pro"; + } + + /// + /// Recopie les quotas du plan sur l'instance. Duplique volontairement la logique + /// d'`InstanceController.ApplyPlanQuotas` plutôt que d'en dépendre : la migration + /// ne doit pas casser si ce contrôleur change de forme. Les deux doivent rester + /// alignées — voir l'écart (c) du §1quinquies. + /// + private void ApplyPlanQuotas(Instance instance) + { + if (instance.SubscriptionPlanId == null) return; + + var plan = _db.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; + } + // ─── Resources ──────────────────────────────────────────────────────── private async Task MigrateResourcesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId) @@ -140,6 +231,7 @@ namespace ManagerService.Controllers httpClient.Timeout = TimeSpan.FromSeconds(10); var sizemap = new System.Collections.Concurrent.ConcurrentDictionary(); + var unsized = new System.Collections.Concurrent.ConcurrentDictionary(); var urlSource = source.Where(r => !string.IsNullOrEmpty(r.Url)).ToList(); const int batchSize = 30; @@ -155,7 +247,13 @@ namespace ManagerService.Controllers if (resp.Content.Headers.ContentLength.HasValue) sizemap[r.Id] = resp.Content.Headers.ContentLength.Value; } - catch { /* URL inaccessible ou expirée → SizeBytes restera 0 */ } + catch + { + // L'échec était avalé sans laisser de trace : la ressource + // arrivait à 0 octet et le quota de stockage la comptait pour + // rien, sans que personne puisse savoir lesquelles. On le note. + unsized[r.Id] = true; + } })); } @@ -177,12 +275,20 @@ namespace ManagerService.Controllers DateCreation = old.DateCreation, InstanceId = old.InstanceId, Url = old.Url, - SizeBytes = sizemap.TryGetValue(old.Id, out var size) ? size : 0, }; + // Écart (e) : StoragePath n'était pas renseigné du tout, et SizeBytes + // l'était à la main. Les deux passent par le même calculateur que la + // création et le backfill (lien L5) — sinon les trois divergent sur + // les types URL, qui n'ont ni blob ni poids à compter dans le quota. + ResourceStorage.Apply(entity, sizemap.TryGetValue(old.Id, out var size) ? size : 0); + if (!dryRun) _db.Resources.Add(entity); + if (unsized.ContainsKey(old.Id) && ResourceStorage.HasBlob(entity.Type)) + report.Errors.Add($"Resource {old.Id} ({old.Label}) : taille inconnue (HEAD en échec), SizeBytes à 0 — le quota de stockage la comptera pour rien"); + report.Migrated.Resources++; } catch (Exception ex) @@ -222,7 +328,15 @@ namespace ManagerService.Controllers Token = old.Token, DateCreation = old.DateCreation, InstanceId = old.InstanceId, - Role = UserRole.ContentEditor, + // Mongo n'a pas de champ Role : le rôle est une notion née avec + // Postgres v3. Tous les utilisateurs y avaient donc un accès + // complet. `ContentEditor` était un choix par défaut qui les + // dégradait tous en silence — plus personne n'aurait pu gérer les + // utilisateurs de son instance après la bascule. + // `InstanceAdmin` préserve ce qu'ils pouvaient faire hier, et + // c'est aussi le rôle que l'inscription self-service donne au + // premier utilisateur d'une instance. + Role = UserRole.InstanceAdmin, }; if (!dryRun) @@ -523,7 +637,13 @@ namespace ManagerService.Controllers var dto = ParseData(old, opts, report); var s = new SectionQuiz { - QuizQuestions = new List(), + // Écart (b). `new List()` jetait les questions : + // mesuré sur l'export, 5 sections quiz en portent **41**. Un quiz + // arrivait donc sans une seule question, et rien ne le signalait. + // Les agendas, eux, n'ont pas d'événements dans Mongo et les + // parcours guidés n'y existent pas : leurs collections filles + // vides sont correctes, celle-ci ne l'était pas. + QuizQuestions = BuildQuizQuestions(dto?.questions), QuizBadLevel = dto?.bad_level?.label ?? new List(), QuizMediumLevel = dto?.medium_level?.label ?? new List(), QuizGoodLevel = dto?.good_level?.label ?? new List(), @@ -613,6 +733,39 @@ namespace ManagerService.Controllers } } + /// + /// Écart (b) : les questions de quiz de Mongo, jusqu'ici perdues. Le type de + /// validation n'existait pas dans l'ancien modèle — toutes les questions y étaient + /// à choix multiples — d'où `MultipleChoice`, qui préserve le comportement d'hier + /// plutôt que de retomber sur le défaut `Simple` du modèle. + /// + private static List BuildQuizQuestions(List old) + { + var questions = new List(); + if (old == null) return questions; + + foreach (var q in old) + { + questions.Add(new QuizQuestion + { + Label = q.label ?? new List(), + ResourceId = q.imageBackgroundResourceId, + Order = q.order, + ValidationQuestionType = QuestionType.MultipleChoice, + Responses = (q.responses ?? new List()) + .Select(r => new ResponseDTO + { + label = r.label ?? new List(), + isGood = r.isGood, + order = r.order, + }) + .ToList(), + }); + } + + return questions; + } + private GeoPoint BuildGeoPoint(OldGeoPointDTO p, string sectionMapId) { Geometry geometry = null;