275 lines
12 KiB
C#
275 lines
12 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 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;
|
|
}
|
|
|
|
/// <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>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");
|
|
|
|
var fromDate = (from ?? DateTime.UtcNow.AddDays(-30)).ToUniversalTime();
|
|
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 events = eventsQuery.ToList();
|
|
|
|
var summary = new StatsSummaryDTO();
|
|
|
|
// Sessions
|
|
summary.TotalSessions = events.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())
|
|
{
|
|
var sessionsWithDuration = leaveEvents
|
|
.GroupBy(e => e.SessionId)
|
|
.Select(g => g.Sum(e => e.DurationSeconds!.Value));
|
|
summary.AvgVisitDurationSeconds = (int)sessionsWithDuration.Average();
|
|
}
|
|
|
|
// Top sections
|
|
var sectionViews = events
|
|
.Where(e => e.EventType == VisitEventType.SectionView && e.SectionId != null)
|
|
.GroupBy(e => e.SectionId!)
|
|
.Select(g =>
|
|
{
|
|
var leaveDurations = leaveEvents
|
|
.Where(l => l.SectionId == g.Key)
|
|
.Select(l => l.DurationSeconds!.Value)
|
|
.ToList();
|
|
return new SectionStatDTO
|
|
{
|
|
SectionId = g.Key,
|
|
SectionTitle = g.Key, // apps can enrich with section titles client-side
|
|
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
|
|
summary.LanguageDistribution = events
|
|
.Where(e => e.Language != null)
|
|
.GroupBy(e => e.Language!)
|
|
.ToDictionary(g => g.Key, g => g.Count());
|
|
|
|
// AppType distribution
|
|
summary.AppTypeDistribution = events
|
|
.GroupBy(e => e.AppType.ToString())
|
|
.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<int, (string title, int taps, string sectionId)>();
|
|
foreach (var ev in poiEvents)
|
|
{
|
|
try
|
|
{
|
|
var meta = JsonSerializer.Deserialize<JsonElement>(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<string, (string title, int taps)>();
|
|
foreach (var ev in agendaEvents)
|
|
{
|
|
try
|
|
{
|
|
var meta = JsonSerializer.Deserialize<JsonElement>(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<JsonElement>(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 = g.Key,
|
|
Completions = g.Count(),
|
|
AvgScore = scores.Any() ? scores.Average(s => s.score) : 0,
|
|
TotalQuestions = scores.Any() ? (int)scores.Average(s => s.total) : 0
|
|
};
|
|
})
|
|
.ToList();
|
|
|
|
// Game stats
|
|
var gameEvents = events.Where(e => e.EventType == VisitEventType.GameComplete && e.Metadata != null).ToList();
|
|
var gameGroups = new Dictionary<string, (int completions, List<int> durations)>();
|
|
foreach (var ev in gameEvents)
|
|
{
|
|
try
|
|
{
|
|
var meta = JsonSerializer.Deserialize<JsonElement>(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<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();
|
|
|
|
return Ok(summary);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return StatusCode(500, ex.Message);
|
|
}
|
|
}
|
|
}
|
|
}
|