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