Thomas Fransolet a3bf9523e3 GetSummary agrege en SQL au lieu de charger 13 mois en memoire
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 <noreply@anthropic.com>
2026-08-12 15:04:39 +02:00

414 lines
20 KiB
C#

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);
/// <summary>Track a single visit event (anonymous)</summary>
[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<VisitEventType>(dto.eventType, ignoreCase: true, out var eventType))
return BadRequest($"Unknown eventType: {dto.eventType}");
if (!Enum.TryParse<AppType>(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);
}
}
/// <summary>
/// Metadata (et section) des événements d'un type donné.
/// </summary>
/// <remarks>
/// Ces agrégats-là se lisent dans le JSON de <c>Metadata</c>, 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.
/// </remarks>
private static List<(string Metadata, string SectionId)> MetadataOf(
IQueryable<VisitEvent> 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();
}
/// <summary>Get aggregated statistics for an instance</summary>
[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>(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());
// 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<int, (string title, int taps, string sectionId)>();
foreach (var (metadata, eventSectionId) in MetadataOf(eventsQuery, VisitEventType.MapPoiTap))
{
try
{
var meta = JsonSerializer.Deserialize<JsonElement>(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<string, (string title, int taps)>();
foreach (var (metadata, _) in MetadataOf(eventsQuery, VisitEventType.AgendaEventTap))
{
try
{
var meta = JsonSerializer.Deserialize<JsonElement>(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<JsonElement>(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<string, (int completions, List<int> durations)>();
foreach (var (metadata, _) in MetadataOf(eventsQuery, VisitEventType.GameComplete))
{
try
{
var meta = JsonSerializer.Deserialize<JsonElement>(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<int>());
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<string, (string title, int taps)>();
foreach (var (metadata, _) in MetadataOf(eventsQuery, VisitEventType.MenuItemTap))
{
try
{
var meta = JsonSerializer.Deserialize<JsonElement>(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<JsonElement>(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);
}
}
}
}