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);
}
}
}
}