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; }
}
}