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;
}
/// 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);
}
}
/// 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");
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, 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();
foreach (var ev in poiEvents)
{
try
{
var meta = JsonSerializer.Deserialize(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();
foreach (var ev in agendaEvents)
{
try
{
var meta = JsonSerializer.Deserialize(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(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 durations)>();
foreach (var ev in gameEvents)
{
try
{
var meta = JsonSerializer.Deserialize(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());
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);
}
}
}
}