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)