From a3bf9523e3201d27e01dae359a367f5ef6b7e852 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Wed, 12 Aug 2026 15:04:39 +0200 Subject: [PATCH] GetSummary agrege en SQL au lieu de charger 13 mois en memoire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit eventsQuery.ToList() ramenait la fenetre entiere, toutes colonnes comprises, pour n'en faire que des comptages. La retention est passee de 30 jours a 13 mois (StatsRetentionDays = 395) : la methode charge donc desormais treize fois ce pour quoi elle avait ete ecrite, et ca grossit tout seul. Ce qui part en SQL : sessions distinctes, sommes de duree par session, duree moyenne par section, top sections, visites par jour, distributions par session, top articles, total des scans QR. Ce qui ne peut pas y aller : POI, agenda, quiz, jeux, menus et validite des QR se regroupent sur du JSON dans Metadata. On ne remonte plus que les deux colonnes utiles, et seulement pour le type d'evenement concerne (MetadataOf). Deux effets de bord voulus : - les stats avancees ne sont plus calculees puis effacees pour les plans qui n'y ont pas droit -- les six requetes ne partent pas ; - les titres de section ne sont resolus que pour les ids reellement affiches (top 10 + quiz), plus pour tous les ids vus dans la fenetre. Un changement de semantique, delibere : « la langue de la session » etait prise sur le premier evenement rendu par la base, dans un ordre indefini. C'est desormais le plus ancien horodatage. Meme chose pour AppType. Le reste est preserve a l'identique, y compris les cas tordus : un scan QR sans metadata compte dans le total sans etre ni valide ni invalide, une metadata illisible compte la completion de quiz a zero. 11 tests ajoutes -- la branche « stats avancees » n'etait couverte par AUCUN test : les cas existants ne seedent pas d'Instance, donc hasAdvancedStats etait toujours faux et la moitie de la methode n'etait jamais executee. dotnet test 186/186. ⚠️ Ces tests tournent sur le provider InMemory, qui evalue tout cote client : ils tiennent la semantique, pas la traduction SQL. La verification sur un vrai Postgres arrive avec les tests Testcontainers. Co-Authored-By: Claude Opus 5 --- .../Controllers/StatsControllerTests.cs | 278 ++++++++++ ManagerService/Controllers/StatsController.cs | 496 ++++++++++-------- 2 files changed, 543 insertions(+), 231 deletions(-) diff --git a/ManagerService.Tests/Controllers/StatsControllerTests.cs b/ManagerService.Tests/Controllers/StatsControllerTests.cs index 6ef314a..70cad0b 100644 --- a/ManagerService.Tests/Controllers/StatsControllerTests.cs +++ b/ManagerService.Tests/Controllers/StatsControllerTests.cs @@ -1,8 +1,12 @@ +using Manager.DTOs; using ManagerService.Controllers; using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Tests.Infrastructure; using Microsoft.AspNetCore.Mvc; +using System; +using System.Collections.Generic; +using System.Linq; using Xunit; namespace ManagerService.Tests.Controllers @@ -164,6 +168,280 @@ namespace ManagerService.Tests.Controllers Assert.Equal(1, summary.TotalSessions); } + // ── STATS AVANCÉES ─────────────────────────────────────────────────── + // + // Sans Instance en base, hasAdvancedStats est faux et toute cette branche est + // sautée : c'est le cas des tests ci-dessus. Ceux qui suivent la seedent, sans + // quoi la moitié de GetSummary ne serait jamais exécutée par la suite. + + private static void SeedAdvancedInstance(MyInfoMateDbContext db) + { + db.Instances.Add(new Instance + { + Id = "i1", + Name = "Instance de test", + HasStats = true, + HasAdvancedStats = true, + StatsHistoryDays = MyInfoMateDbContext.StatsRetentionDays + }); + db.SaveChanges(); + } + + private static VisitEvent Event(string id, VisitEventType type, string sessionId = "s1", + string sectionId = null, string metadata = null, string language = null, + AppType appType = AppType.Mobile, int? durationSeconds = null, DateTime? timestamp = null) => + new VisitEvent + { + Id = id, + InstanceId = "i1", + SessionId = sessionId, + EventType = type, + SectionId = sectionId, + Metadata = metadata, + Language = language, + AppType = appType, + DurationSeconds = durationSeconds, + Timestamp = timestamp ?? DateTime.UtcNow + }; + + private static StatsSummaryDTO Summarize(MyInfoMateDbContext db, StatsController controller = null) + { + var result = (controller ?? BuildControllerStatic(db)).GetSummary("i1", null, null, null); + var ok = Assert.IsType(result); + return Assert.IsType(ok.Value); + } + + private static StatsController BuildControllerStatic(MyInfoMateDbContext db) + { + var controller = new StatsController(db); + FakeUser.SetUser(controller, FakeUser.Create(Permissions.SuperAdmin, "i1")); + return controller; + } + + [Fact] + public void GetSummary_WithoutAdvancedStats_SkipsAdvancedBlocks() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "Basique", HasStats = true, HasAdvancedStats = false }); + db.VisitEvents.AddRange( + Event("e1", VisitEventType.SectionView, sectionId: "sect-a", language: "fr"), + Event("e2", VisitEventType.QrScan, metadata: "{\"valid\":true}"), + Event("e3", VisitEventType.ArticleRead, sectionId: "sect-a") + ); + db.SaveChanges(); + + var summary = Summarize(db); + + Assert.Equal(1, summary.TotalSessions); + Assert.Empty(summary.LanguageDistribution); + Assert.Empty(summary.TopArticles); + Assert.Equal(0, summary.QrScans.TotalScans); + // Les stats de base, elles, restent servies + Assert.Single(summary.TopSections); + Assert.NotEmpty(summary.AppTypeDistribution); + } + + [Fact] + public void GetSummary_AvgVisitDuration_SumsPerSessionThenAverages() + { + using var db = DbContextFactory.Create(); + SeedAdvancedInstance(db); + db.VisitEvents.AddRange( + Event("e1", VisitEventType.SectionLeave, "s1", "sect-a", durationSeconds: 10), + Event("e2", VisitEventType.SectionLeave, "s1", "sect-b", durationSeconds: 30), + Event("e3", VisitEventType.SectionLeave, "s2", "sect-a", durationSeconds: 20) + ); + db.SaveChanges(); + + // s1 = 40, s2 = 20 → moyenne 30. Une moyenne sur les événements donnerait 20. + Assert.Equal(30, Summarize(db).AvgVisitDurationSeconds); + } + + [Fact] + public void GetSummary_TopSections_CarryAvgDurationAndTitle() + { + using var db = DbContextFactory.Create(); + SeedAdvancedInstance(db); + var section = TestSection.Article("sect-a", "i1", "A", "c1"); + section.Title = new List + { + new TranslationDTO { language = "en", value = "Cellar" }, + new TranslationDTO { language = "fr", value = "La cave" } + }; + db.Sections.Add(section); + db.VisitEvents.AddRange( + Event("e1", VisitEventType.SectionView, "s1", "sect-a"), + Event("e2", VisitEventType.SectionLeave, "s1", "sect-a", durationSeconds: 10), + Event("e3", VisitEventType.SectionLeave, "s2", "sect-a", durationSeconds: 20) + ); + db.SaveChanges(); + + var stat = Assert.Single(Summarize(db).TopSections); + Assert.Equal("La cave", stat.SectionTitle); // FR privilégié + Assert.Equal(1, stat.Views); + Assert.Equal(15, stat.AvgDurationSeconds); + } + + [Fact] + public void GetSummary_VisitsByDay_GroupsByCalendarDayAndChannel() + { + using var db = DbContextFactory.Create(); + SeedAdvancedInstance(db); + var day = DateTime.UtcNow.AddDays(-3); + db.VisitEvents.AddRange( + Event("e1", VisitEventType.SectionView, "s1", timestamp: day, appType: AppType.Mobile), + Event("e2", VisitEventType.SectionView, "s2", timestamp: day.AddHours(2), appType: AppType.Tablet), + Event("e3", VisitEventType.SectionView, "s3", timestamp: day.AddDays(1), appType: AppType.Mobile) + ); + db.SaveChanges(); + + var days = Summarize(db).VisitsByDay; + + Assert.Equal(2, days.Count); + Assert.Equal(day.ToString("yyyy-MM-dd"), days[0].Date); + Assert.Equal(2, days[0].Total); + Assert.Equal(1, days[0].Mobile); + Assert.Equal(1, days[0].Tablet); + } + + [Fact] + public void GetSummary_LanguageDistribution_CountsOneEntryPerSession() + { + using var db = DbContextFactory.Create(); + SeedAdvancedInstance(db); + var start = DateTime.UtcNow.AddHours(-2); + db.VisitEvents.AddRange( + Event("e1", VisitEventType.SectionView, "s1", language: "fr", timestamp: start), + Event("e2", VisitEventType.SectionView, "s1", language: "fr", timestamp: start.AddMinutes(1)), + // La session bascule en cours de route : c'est sa première langue qui compte + Event("e3", VisitEventType.SectionView, "s1", language: "nl", timestamp: start.AddMinutes(2)), + Event("e4", VisitEventType.SectionView, "s2", language: "nl", timestamp: start) + ); + db.SaveChanges(); + + var languages = Summarize(db).LanguageDistribution; + + Assert.Equal(1, languages["fr"]); + Assert.Equal(1, languages["nl"]); + } + + [Fact] + public void GetSummary_AppTypeDistribution_CountsOneEntryPerSession() + { + using var db = DbContextFactory.Create(); + SeedAdvancedInstance(db); + db.VisitEvents.AddRange( + Event("e1", VisitEventType.SectionView, "s1", appType: AppType.Tablet), + Event("e2", VisitEventType.SectionView, "s1", appType: AppType.Tablet), + Event("e3", VisitEventType.SectionView, "s2", appType: AppType.Mobile) + ); + db.SaveChanges(); + + var appTypes = Summarize(db).AppTypeDistribution; + + Assert.Equal(1, appTypes["Tablet"]); + Assert.Equal(1, appTypes["Mobile"]); + } + + [Fact] + public void GetSummary_MetadataStats_AreAggregated() + { + using var db = DbContextFactory.Create(); + SeedAdvancedInstance(db); + db.VisitEvents.AddRange( + Event("p1", VisitEventType.MapPoiTap, sectionId: "sect-map", metadata: "{\"geoPointId\":7,\"geoPointTitle\":\"Ruche\"}"), + Event("p2", VisitEventType.MapPoiTap, sectionId: "sect-map", metadata: "{\"geoPointId\":7,\"geoPointTitle\":\"Ruche\"}"), + Event("a1", VisitEventType.AgendaEventTap, metadata: "{\"eventId\":\"ev1\",\"eventTitle\":\"Concert\"}"), + Event("g1", VisitEventType.GameComplete, metadata: "{\"gameType\":\"Puzzle\",\"durationSeconds\":60}"), + Event("g2", VisitEventType.GameComplete, metadata: "{\"gameType\":\"Puzzle\",\"durationSeconds\":40}"), + Event("m1", VisitEventType.MenuItemTap, metadata: "{\"targetSectionId\":\"sect-a\",\"menuItemTitle\":\"Accueil\"}"), + Event("r1", VisitEventType.ArticleRead, sectionId: "sect-a") + ); + db.SaveChanges(); + + var summary = Summarize(db); + + var poi = Assert.Single(summary.TopPois); + Assert.Equal(7, poi.GeoPointId); + Assert.Equal(2, poi.Taps); + Assert.Equal("sect-map", poi.SectionId); + + Assert.Equal("Concert", Assert.Single(summary.TopAgendaEvents).EventTitle); + var game = Assert.Single(summary.GameStats); + Assert.Equal(2, game.Completions); + Assert.Equal(50, game.AvgDurationSeconds); + Assert.Equal("Accueil", Assert.Single(summary.TopMenuItems).MenuItemTitle); + Assert.Equal(1, Assert.Single(summary.TopArticles).Reads); + } + + [Fact] + public void GetSummary_QuizStats_AverageScorePerSection() + { + using var db = DbContextFactory.Create(); + SeedAdvancedInstance(db); + db.VisitEvents.AddRange( + Event("q1", VisitEventType.QuizComplete, "s1", "sect-quiz", "{\"score\":4,\"totalQuestions\":5}"), + Event("q2", VisitEventType.QuizComplete, "s2", "sect-quiz", "{\"score\":2,\"totalQuestions\":5}"), + // Metadata illisible : compté, à zéro, comme avant + Event("q3", VisitEventType.QuizComplete, "s3", "sect-quiz", "pas du json") + ); + db.SaveChanges(); + + var quiz = Assert.Single(Summarize(db).QuizStats); + Assert.Equal("sect-quiz", quiz.SectionId); + Assert.Equal(3, quiz.Completions); + Assert.Equal(2.0, quiz.AvgScore); + } + + [Fact] + public void GetSummary_QrScans_CountsScansWithoutMetadataInTotalOnly() + { + using var db = DbContextFactory.Create(); + SeedAdvancedInstance(db); + db.VisitEvents.AddRange( + Event("qr1", VisitEventType.QrScan, metadata: "{\"valid\":true}"), + Event("qr2", VisitEventType.QrScan, metadata: "{\"valid\":false}"), + Event("qr3", VisitEventType.QrScan) // sans metadata : ni valide ni invalide + ); + db.SaveChanges(); + + var qr = Summarize(db).QrScans; + + Assert.Equal(3, qr.TotalScans); + Assert.Equal(1, qr.ValidScans); + Assert.Equal(1, qr.InvalidScans); + } + + [Fact] + public void GetSummary_HasStatsDisabled_ReturnsEmptySummary() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "Sans stats", HasStats = false }); + db.VisitEvents.Add(Event("e1", VisitEventType.SectionView, sectionId: "sect-a")); + db.SaveChanges(); + + Assert.Equal(0, Summarize(db).TotalSessions); + } + + [Fact] + public void GetSummary_RetentionCapsTheRequestedRange() + { + // Le plan borne l'historique : une plage plus large que la rétention est + // ramenée à la rétention, pas honorée. + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "30 jours", HasStats = true, StatsHistoryDays = 30 }); + db.VisitEvents.AddRange( + Event("e1", VisitEventType.SectionView, "s1", timestamp: DateTime.UtcNow.AddDays(-10)), + Event("e2", VisitEventType.SectionView, "s2", timestamp: DateTime.UtcNow.AddDays(-90)) + ); + db.SaveChanges(); + + var result = BuildControllerStatic(db).GetSummary("i1", DateTime.UtcNow.AddDays(-365), DateTime.UtcNow, null); + var summary = Assert.IsType(Assert.IsType(result).Value); + + Assert.Equal(1, summary.TotalSessions); + } + [Fact] public void GetSummary_TopSections_AggregatesByViewCount() { diff --git a/ManagerService/Controllers/StatsController.cs b/ManagerService/Controllers/StatsController.cs index 2a200a1..c204452 100644 --- a/ManagerService/Controllers/StatsController.cs +++ b/ManagerService/Controllers/StatsController.cs @@ -75,6 +75,29 @@ namespace ManagerService.Controllers } } + /// + /// Metadata (et section) des événements d'un type donné. + /// + /// + /// Ces agrégats-là se lisent dans le JSON de Metadata, donc hors de portée de + /// SQL. On ne remonte que les deux colonnes utiles, et seulement pour le type + /// d'événement concerné — au lieu de la fenêtre entière, colonnes comprises. + /// + private static List<(string Metadata, string SectionId)> MetadataOf( + IQueryable events, VisitEventType type, bool sectionIdRequired = false) + { + var query = events.Where(e => e.EventType == type && e.Metadata != null); + + if (sectionIdRequired) + query = query.Where(e => e.SectionId != null); + + return query + .Select(e => new { e.Metadata, e.SectionId }) + .ToList() + .Select(e => (e.Metadata!, e.SectionId)) + .ToList(); + } + /// Get aggregated statistics for an instance [Authorize(Policy = ManagerService.Service.Security.Policies.Viewer)] [HttpGet("summary")] @@ -113,27 +136,242 @@ namespace ManagerService.Controllers if (!string.IsNullOrEmpty(appType) && Enum.TryParse(appType, ignoreCase: true, out var appTypeFilter)) eventsQuery = eventsQuery.Where(e => e.AppType == appTypeFilter); - var events = eventsQuery.ToList(); - var summary = new StatsSummaryDTO(); // Sessions - summary.TotalSessions = events.Select(e => e.SessionId).Distinct().Count(); + summary.TotalSessions = eventsQuery.Select(e => e.SessionId).Distinct().Count(); - // Avg visit duration (from SectionLeave events with duration) - var leaveEvents = events.Where(e => e.EventType == VisitEventType.SectionLeave && e.DurationSeconds.HasValue).ToList(); - if (leaveEvents.Any()) + // Durée moyenne de visite : somme des durées par session, puis moyenne de + // ces sommes. Une ligne par session remonte, pas une par événement. + var leaveQuery = eventsQuery + .Where(e => e.EventType == VisitEventType.SectionLeave && e.DurationSeconds.HasValue); + + var sessionDurations = leaveQuery + .GroupBy(e => e.SessionId) + .Select(g => g.Sum(e => e.DurationSeconds!.Value)) + .ToList(); + if (sessionDurations.Count > 0) + summary.AvgVisitDurationSeconds = (int)sessionDurations.Average(); + + var sectionDurations = leaveQuery + .Where(e => e.SectionId != null) + .GroupBy(e => e.SectionId!) + .Select(g => new { SectionId = g.Key, Avg = g.Average(e => (double)e.DurationSeconds!.Value) }) + .ToDictionary(x => x.SectionId, x => (int)x.Avg); + + // Top sections + var sectionViews = eventsQuery + .Where(e => e.EventType == VisitEventType.SectionView && e.SectionId != null) + .GroupBy(e => e.SectionId!) + .Select(g => new { SectionId = g.Key, Views = g.Count() }) + .OrderByDescending(x => x.Views) + .Take(10) + .ToList(); + + // Visits by day + summary.VisitsByDay = eventsQuery + .Where(e => e.EventType == VisitEventType.SectionView) + .GroupBy(e => e.Timestamp.Date) + .Select(g => new + { + Day = g.Key, + Total = g.Count(), + Mobile = g.Sum(e => e.AppType == AppType.Mobile ? 1 : 0), + Tablet = g.Sum(e => e.AppType == AppType.Tablet ? 1 : 0) + }) + .OrderBy(x => x.Day) + .ToList() + .Select(x => new DayStatDTO + { + Date = x.Day.ToString("yyyy-MM-dd"), + Total = x.Total, + Mobile = x.Mobile, + Tablet = x.Tablet + }) + .ToList(); + + // AppType distribution (une entrée par session distincte). Le couple + // (session, valeur) est agrégé en SQL ; seule la levée d'ambiguïté d'une + // session qui aurait changé de valeur se fait ici, sur la plus ancienne. + summary.AppTypeDistribution = eventsQuery + .GroupBy(e => new { e.SessionId, e.AppType }) + .Select(g => new { g.Key.SessionId, g.Key.AppType, FirstSeen = g.Min(e => e.Timestamp) }) + .ToList() + .GroupBy(x => x.SessionId) + .Select(g => g.OrderBy(x => x.FirstSeen).First().AppType.ToString()) + .GroupBy(t => t) + .ToDictionary(g => g.Key, g => g.Count()); + + // Les statistiques avancées ne sont pas seulement masquées : elles ne sont + // plus calculées. Les six requêtes ci-dessous ne partent pas du tout sur un + // plan qui n'y donne pas droit. + var quizScores = new List<(string sectionId, double score, int total)>(); + + if (hasAdvancedStats) { - var sessionsWithDuration = leaveEvents - .GroupBy(e => e.SessionId) - .Select(g => g.Sum(e => e.DurationSeconds!.Value)); - summary.AvgVisitDurationSeconds = (int)sessionsWithDuration.Average(); + // Language distribution (une entrée par session distincte) + summary.LanguageDistribution = eventsQuery + .Where(e => e.Language != null) + .GroupBy(e => new { e.SessionId, e.Language }) + .Select(g => new { g.Key.SessionId, g.Key.Language, FirstSeen = g.Min(e => e.Timestamp) }) + .ToList() + .GroupBy(x => x.SessionId) + .Select(g => g.OrderBy(x => x.FirstSeen).First().Language!) + .GroupBy(lang => lang) + .ToDictionary(g => g.Key, g => g.Count()); + + // Top POIs — le regroupement vit dans le JSON de Metadata, donc hors de + // portée de SQL : on ne remonte que les deux colonnes nécessaires. + var poiGroups = new Dictionary(); + foreach (var (metadata, eventSectionId) in MetadataOf(eventsQuery, VisitEventType.MapPoiTap)) + { + try + { + var meta = JsonSerializer.Deserialize(metadata); + if (meta.TryGetProperty("geoPointId", out var idEl) && meta.TryGetProperty("geoPointTitle", out var titleEl)) + { + int id = idEl.GetInt32(); + string title = titleEl.GetString() ?? ""; + string sectionId = eventSectionId ?? ""; + if (poiGroups.TryGetValue(id, out var existing)) + poiGroups[id] = (existing.title, existing.taps + 1, existing.sectionId); + else + poiGroups[id] = (title, 1, sectionId); + } + } + catch { /* skip malformed metadata */ } + } + summary.TopPois = poiGroups + .Select(kv => new PoiStatDTO { GeoPointId = kv.Key, Title = kv.Value.title, Taps = kv.Value.taps, SectionId = kv.Value.sectionId }) + .OrderByDescending(p => p.Taps) + .Take(10) + .ToList(); + + // Top agenda events + var agendaGroups = new Dictionary(); + foreach (var (metadata, _) in MetadataOf(eventsQuery, VisitEventType.AgendaEventTap)) + { + try + { + var meta = JsonSerializer.Deserialize(metadata); + if (meta.TryGetProperty("eventId", out var idEl) && meta.TryGetProperty("eventTitle", out var titleEl)) + { + string id = idEl.GetString() ?? ""; + string title = titleEl.GetString() ?? ""; + if (agendaGroups.TryGetValue(id, out var existing)) + agendaGroups[id] = (existing.title, existing.taps + 1); + else + agendaGroups[id] = (title, 1); + } + } + catch { /* skip */ } + } + summary.TopAgendaEvents = agendaGroups + .Select(kv => new AgendaEventStatDTO { EventId = kv.Key, EventTitle = kv.Value.title, Taps = kv.Value.taps }) + .OrderByDescending(a => a.Taps) + .Take(10) + .ToList(); + + // Quiz stats — assemblées plus bas, une fois les titres de section connus + foreach (var (metadata, quizSectionId) in MetadataOf(eventsQuery, VisitEventType.QuizComplete, sectionIdRequired: true)) + { + try + { + var meta = JsonSerializer.Deserialize(metadata); + double score = meta.TryGetProperty("score", out var scoreEl) ? scoreEl.GetDouble() : 0; + int total = meta.TryGetProperty("totalQuestions", out var totalEl) ? totalEl.GetInt32() : 1; + quizScores.Add((quizSectionId, score, total)); + } + catch { quizScores.Add((quizSectionId, 0.0, 1)); } + } + + // Game stats + var gameGroups = new Dictionary durations)>(); + foreach (var (metadata, _) in MetadataOf(eventsQuery, VisitEventType.GameComplete)) + { + try + { + var meta = JsonSerializer.Deserialize(metadata); + if (meta.TryGetProperty("gameType", out var typeEl)) + { + string gameType = typeEl.GetString() ?? "Unknown"; + int duration = meta.TryGetProperty("durationSeconds", out var dEl) ? dEl.GetInt32() : 0; + if (!gameGroups.ContainsKey(gameType)) + gameGroups[gameType] = (0, new List()); + gameGroups[gameType] = (gameGroups[gameType].completions + 1, gameGroups[gameType].durations.Append(duration).ToList()); + } + } + catch { /* skip */ } + } + summary.GameStats = gameGroups + .Select(kv => new GameStatDTO + { + GameType = kv.Key, + Completions = kv.Value.completions, + AvgDurationSeconds = kv.Value.durations.Any() ? (int)kv.Value.durations.Average() : 0 + }) + .ToList(); + + // Top articles lus + summary.TopArticles = eventsQuery + .Where(e => e.EventType == VisitEventType.ArticleRead && e.SectionId != null) + .GroupBy(e => e.SectionId!) + .Select(g => new ArticleStatDTO { SectionId = g.Key, Reads = g.Count() }) + .OrderByDescending(a => a.Reads) + .Take(10) + .ToList(); + + // Top menu items + var menuGroups = new Dictionary(); + foreach (var (metadata, _) in MetadataOf(eventsQuery, VisitEventType.MenuItemTap)) + { + try + { + var meta = JsonSerializer.Deserialize(metadata); + if (meta.TryGetProperty("targetSectionId", out var idEl)) + { + string id = idEl.GetString() ?? ""; + string title = meta.TryGetProperty("menuItemTitle", out var titleEl) ? titleEl.GetString() ?? "" : id; + if (menuGroups.TryGetValue(id, out var existing)) + menuGroups[id] = (existing.title, existing.taps + 1); + else + menuGroups[id] = (title, 1); + } + } + catch { /* skip */ } + } + summary.TopMenuItems = menuGroups + .Select(kv => new MenuItemStatDTO { TargetSectionId = kv.Key, MenuItemTitle = kv.Value.title, Taps = kv.Value.taps }) + .OrderByDescending(m => m.Taps) + .Take(10) + .ToList(); + + // QR scans — le total compte aussi les scans sans metadata, qui ne sont + // ni valides ni invalides. Compté en SQL, séparément du dépouillement. + var qrQuery = eventsQuery.Where(e => e.EventType == VisitEventType.QrScan); + var validQr = 0; var invalidQr = 0; + foreach (var qrMetadata in qrQuery.Where(e => e.Metadata != null).Select(e => e.Metadata!).ToList()) + { + try + { + var meta = JsonSerializer.Deserialize(qrMetadata); + if (meta.TryGetProperty("valid", out var validEl) && validEl.GetBoolean()) validQr++; + else invalidQr++; + } + catch { invalidQr++; } + } + summary.QrScans = new QrScanStatDTO { TotalScans = qrQuery.Count(), ValidScans = validQr, InvalidScans = invalidQr }; } - // Section title lookup (FR preferred, fallback to first available) - var allSectionIds = events.Where(e => e.SectionId != null).Select(e => e.SectionId!).Distinct().ToList(); + // Titres de section (FR par défaut, sinon la première traduction disponible). + // Seuls les ids réellement affichés sont résolus, pas tous ceux vus. + var titledSectionIds = sectionViews.Select(s => s.SectionId) + .Concat(quizScores.Select(q => q.sectionId)) + .Distinct() + .ToList(); + var sectionTitles = _db.Sections - .Where(s => allSectionIds.Contains(s.Id)) + .Where(s => titledSectionIds.Contains(s.Id)) .Select(s => new { s.Id, s.Title }) .ToList() .ToDictionary( @@ -142,232 +380,28 @@ namespace ManagerService.Controllers ?? s.Title?.FirstOrDefault()?.value ); - // Top sections - var sectionViews = events - .Where(e => e.EventType == VisitEventType.SectionView && e.SectionId != null) - .GroupBy(e => e.SectionId!) - .Select(g => + summary.TopSections = sectionViews + .Select(s => new SectionStatDTO { - var leaveDurations = leaveEvents - .Where(l => l.SectionId == g.Key) - .Select(l => l.DurationSeconds!.Value) - .ToList(); - return new SectionStatDTO - { - SectionId = g.Key, - SectionTitle = sectionTitles.GetValueOrDefault(g.Key), - Views = g.Count(), - AvgDurationSeconds = leaveDurations.Any() ? (int)leaveDurations.Average() : 0 - }; - }) - .OrderByDescending(s => s.Views) - .Take(10) - .ToList(); - summary.TopSections = sectionViews; - - // Visits by day - summary.VisitsByDay = events - .Where(e => e.EventType == VisitEventType.SectionView) - .GroupBy(e => e.Timestamp.Date.ToString("yyyy-MM-dd")) - .Select(g => new DayStatDTO - { - Date = g.Key, - Total = g.Count(), - Mobile = g.Count(e => e.AppType == AppType.Mobile), - Tablet = g.Count(e => e.AppType == AppType.Tablet) - }) - .OrderBy(d => d.Date) - .ToList(); - - // Language distribution (one entry per distinct session) - summary.LanguageDistribution = events - .Where(e => e.Language != null) - .GroupBy(e => e.SessionId) - .Select(g => g.First().Language!) - .GroupBy(lang => lang) - .ToDictionary(g => g.Key, g => g.Count()); - - // AppType distribution (one entry per distinct session) - summary.AppTypeDistribution = events - .GroupBy(e => e.SessionId) - .Select(g => g.First().AppType.ToString()) - .GroupBy(t => t) - .ToDictionary(g => g.Key, g => g.Count()); - - // Top POIs - var poiEvents = events.Where(e => e.EventType == VisitEventType.MapPoiTap && e.Metadata != null).ToList(); - var poiGroups = new Dictionary(); - foreach (var ev in poiEvents) - { - try - { - var meta = JsonSerializer.Deserialize(ev.Metadata!); - if (meta.TryGetProperty("geoPointId", out var idEl) && meta.TryGetProperty("geoPointTitle", out var titleEl)) - { - int id = idEl.GetInt32(); - string title = titleEl.GetString() ?? ""; - string sectionId = ev.SectionId ?? ""; - if (poiGroups.TryGetValue(id, out var existing)) - poiGroups[id] = (existing.title, existing.taps + 1, existing.sectionId); - else - poiGroups[id] = (title, 1, sectionId); - } - } - catch { /* skip malformed metadata */ } - } - summary.TopPois = poiGroups - .Select(kv => new PoiStatDTO { GeoPointId = kv.Key, Title = kv.Value.title, Taps = kv.Value.taps, SectionId = kv.Value.sectionId }) - .OrderByDescending(p => p.Taps) - .Take(10) - .ToList(); - - // Top agenda events - var agendaEvents = events.Where(e => e.EventType == VisitEventType.AgendaEventTap && e.Metadata != null).ToList(); - var agendaGroups = new Dictionary(); - foreach (var ev in agendaEvents) - { - try - { - var meta = JsonSerializer.Deserialize(ev.Metadata!); - if (meta.TryGetProperty("eventId", out var idEl) && meta.TryGetProperty("eventTitle", out var titleEl)) - { - string id = idEl.GetString() ?? ""; - string title = titleEl.GetString() ?? ""; - if (agendaGroups.TryGetValue(id, out var existing)) - agendaGroups[id] = (existing.title, existing.taps + 1); - else - agendaGroups[id] = (title, 1); - } - } - catch { /* skip */ } - } - summary.TopAgendaEvents = agendaGroups - .Select(kv => new AgendaEventStatDTO { EventId = kv.Key, EventTitle = kv.Value.title, Taps = kv.Value.taps }) - .OrderByDescending(a => a.Taps) - .Take(10) - .ToList(); - - // Quiz stats - var quizEvents = events.Where(e => e.EventType == VisitEventType.QuizComplete && e.SectionId != null && e.Metadata != null).ToList(); - summary.QuizStats = quizEvents - .GroupBy(e => e.SectionId!) - .Select(g => - { - var scores = g.Select(e => - { - try - { - var meta = JsonSerializer.Deserialize(e.Metadata!); - double score = meta.TryGetProperty("score", out var s) ? s.GetDouble() : 0; - int total = meta.TryGetProperty("totalQuestions", out var t) ? t.GetInt32() : 1; - return (score, total); - } - catch { return (score: 0.0, total: 1); } - }).ToList(); - return new QuizStatDTO - { - SectionId = g.Key, - SectionTitle = sectionTitles.GetValueOrDefault(g.Key), - Completions = g.Count(), - AvgScore = scores.Any() ? scores.Average(s => s.score) : 0, - TotalQuestions = scores.Any() ? (int)scores.Average(s => s.total) : 0 - }; + SectionId = s.SectionId, + SectionTitle = sectionTitles.GetValueOrDefault(s.SectionId), + Views = s.Views, + AvgDurationSeconds = sectionDurations.GetValueOrDefault(s.SectionId) }) .ToList(); - // Game stats - var gameEvents = events.Where(e => e.EventType == VisitEventType.GameComplete && e.Metadata != null).ToList(); - var gameGroups = new Dictionary durations)>(); - foreach (var ev in gameEvents) - { - try + summary.QuizStats = quizScores + .GroupBy(q => q.sectionId) + .Select(g => new QuizStatDTO { - var meta = JsonSerializer.Deserialize(ev.Metadata!); - if (meta.TryGetProperty("gameType", out var typeEl)) - { - string gameType = typeEl.GetString() ?? "Unknown"; - int duration = meta.TryGetProperty("durationSeconds", out var dEl) ? dEl.GetInt32() : 0; - if (!gameGroups.ContainsKey(gameType)) - gameGroups[gameType] = (0, new List()); - gameGroups[gameType] = (gameGroups[gameType].completions + 1, gameGroups[gameType].durations.Append(duration).ToList()); - } - } - catch { /* skip */ } - } - summary.GameStats = gameGroups - .Select(kv => new GameStatDTO - { - GameType = kv.Key, - Completions = kv.Value.completions, - AvgDurationSeconds = kv.Value.durations.Any() ? (int)kv.Value.durations.Average() : 0 + SectionId = g.Key, + SectionTitle = sectionTitles.GetValueOrDefault(g.Key), + Completions = g.Count(), + AvgScore = g.Average(q => q.score), + TotalQuestions = (int)g.Average(q => q.total) }) .ToList(); - // Top articles lus - summary.TopArticles = events - .Where(e => e.EventType == VisitEventType.ArticleRead && e.SectionId != null) - .GroupBy(e => e.SectionId!) - .Select(g => new ArticleStatDTO { SectionId = g.Key, Reads = g.Count() }) - .OrderByDescending(a => a.Reads) - .Take(10) - .ToList(); - - // Top menu items - var menuEvents = events.Where(e => e.EventType == VisitEventType.MenuItemTap && e.Metadata != null).ToList(); - var menuGroups = new Dictionary(); - foreach (var ev in menuEvents) - { - try - { - var meta = JsonSerializer.Deserialize(ev.Metadata!); - if (meta.TryGetProperty("targetSectionId", out var idEl)) - { - string id = idEl.GetString() ?? ""; - string title = meta.TryGetProperty("menuItemTitle", out var titleEl) ? titleEl.GetString() ?? "" : id; - if (menuGroups.TryGetValue(id, out var existing)) - menuGroups[id] = (existing.title, existing.taps + 1); - else - menuGroups[id] = (title, 1); - } - } - catch { /* skip */ } - } - summary.TopMenuItems = menuGroups - .Select(kv => new MenuItemStatDTO { TargetSectionId = kv.Key, MenuItemTitle = kv.Value.title, Taps = kv.Value.taps }) - .OrderByDescending(m => m.Taps) - .Take(10) - .ToList(); - - // QR scans - var qrEvents = events.Where(e => e.EventType == VisitEventType.QrScan).ToList(); - var validQr = 0; var invalidQr = 0; - foreach (var ev in qrEvents) - { - try - { - if (ev.Metadata != null) - { - var meta = JsonSerializer.Deserialize(ev.Metadata); - if (meta.TryGetProperty("valid", out var validEl) && validEl.GetBoolean()) validQr++; - else invalidQr++; - } - } - catch { invalidQr++; } - } - summary.QrScans = new QrScanStatDTO { TotalScans = qrEvents.Count, ValidScans = validQr, InvalidScans = invalidQr }; - - if (!hasAdvancedStats) - { - summary.LanguageDistribution = new Dictionary(); - summary.TopPois = new List(); - summary.TopAgendaEvents = new List(); - summary.QuizStats = new List(); - summary.GameStats = new List(); - summary.TopArticles = new List(); - summary.TopMenuItems = new List(); - summary.QrScans = new QrScanStatDTO { TotalScans = 0, ValidScans = 0, InvalidScans = 0 }; - } - return Ok(summary); } catch (Exception ex)