using Manager.DTOs; using Manager.Services; using ManagerService.Data; using ManagerService.Data.SubSection; using ManagerService.DTOs; 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.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; public MigrationController( MyInfoMateDbContext db, InstanceDatabaseService instanceSvc, ConfigurationDatabaseService configSvc, SectionDatabaseService sectionSvc, ResourceDatabaseService resourceSvc, UserDatabaseService userSvc, DeviceDatabaseService deviceSvc) { _db = db; _instanceSvc = instanceSvc; _configSvc = configSvc; _sectionSvc = sectionSvc; _resourceSvc = resourceSvc; _userSvc = userSvc; _deviceSvc = deviceSvc; } /// /// 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 }; 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); } catch (Exception ex) { report.FatalError = ex.Message; } 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; } var entity = new Instance { Id = old.Id, Name = old.Name, DateCreation = old.DateCreation, PinCode = old.PinCode?.ToString(), }; 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(); } // ─── 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(); 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, // NOTE: MongoDB field is "URL", mapped to Url in OldResource SizeBytes = 0, }; if (!dryRun) _db.Resources.Add(entity); 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, LastName = old.LastName, Token = old.Token, DateCreation = old.DateCreation, InstanceId = old.InstanceId, Role = UserRole.ContentEditor, }; 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(); 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, MapResourceId = 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 { QuizQuestions = new List(), 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; } } 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; } } }