manager-service/ManagerService/Controllers/MigrationController.cs
Thomas Fransolet 934d71c2ee Lot G : les 8 écarts de MigrationController
Trois n'existaient pas — vérifié dans l'export Mongo plutôt que dans la doc :
- (a) « BuildSection ne couvre que 11 types sur 13 » : les 327 sections de
  l'export ne contiennent que les types 0 à 10. SectionEvent et
  SectionParcours sont nés avec Postgres v3, le default est un filet.
- (f) IsQRCode/IsSearchText/IsSearchNumber n'existent pas dans Mongo, false
  est le bon défaut. Contrôle inverse fait : les cinq réglages qui existent
  vraiment sont bien mappés sur AppConfigurationLink.
- (g) était tombé avec le rename du lot B.

Les cinq réels :
- (b) QuizQuestions = new() jetait les questions. Mesuré : 5 sections quiz
  en portent 41, réponses comprises. Migrées en MultipleChoice, le type de
  validation n'existant pas dans l'ancien modèle. EventAgendas et l'absence
  de GuidedPath sont corrects : Mongo n'a ni événement ni parcours.
- (c) la source ne contient que 4 champs, il n'y avait rien à mapper de
  plus. PublicApiKey générée (même schéma que l'onboarding), WebSlug via
  SlugHelper, drapeaux de plateforme dérivés des configurations, plan
  affecté par nom avec repli sur Pro, IsAssistant allumé seulement si le
  plan donne des jetons.
- (d) Mongo n'a pas de champ Role : le ContentEditor en dur dégradait les
  10 utilisateurs en silence, plus personne n'aurait pu gérer les
  utilisateurs de son instance. Passé à InstanceAdmin.
- (e) StoragePath n'était pas écrit du tout. Passe par ResourceStorage, le
  calculateur commun à la création et au backfill. L'échec du HEAD, jusqu'ici
  avalé, remonte dans le rapport.
- (h) transaction unique sur les neuf étapes, et 500 au lieu de 200 OK sur
  échec fatal — un script de bascule testant le code HTTP concluait au
  succès alors que l'erreur n'était que dans le corps.

ApplyPlanQuotas est dupliqué depuis InstanceController plutôt qu'appelé : la
migration ne doit pas casser si ce contrôleur change de forme.

dotnet build vert, dotnet test 143/143. Reste le dry run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:26:01 +02:00

985 lines
43 KiB
C#

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;
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("run")]
[ProducesResponseType(typeof(MigrationReportDTO), 200)]
[ProducesResponseType(typeof(string), 500)]
public async Task<ObjectResult> 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();
}
/// <summary>
/// Même schéma que l'inscription self-service (`OnboardingController`) : préfixe
/// `ap_`, 32 octets aléatoires cryptographiques, encodés en base64 URL-safe.
/// </summary>
private static string GeneratePublicApiKey() =>
"ap_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
/// <summary>
/// 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.
/// </summary>
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";
}
/// <summary>
/// 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.
/// </summary>
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<string> { filterInstanceId }
: _instanceSvc.GetAll().Select(i => i.Id).ToList();
var source = instanceIds.SelectMany(id => _resourceSvc.GetAll(id)).ToList();
// Fetch sizes en parallèle par batches de 30
var httpClient = _httpClientFactory.CreateClient();
httpClient.Timeout = TimeSpan.FromSeconds(10);
var sizemap = new System.Collections.Concurrent.ConcurrentDictionary<string, long>();
var unsized = new System.Collections.Concurrent.ConcurrentDictionary<string, bool>();
var urlSource = source.Where(r => !string.IsNullOrEmpty(r.Url)).ToList();
const int batchSize = 30;
for (int i = 0; i < urlSource.Count; i += batchSize)
{
var batch = urlSource.Skip(i).Take(batchSize);
await Task.WhenAll(batch.Select(async r =>
{
try
{
var req = new HttpRequestMessage(HttpMethod.Head, r.Url);
var resp = await httpClient.SendAsync(req);
if (resp.Content.Headers.ContentLength.HasValue)
sizemap[r.Id] = resp.Content.Headers.ContentLength.Value;
}
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;
}
}));
}
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, 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)
{
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,
// 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<string> { 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<TranslationDTO>(),
ImageId = old.ImageId,
ImageSource = old.ImageSource,
PrimaryColor = old.PrimaryColor,
SecondaryColor = old.SecondaryColor,
Languages = old.Languages ?? new List<string>(),
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<string> { 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<AppConfigurationLink>(),
};
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<string> { 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<TranslationDTO>();
s.Description = old.Description ?? new List<TranslationDTO>();
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<OldMapDTO>(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<CategorieDTO>(),
MapPoints = dto?.points?.Select(p => BuildGeoPoint(p, old.Id)).ToList()
?? new List<GeoPoint>(),
};
FillBase(s);
return s;
}
case SectionType.Slider:
{
var dto = ParseData<OldSliderDTO>(old, opts, report);
var s = new SectionSlider
{
SliderContents = dto?.contents ?? new List<ContentDTO>(),
};
FillBase(s);
return s;
}
case SectionType.Video:
{
var dto = ParseData<OldVideoDTO>(old, opts, report);
var s = new SectionVideo { VideoSource = dto?.source ?? "" };
FillBase(s);
return s;
}
case SectionType.Web:
{
var dto = ParseData<OldWebDTO>(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<Section>() };
FillBase(s);
return s;
}
case SectionType.Quiz:
{
var dto = ParseData<OldQuizzDTO>(old, opts, report);
var s = new SectionQuiz
{
// Écart (b). `new List<QuizQuestion>()` 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<TranslationAndResourceDTO>(),
QuizMediumLevel = dto?.medium_level?.label ?? new List<TranslationAndResourceDTO>(),
QuizGoodLevel = dto?.good_level?.label ?? new List<TranslationAndResourceDTO>(),
QuizGreatLevel = dto?.great_level?.label ?? new List<TranslationAndResourceDTO>(),
};
FillBase(s);
return s;
}
case SectionType.Article:
{
var dto = ParseData<OldArticleDTO>(old, opts, report);
var s = new SectionArticle
{
ArticleContent = dto?.content ?? new List<TranslationDTO>(),
ArticleIsContentTop = dto?.isContentTop ?? false,
ArticleAudioIds = dto?.audioIds ?? new List<TranslationDTO>(),
ArticleIsReadAudioAuto = dto?.isReadAudioAuto ?? false,
ArticleContents = dto?.contents ?? new List<ContentDTO>(),
};
FillBase(s);
return s;
}
case SectionType.PDF:
{
var dto = ParseData<OldPdfDTO>(old, opts, report);
var s = new SectionPdf
{
PDFOrderedTranslationAndResources = dto?.pdfs?.Select(p =>
new OrderedTranslationAndResourceDTO
{
translationAndResourceDTOs = p.pdfFilesAndTitles ?? new List<TranslationAndResourceDTO>(),
order = p.order,
}).ToList() ?? new List<OrderedTranslationAndResourceDTO>(),
};
FillBase(s);
return s;
}
case SectionType.Game:
{
var dto = ParseData<OldPuzzleDTO>(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<OldAgendaDTO>(old, opts, report);
var s = new SectionAgenda
{
IsOnlineAgenda = true,
AgendaResourceIds = dto?.resourceIds ?? new List<TranslationDTO>(),
AgendaMapProvider = dto?.mapProvider,
EventAgendas = new List<EventAgenda>(),
};
FillBase(s);
return s;
}
case SectionType.Weather:
{
var dto = ParseData<OldWeatherDTO>(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;
}
}
/// <summary>
/// É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.
/// </summary>
private static List<QuizQuestion> BuildQuizQuestions(List<OldQuestionDTO> old)
{
var questions = new List<QuizQuestion>();
if (old == null) return questions;
foreach (var q in old)
{
questions.Add(new QuizQuestion
{
Label = q.label ?? new List<TranslationAndResourceDTO>(),
ResourceId = q.imageBackgroundResourceId,
Order = q.order,
ValidationQuestionType = QuestionType.MultipleChoice,
Responses = (q.responses ?? new List<OldResponseDTO>())
.Select(r => new ResponseDTO
{
label = r.label ?? new List<TranslationAndResourceDTO>(),
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<TranslationDTO>(),
Description = p.description ?? new List<TranslationDTO>(),
Contents = p.contents?.Select(c => new ContentDTO
{
resourceId = c.resourceId,
}).ToList() ?? new List<ContentDTO>(),
CategorieId = p.categorieId ?? p.categorie?.id,
Geometry = geometry,
ImageResourceId = p.imageResourceId,
ImageUrl = p.imageUrl,
Schedules = p.schedules ?? new List<TranslationDTO>(),
Prices = p.prices ?? new List<TranslationDTO>(),
Phone = p.phone ?? new List<TranslationDTO>(),
Email = p.email ?? new List<TranslationDTO>(),
Site = p.site ?? new List<TranslationDTO>(),
SectionMapId = sectionMapId,
};
}
// ─── Devices ──────────────────────────────────────────────────────────
private async Task MigrateDevicesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
{
var instanceIds = filterInstanceId != null
? new List<string> { 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<SectionMenu>()
.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<OldMenuDTO>(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<SectionPdf>().ToListAsync();
foreach (var pdf in pdfSections)
{
try
{
bool modified = false;
foreach (var ordered in pdf.PDFOrderedTranslationAndResources ?? new List<OrderedTranslationAndResourceDTO>())
{
foreach (var tar in ordered.translationAndResourceDTOs ?? new List<TranslationAndResourceDTO>())
{
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<T>(OldSection old, JsonSerializerOptions opts, MigrationReportDTO report) where T : class
{
if (string.IsNullOrEmpty(old.Data)) return null;
try
{
return JsonSerializer.Deserialize<T>(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<TranslationAndResourceDTO> ToTranslationAndResourceList(List<TranslationAndResourceDTO> source)
=> source ?? new List<TranslationAndResourceDTO>();
}
// ─── DTO du rapport ───────────────────────────────────────────────────────
public class MigrationReportDTO
{
public bool DryRun { get; set; }
public MigrationCountsDTO Migrated { get; set; } = new();
public List<string> Skipped { get; set; } = new();
public List<string> Errors { get; set; } = new();
public List<string> 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; }
}
}