using Manager.DTOs; 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; using NetTopologySuite.Geometries; using NSwag.Annotations; 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; namespace ManagerService.Controllers { [Authorize(Policy = ManagerService.Service.Security.Policies.SuperAdmin)] [ApiController, Route("api/[controller]")] [OpenApiTag("Migration", Description = "MongoDB → PostgreSQL data migration")] public class MigrationController : ControllerBase { private readonly MyInfoMateDbContext _db; private readonly InstanceDatabaseService _instanceSvc; private readonly ConfigurationDatabaseService _configSvc; private readonly SectionDatabaseService _sectionSvc; private readonly ResourceDatabaseService _resourceSvc; private readonly UserDatabaseService _userSvc; private readonly DeviceDatabaseService _deviceSvc; private readonly IHttpClientFactory _httpClientFactory; public MigrationController( MyInfoMateDbContext db, InstanceDatabaseService instanceSvc, ConfigurationDatabaseService configSvc, SectionDatabaseService sectionSvc, ResourceDatabaseService resourceSvc, UserDatabaseService userSvc, DeviceDatabaseService deviceSvc, IHttpClientFactory httpClientFactory) { _db = db; _instanceSvc = instanceSvc; _configSvc = configSvc; _sectionSvc = sectionSvc; _resourceSvc = resourceSvc; _userSvc = userSvc; _deviceSvc = deviceSvc; _httpClientFactory = httpClientFactory; } /// /// Run (or simulate) the MongoDB → PostgreSQL migration. /// Use dryRun=true to validate without writing anything to the database. /// Use instanceId to restrict to a single instance. /// [HttpPost("run")] [ProducesResponseType(typeof(MigrationReportDTO), 200)] [ProducesResponseType(typeof(string), 500)] public async Task Run( [FromQuery] bool dryRun = false, [FromQuery] string instanceId = null) { 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); await MigrateResourcesAsync(report, dryRun, instanceId); await MigrateUsersAsync(report, dryRun, instanceId); await MigrateConfigurationsAsync(report, dryRun, instanceId); await MigrateApplicationInstancesAsync(report, dryRun, instanceId); await MigrateSectionsAsync(report, dryRun, instanceId); 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); } // ─── Instances ──────────────────────────────────────────────────────── private async Task MigrateInstancesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId) { var source = _instanceSvc.GetAll(); if (filterInstanceId != null) source = source.Where(i => i.Id == filterInstanceId).ToList(); foreach (var old in source) { try { if (await _db.Instances.AnyAsync(x => x.Id == old.Id)) { report.Skipped.Add($"Instance {old.Id} ({old.Name}) — already exists"); 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); report.Migrated.Instances++; report.Details.Add($"Instance {old.Id} ({old.Name})"); } catch (Exception ex) { report.Errors.Add($"Instance {old.Id}: {ex.Message}"); } } 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) { var instanceIds = filterInstanceId != null ? new List { filterInstanceId } : _instanceSvc.GetAll().Select(i => i.Id).ToList(); var source = instanceIds.SelectMany(id => _resourceSvc.GetAll(id)).ToList(); // Sondage des tailles par HEAD, extrait dans ResourceSizeProbe : le backfill // des lignes existantes (C2) en a besoin à l'identique, et deux copies // divergeraient sur ce qui compte — le sort réservé aux échecs. // L'échec était avalé sans laisser de trace : la ressource arrivait à // 0 octet et le quota la comptait pour rien, sans que personne puisse // savoir lesquelles. La sonde les signale, y compris le cas ajouté à // l'extraction : une réponse sans Content-Length, qu'aucun catch n'attrapait. var httpClient = _httpClientFactory.CreateClient(); httpClient.Timeout = TimeSpan.FromSeconds(10); var probe = await ResourceSizeProbe.ProbeAsync( httpClient, source.Select(r => (r.Id, r.Url))); foreach (var old in source) { try { if (await _db.Resources.AnyAsync(x => x.Id == old.Id)) { report.Skipped.Add($"Resource {old.Id} ({old.Label}) — already exists"); continue; } var entity = new Resource { Id = old.Id, Type = old.Type, Label = old.Label, DateCreation = old.DateCreation, InstanceId = old.InstanceId, Url = old.Url, }; // É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, probe.TryGetSize(old.Id, out var size) ? size : 0); if (!dryRun) _db.Resources.Add(entity); if (probe.IsUnsized(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) { report.Errors.Add($"Resource {old.Id}: {ex.Message}"); } } if (!dryRun) await _db.SaveChangesAsync(); } // ─── Users ──────────────────────────────────────────────────────────── private async Task MigrateUsersAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId) { var source = _userSvc.GetAll(); if (filterInstanceId != null) source = source.Where(u => u.InstanceId == filterInstanceId).ToList(); foreach (var old in source) { try { if (await _db.Users.AnyAsync(x => x.Id == old.Id)) { report.Skipped.Add($"User {old.Id} ({old.Email}) — already exists"); continue; } var entity = new User { Id = old.Id, Email = old.Email, Password = old.Password, FirstName = old.FirstName, // Mongo n'impose rien sur le nom, Postgres le veut NOT NULL : // `test@email.be` a LastName null et faisait échouer tout le // lot au SaveChanges. Le dry run ne peut pas voir ce défaut — // il n'écrit rien, donc aucune contrainte n'est évaluée. LastName = old.LastName ?? string.Empty, Token = old.Token, DateCreation = old.DateCreation, InstanceId = old.InstanceId, // 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) _db.Users.Add(entity); report.Migrated.Users++; } catch (Exception ex) { report.Errors.Add($"User {old.Id}: {ex.Message}"); } } if (!dryRun) await _db.SaveChangesAsync(); } // ─── Configurations ─────────────────────────────────────────────────── private async Task MigrateConfigurationsAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId) { var instanceIds = filterInstanceId != null ? new List { filterInstanceId } : _instanceSvc.GetAll().Select(i => i.Id).ToList(); var source = instanceIds.SelectMany(id => _configSvc.GetAll(id)).ToList(); foreach (var old in source) { try { if (await _db.Configurations.AnyAsync(x => x.Id == old.Id)) { report.Skipped.Add($"Configuration {old.Id} ({old.Label}) — already exists"); continue; } var entity = new Configuration { Id = old.Id, InstanceId = old.InstanceId, Label = old.Label, Title = old.Title ?? new List(), ImageId = old.ImageId, ImageSource = old.ImageSource, PrimaryColor = old.PrimaryColor, SecondaryColor = old.SecondaryColor, Languages = old.Languages ?? new List(), DateCreation = old.DateCreation, IsOffline = old.IsOffline, LoaderImageId = old.LoaderImageId, LoaderImageUrl = old.LoaderImageUrl, IsQRCode = false, IsSearchText = false, IsSearchNumber = false, }; if (!dryRun) _db.Configurations.Add(entity); report.Migrated.Configurations++; } catch (Exception ex) { report.Errors.Add($"Configuration {old.Id}: {ex.Message}"); } } if (!dryRun) await _db.SaveChangesAsync(); } // ─── ApplicationInstances + AppConfigurationLinks ──────────────────── private async Task MigrateApplicationInstancesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId) { var instanceIds = filterInstanceId != null ? new List { filterInstanceId } : _instanceSvc.GetAll().Select(i => i.Id).ToList(); foreach (var iid in instanceIds) { var configs = _configSvc.GetAll(iid); foreach (var appType in new[] { AppType.Tablet, AppType.Mobile }) { var relevant = appType == AppType.Tablet ? configs.Where(c => c.IsTablet).ToList() : configs.Where(c => c.IsMobile).ToList(); if (!relevant.Any()) continue; var appInstanceId = $"migration-{appType.ToString().ToLower()}-{iid}"; try { if (!await _db.ApplicationInstances.AnyAsync(x => x.Id == appInstanceId)) { var firstConfig = relevant.First(); var allLanguages = relevant .Where(c => c.Languages != null) .SelectMany(c => c.Languages) .Distinct() .ToList(); var appInstance = new ApplicationInstance { Id = appInstanceId, InstanceId = iid, AppType = appType, Languages = allLanguages, LoaderImageId = firstConfig.LoaderImageId, LoaderImageUrl = firstConfig.LoaderImageUrl, PrimaryColor = firstConfig.PrimaryColor, SecondaryColor = firstConfig.SecondaryColor, Configurations = new List(), }; if (!dryRun) _db.ApplicationInstances.Add(appInstance); report.Migrated.ApplicationInstances++; report.Details.Add($"ApplicationInstance {appType} for instance {iid}"); } foreach (var config in relevant) { var linkId = $"migration-{appType.ToString().ToLower()}-link-{config.Id}"; if (await _db.AppConfigurationLinks.AnyAsync(x => x.Id == linkId)) { report.Skipped.Add($"AppConfigurationLink {linkId} — already exists"); continue; } var link = new AppConfigurationLink { Id = linkId, ConfigurationId = config.Id, ApplicationInstanceId = appInstanceId, IsActive = true, IsDate = appType == AppType.Tablet && config.IsDate, IsHour = appType == AppType.Tablet && config.IsHour, IsSectionImageBackground = appType == AppType.Tablet && config.IsSectionImageBackground, RoundedValue = appType == AppType.Tablet ? config.RoundedValue : null, ScreenPercentageSectionsMainPage = appType == AppType.Tablet ? config.ScreenPercentageSectionsMainPage : null, LoaderImageId = config.LoaderImageId, LoaderImageUrl = config.LoaderImageUrl, PrimaryColor = config.PrimaryColor, SecondaryColor = config.SecondaryColor, }; if (!dryRun) _db.AppConfigurationLinks.Add(link); report.Migrated.AppConfigurationLinks++; } } catch (Exception ex) { report.Errors.Add($"ApplicationInstance {appType} {iid}: {ex.Message}"); } } } if (!dryRun) await _db.SaveChangesAsync(); } // ─── Sections ───────────────────────────────────────────────────────── private async Task MigrateSectionsAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId) { var instanceIds = filterInstanceId != null ? new List { filterInstanceId } : _instanceSvc.GetAll().Select(i => i.Id).ToList(); // GetAllFromConfigurationEvenSubsection inclut sections ET sous-sections var configIds = instanceIds .SelectMany(id => _configSvc.GetAll(id)) .Select(c => c.Id) .ToList(); var source = configIds .SelectMany(cid => _sectionSvc.GetAllFromConfigurationEvenSubsection(cid)) .GroupBy(s => s.Id).Select(g => g.First()) .ToList(); // Les sections sont collectées configuration par configuration : celles dont // la configuration a été supprimée dans Mongo ne sont jamais énumérées, donc // ne partaient ni dans Migrated, ni dans Skipped, ni dans Errors. Le rapport // affichait un compte inférieur à Mongo sans dire pourquoi — mesuré au dry // run du 2026-08-11 : 327 sections en base, 307 reprises, 20 muettes. // Elles ne peuvent pas être migrées (la FK ConfigurationId les refuserait), // mais leur disparition doit être un constat, pas une surprise du jour J. var knownConfigIds = configIds.ToHashSet(); var orphans = instanceIds .SelectMany(id => _sectionSvc.GetAll(id)) .GroupBy(s => s.Id).Select(g => g.First()) .Where(s => !knownConfigIds.Contains(s.ConfigurationId)) .ToList(); foreach (var orphan in orphans) report.Skipped.Add( $"Section {orphan.Id} ({orphan.Label}) — configuration {orphan.ConfigurationId} introuvable, section orpheline dans Mongo"); if (orphans.Count > 0) report.Details.Add( $"{orphans.Count} section(s) orpheline(s) non migrée(s) : leur configuration n'existe plus dans Mongo"); var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; foreach (var old in source) { try { if (await _db.Sections.AnyAsync(x => x.Id == old.Id)) { report.Skipped.Add($"Section {old.Id} ({old.Label}, type {old.Type}) — already exists"); continue; } Section entity = BuildSection(old, opts, report); if (entity == null) continue; if (!dryRun) _db.Sections.Add(entity); report.Migrated.Sections++; } catch (Exception ex) { report.Errors.Add($"Section {old.Id} ({old.Label}, type {old.Type}): {ex.Message}"); } } if (!dryRun) await _db.SaveChangesAsync(); } private Section BuildSection(OldSection old, JsonSerializerOptions opts, MigrationReportDTO report) { // Champs communs à tous les sous-types void FillBase(Section s) { s.Id = old.Id; s.Label = old.Label ?? ""; s.Title = old.Title ?? new List(); s.Description = old.Description ?? new List(); s.Order = old.Order; s.ConfigurationId = old.ConfigurationId; s.ImageId = old.ImageId; s.ImageSource = old.ImageSource; s.Type = old.Type; s.IsSubSection = old.IsSubSection; s.ParentId = old.ParentId; s.DateCreation = old.DateCreation; s.InstanceId = old.InstanceId; s.IsBeacon = old.IsBeacon; s.BeaconId = old.BeaconId; s.Latitude = old.Latitude; s.Longitude = old.Longitude; s.MeterZoneGPS = old.MeterZoneGPS; s.IsActive = true; } switch (old.Type) { case SectionType.Map: { var dto = ParseData(old, opts, report); var s = new SectionMap { MapZoom = dto?.zoom ?? 18, MapMapType = dto?.mapType, MapTypeMapbox = dto?.mapTypeMapbox, MapMapProvider = dto?.mapProvider, IconResourceId = dto?.iconResourceId, MapCenterLatitude = dto?.latitude, MapCenterLongitude = dto?.longitude, MapCategories = dto?.categories?.Select(c => new CategorieDTO { id = c.id, label = c.label, icon = c.icon, order = c.order, }).ToList() ?? new List(), MapPoints = dto?.points?.Select(p => BuildGeoPoint(p, old.Id)).ToList() ?? new List(), }; FillBase(s); return s; } case SectionType.Slider: { var dto = ParseData(old, opts, report); var s = new SectionSlider { SliderContents = dto?.contents ?? new List(), }; FillBase(s); return s; } case SectionType.Video: { var dto = ParseData(old, opts, report); var s = new SectionVideo { VideoSource = dto?.source ?? "" }; FillBase(s); return s; } case SectionType.Web: { var dto = ParseData(old, opts, report); var s = new SectionWeb { WebSource = dto?.source ?? "" }; FillBase(s); return s; } case SectionType.Menu: { // Les relations MenuSections sont gérées dans LinkMenuSectionsAsync var s = new SectionMenu { MenuSections = new List
() }; FillBase(s); return s; } case SectionType.Quiz: { var dto = ParseData(old, opts, report); var s = new SectionQuiz { // É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(), QuizGreatLevel = dto?.great_level?.label ?? new List(), }; FillBase(s); return s; } case SectionType.Article: { var dto = ParseData(old, opts, report); var s = new SectionArticle { ArticleContent = dto?.content ?? new List(), ArticleIsContentTop = dto?.isContentTop ?? false, ArticleAudioIds = dto?.audioIds ?? new List(), ArticleIsReadAudioAuto = dto?.isReadAudioAuto ?? false, ArticleContents = dto?.contents ?? new List(), }; FillBase(s); return s; } case SectionType.PDF: { var dto = ParseData(old, opts, report); var s = new SectionPdf { PDFOrderedTranslationAndResources = dto?.pdfs?.Select(p => new OrderedTranslationAndResourceDTO { translationAndResourceDTOs = p.pdfFilesAndTitles ?? new List(), order = p.order, }).ToList() ?? new List(), }; FillBase(s); return s; } case SectionType.Game: { var dto = ParseData(old, opts, report); var s = new SectionGame { GameMessageDebut = ToTranslationAndResourceList(dto?.messageDebut), GameMessageFin = ToTranslationAndResourceList(dto?.messageFin), GamePuzzleImageId = dto?.image?.resourceId, GamePuzzleRows = dto?.rows ?? 3, GamePuzzleCols = dto?.cols ?? 3, GameType = SectionGame.GameTypes.Puzzle, }; FillBase(s); return s; } case SectionType.Agenda: { var dto = ParseData(old, opts, report); var s = new SectionAgenda { IsOnlineAgenda = true, AgendaResourceIds = dto?.resourceIds ?? new List(), AgendaMapProvider = dto?.mapProvider, EventAgendas = new List(), }; FillBase(s); return s; } case SectionType.Weather: { var dto = ParseData(old, opts, report); var s = new SectionWeather { WeatherCity = dto?.city, WeatherUpdatedDate = dto?.updatedDate, WeatherResult = dto?.result, }; FillBase(s); return s; } default: report.Errors.Add($"Section {old.Id}: unknown SectionType {old.Type} — skipped"); return null; } } /// /// É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; if (p.latitude != null && p.longitude != null && double.TryParse(p.latitude, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var lat) && double.TryParse(p.longitude, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var lon)) { geometry = new Point(lon, lat) { SRID = 4326 }; } return new GeoPoint { Title = p.title ?? new List(), Description = p.description ?? new List(), Contents = p.contents?.Select(c => new ContentDTO { resourceId = c.resourceId, }).ToList() ?? new List(), CategorieId = p.categorieId ?? p.categorie?.id, Geometry = geometry, ImageResourceId = p.imageResourceId, ImageUrl = p.imageUrl, Schedules = p.schedules ?? new List(), Prices = p.prices ?? new List(), Phone = p.phone ?? new List(), Email = p.email ?? new List(), Site = p.site ?? new List(), SectionMapId = sectionMapId, }; } // ─── Devices ────────────────────────────────────────────────────────── private async Task MigrateDevicesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId) { var instanceIds = filterInstanceId != null ? new List { filterInstanceId } : _instanceSvc.GetAll().Select(i => i.Id).ToList(); var source = instanceIds.SelectMany(id => _deviceSvc.GetAll(id)).ToList(); foreach (var old in source) { try { if (await _db.Devices.AnyAsync(x => x.Id == old.Id)) { report.Skipped.Add($"Device {old.Id} ({old.Name}) — already exists"); continue; } var entity = new Device { Id = old.Id, Identifier = old.Identifier, Name = old.Name, IpAddressWLAN = old.IpAddressWLAN, IpAddressETH = old.IpAddressETH, ConfigurationId = old.ConfigurationId, Connected = old.Connected, DateCreation = old.DateCreation, DateUpdate = old.DateUpdate < new DateTime(2000, 1, 1) ? DateTime.MinValue : old.DateUpdate, BatteryLevel = old.BatteryLevel?.ToString(), LastBatteryLevel = old.LastBatteryLevel, ConnectionLevel = old.ConnectionLevel?.ToString(), LastConnectionLevel = old.LastConnectionLevel, InstanceId = old.InstanceId, }; if (!dryRun) _db.Devices.Add(entity); report.Migrated.Devices++; } catch (Exception ex) { report.Errors.Add($"Device {old.Id}: {ex.Message}"); } } if (!dryRun) await _db.SaveChangesAsync(); } // ─── Post-processing : relier SectionMenu.MenuSections ─────────────── private async Task LinkMenuSectionsAsync(MigrationReportDTO report, bool dryRun) { var menus = await _db.Sections .OfType() .Include(m => m.MenuSections) .ToListAsync(); foreach (var menu in menus) { if (menu.MenuSections.Count > 0) { report.Skipped.Add($"MenuLink {menu.Id} — already linked ({menu.MenuSections.Count} children)"); continue; } var oldSection = _sectionSvc.GetById(menu.Id); if (oldSection?.Data == null) continue; try { var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var dto = JsonSerializer.Deserialize(oldSection.Data, opts); if (dto?.sections == null) continue; var childIds = dto.sections.Select(s => s.id).Where(id => id != null).ToList(); var children = await _db.Sections.Where(s => childIds.Contains(s.Id)).ToListAsync(); if (!dryRun) { menu.MenuSections = children; _db.Sections.Update(menu); } report.Migrated.MenuLinks += children.Count; } catch (Exception ex) { report.Errors.Add($"MenuLink {menu.Id}: {ex.Message}"); } } if (!dryRun) await _db.SaveChangesAsync(); } // ─── Post-processing : enrichir les resource dans les JSONB PDF ───────── private async Task EnrichPdfResourcesAsync(MigrationReportDTO report, bool dryRun) { var pdfSections = await _db.Sections.OfType().ToListAsync(); foreach (var pdf in pdfSections) { try { bool modified = false; foreach (var ordered in pdf.PDFOrderedTranslationAndResources ?? new List()) { foreach (var tar in ordered.translationAndResourceDTOs ?? new List()) { if (tar.resource == null && tar.resourceId != null) { var resource = await _db.Resources.FindAsync(tar.resourceId); if (resource != null) { tar.resource = resource.ToDTO(); modified = true; } } } } if (modified) { if (!dryRun) _db.Sections.Update(pdf); report.Migrated.PdfResourcesEnriched++; } } catch (Exception ex) { report.Errors.Add($"EnrichPdf {pdf.Id}: {ex.Message}"); } } if (!dryRun) await _db.SaveChangesAsync(); } // ─── Helpers ────────────────────────────────────────────────────────── private T ParseData(OldSection old, JsonSerializerOptions opts, MigrationReportDTO report) where T : class { if (string.IsNullOrEmpty(old.Data)) return null; try { return JsonSerializer.Deserialize(old.Data, opts); } catch (Exception ex) { report.Errors.Add($"Section {old.Id} — failed to parse Data as {typeof(T).Name}: {ex.Message}"); return null; } } private List ToTranslationAndResourceList(List source) => source ?? new List(); } // ─── DTO du rapport ─────────────────────────────────────────────────────── public class MigrationReportDTO { public bool DryRun { get; set; } public MigrationCountsDTO Migrated { get; set; } = new(); public List Skipped { get; set; } = new(); public List Errors { get; set; } = new(); public List Details { get; set; } = new(); public string FatalError { get; set; } } public class MigrationCountsDTO { public int Instances { get; set; } public int Resources { get; set; } public int Users { get; set; } public int Configurations { get; set; } public int ApplicationInstances { get; set; } public int AppConfigurationLinks { get; set; } public int Sections { get; set; } public int Devices { get; set; } public int MenuLinks { get; set; } public int PdfResourcesEnriched { get; set; } } }