Thomas Fransolet e3ef4137a5 Canal vocal des stats, et IsAutoTriggered renommé IsVisitorQuestion
VoiceSessions : nombre de sessions ayant utilisé le vocal au moins une fois, lu
dans le JSON de Metadata. Volontairement hors d'AppTypeDistribution — le vocal
n'est pas une plateforme mais un mode d'interaction : un visiteur passe des
lunettes à l'écran dans une même session, or la distribution compte une entrée
par session tranchée sur l'événement le plus ancien. Le chiffre aurait dépendu de
l'ordre dans lequel le visiteur a touché ses appareils. Colonne Metadata déjà
existante : aucune migration, le gel du lot B tient.

Le test InMemory ne prouve rien ici — le provider évalue le filtre côté client,
donc une requête intraduisible passerait au vert. Un test Postgres l'accompagne.

IsAutoTriggered devient IsVisitorQuestion (défaut true). Le nom d'hier ne
couvrait qu'un des deux cas : le vrai sens n'est pas « déclenché
automatiquement » mais « ce tour n'est pas une question de visiteur », ce qui
vaut aussi pour l'aperçu de conversation, où c'est le gestionnaire qui teste sa
personnalité. Renommé pendant qu'un seul commit en dépendait. Le défaut à true
est délibéré : une app visiteur publiée qui n'envoie pas le champ continue de
journaliser, et un test le fixe.

dotnet test : 204 passés, 14 sautés, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:32:54 +02:00

424 lines
21 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());
// 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<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);
}
}
}
}