Le dry run est automatisé en test (MigrationDryRunTests) : il appelle le contrôleur directement, ce qui évite la chaîne login + JWT SuperAdmin, et se saute si aucun Mongo n'écoute sur MIGRATION_TEST_MONGO — la suite reste donc verte sans dépendance. Les tailles de fichier sont court-circuitées : un HEAD sur 2374 ressources prendrait des minutes pour une information que ce test ne vérifie pas. Il a trouvé du premier coup ce qu'aucune lecture de code n'avait vu : 327 sections dans Mongo, 307 migrées, et Erreurs : 0. Les 20 manquantes référencent deux configurations supprimées dans Mongo — 19 articles et un slider, du contenu MDLF. La migration a raison de ne pas les reprendre : les sections sont collectées configuration par configuration, donc les orphelines ne sont jamais énumérées, et la FK ConfigurationId les refuserait. Le défaut était le silence — elles partent désormais dans Skipped, avec le détail de la configuration introuvable. L'invariant affirmé par le test n'est donc pas « tout est migré » mais « rien ne disparaît sans être signalé » : migrées + signalées = Mongo. dotnet test 144/144. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1007 lines
45 KiB
C#
1007 lines
45 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();
|
|
|
|
// 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<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; }
|
|
}
|
|
}
|