using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NSwag.Annotations; namespace ManagerService.Controllers { [ApiController, Route("api/[controller]")] [OpenApiTag("Stats", Description = "Visit statistics tracking and summary")] public class StatsController : ControllerBase { private readonly MyInfoMateDbContext _db; IHexIdGeneratorService _idService = new HexIdGeneratorService(); public StatsController(MyInfoMateDbContext db) { _db = db; } private string? GetCallerInstanceId() => User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value; private bool IsSuperAdmin() => User.HasClaim(ManagerService.Service.Security.ClaimTypes.Permission, ManagerService.Service.Security.Permissions.SuperAdmin); /// Track a single visit event (anonymous) [AllowAnonymous] [HttpPost("event")] [ProducesResponseType(204)] [ProducesResponseType(typeof(string), 400)] public IActionResult TrackEvent([FromBody] VisitEventDTO dto) { try { if (dto == null || string.IsNullOrEmpty(dto.instanceId)) return BadRequest("instanceId is required"); if (!Enum.TryParse(dto.eventType, ignoreCase: true, out var eventType)) return BadRequest($"Unknown eventType: {dto.eventType}"); if (!Enum.TryParse(dto.appType, ignoreCase: true, out var appType)) appType = AppType.Mobile; var visitEvent = new VisitEvent { Id = _idService.GenerateHexId(), InstanceId = dto.instanceId, ConfigurationId = dto.configurationId, SectionId = dto.sectionId, SessionId = dto.sessionId ?? "unknown", EventType = eventType, AppType = appType, Language = dto.language, DurationSeconds = dto.durationSeconds, Metadata = dto.metadata, Timestamp = dto.timestamp?.ToUniversalTime() ?? DateTime.UtcNow }; _db.VisitEvents.Add(visitEvent); _db.SaveChanges(); return NoContent(); } catch (Exception ex) { return StatusCode(500, ex.Message); } } /// /// 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")] [ProducesResponseType(typeof(StatsSummaryDTO), 200)] [ProducesResponseType(typeof(string), 400)] public IActionResult GetSummary([FromQuery] string instanceId, [FromQuery] DateTime? from, [FromQuery] DateTime? to, [FromQuery] string? appType) { try { if (string.IsNullOrEmpty(instanceId)) return BadRequest("instanceId is required"); if (!IsSuperAdmin() && instanceId != GetCallerInstanceId()) return Forbid(); var instance = _db.Instances .FirstOrDefault(i => i.Id == instanceId); if (instance != null && !instance.HasStats) return Ok(new StatsSummaryDTO()); var historyDays = instance?.StatsHistoryDays ?? 30; var hasAdvancedStats = instance?.HasAdvancedStats ?? false; var maxFrom = historyDays > 0 ? DateTime.UtcNow.AddDays(-historyDays) : DateTime.MinValue; var requestedFrom = (from ?? DateTime.UtcNow.AddDays(-30)).ToUniversalTime(); var fromDate = historyDays > 0 && requestedFrom < maxFrom ? maxFrom : requestedFrom; var toDate = (to ?? DateTime.UtcNow).ToUniversalTime(); var eventsQuery = _db.VisitEvents .Where(e => e.InstanceId == instanceId && e.Timestamp >= fromDate && e.Timestamp <= toDate); if (!string.IsNullOrEmpty(appType) && Enum.TryParse(appType, ignoreCase: true, out var appTypeFilter)) eventsQuery = eventsQuery.Where(e => e.AppType == appTypeFilter); var summary = new StatsSummaryDTO(); // Sessions summary.TotalSessions = eventsQuery.Select(e => e.SessionId).Distinct().Count(); // 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()); // Sessions ayant utilisé le vocal. Le marqueur est dans le JSON de Metadata, // donc hors de portée d'un opérateur SQL portable : on filtre en base sur la // sous-chaîne — ce qui élimine l'immense majorité des lignes — et on ne // remonte que SessionId, jamais la fenêtre entière. summary.VoiceSessions = eventsQuery .Where(e => e.Metadata != null && e.Metadata.Contains("\"voice\":true")) .Select(e => e.SessionId) .Distinct() .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) { // 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 }; } // 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 => titledSectionIds.Contains(s.Id)) .Select(s => new { s.Id, s.Title }) .ToList() .ToDictionary( s => s.Id, s => s.Title?.FirstOrDefault(t => t.language == "fr")?.value ?? s.Title?.FirstOrDefault()?.value ); summary.TopSections = sectionViews .Select(s => new SectionStatDTO { SectionId = s.SectionId, SectionTitle = sectionTitles.GetValueOrDefault(s.SectionId), Views = s.Views, AvgDurationSeconds = sectionDurations.GetValueOrDefault(s.SectionId) }) .ToList(); summary.QuizStats = quizScores .GroupBy(q => q.sectionId) .Select(g => new QuizStatDTO { 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(); return Ok(summary); } catch (Exception ex) { return StatusCode(500, ex.Message); } } } }