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>
This commit is contained in:
parent
a80096d72b
commit
934d71c2ee
@ -3,6 +3,7 @@ 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;
|
||||
@ -12,6 +13,7 @@ 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;
|
||||
|
||||
@ -65,6 +67,13 @@ namespace ManagerService.Controllers
|
||||
{
|
||||
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);
|
||||
@ -76,10 +85,26 @@ namespace ManagerService.Controllers
|
||||
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);
|
||||
@ -103,14 +128,36 @@ namespace ManagerService.Controllers
|
||||
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);
|
||||
|
||||
@ -126,6 +173,50 @@ namespace ManagerService.Controllers
|
||||
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)
|
||||
@ -140,6 +231,7 @@ namespace ManagerService.Controllers
|
||||
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;
|
||||
@ -155,7 +247,13 @@ namespace ManagerService.Controllers
|
||||
if (resp.Content.Headers.ContentLength.HasValue)
|
||||
sizemap[r.Id] = resp.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
catch { /* URL inaccessible ou expirée → SizeBytes restera 0 */ }
|
||||
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;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@ -177,12 +275,20 @@ namespace ManagerService.Controllers
|
||||
DateCreation = old.DateCreation,
|
||||
InstanceId = old.InstanceId,
|
||||
Url = old.Url,
|
||||
SizeBytes = sizemap.TryGetValue(old.Id, out var size) ? size : 0,
|
||||
};
|
||||
|
||||
// É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)
|
||||
@ -222,7 +328,15 @@ namespace ManagerService.Controllers
|
||||
Token = old.Token,
|
||||
DateCreation = old.DateCreation,
|
||||
InstanceId = old.InstanceId,
|
||||
Role = UserRole.ContentEditor,
|
||||
// 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)
|
||||
@ -523,7 +637,13 @@ namespace ManagerService.Controllers
|
||||
var dto = ParseData<OldQuizzDTO>(old, opts, report);
|
||||
var s = new SectionQuiz
|
||||
{
|
||||
QuizQuestions = new List<QuizQuestion>(),
|
||||
// É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>(),
|
||||
@ -613,6 +733,39 @@ namespace ManagerService.Controllers
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user