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;