using System.Globalization; using System.Net; using System.Web; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Sessions { /// /// Per-tenant dashboard stats. Returns the same numbers regardless of how the /// client has paginated the session list — the aggregation runs server-side /// over a SessionsIndex window. /// public class GetSessionStatsFunction { private const int DefaultDays = 7; private const int MaxDays = 365; private readonly ILogger _logger; private readonly ISessionRepository _sessionRepo; public GetSessionStatsFunction(ILogger logger, ISessionRepository sessionRepo) { _logger = logger; _sessionRepo = sessionRepo; } // Route is "stats/sessions" — NOT "sessions/stats". Azure Functions' route // matcher does NOT honour literal-vs-parametric specificity the way ASP.NET // attribute routing does: a literal "sessions/stats" gets eaten by the // sibling GetSessionFunction's "sessions/{sessionId}" with sessionId="stats", // which 404s when "stats" fails GUID validation. Verified live via App // Insights (Functions.GetSession picked up /api/sessions/stats). [Function("GetSessionStats")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "stats/sessions")] HttpRequestData req) { try { // Authentication + MemberRead authorization enforced by PolicyEnforcementMiddleware. var tenantId = TenantHelper.GetTenantId(req); var query = HttpUtility.ParseQueryString(req.Url.Query ?? string.Empty); if (!TryParseDays(query["days"], out var days, out var error)) { var bad = req.CreateResponse(HttpStatusCode.BadRequest); await bad.WriteAsJsonAsync(new { success = false, message = error }); return bad; } _logger.LogInformation( "Computing session stats (tenant={TenantId}, days={Days})", tenantId, days); var stats = await _sessionRepo.GetSessionStatsAsync(tenantId, days); return await req.OkAsync(new SessionStatsResponse { Success = true, Stats = stats }); } catch (Exception ex) { _logger.LogError(ex, "Error computing session stats"); var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); await errorResponse.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); return errorResponse; } } internal static bool TryParseDays(string? raw, out int days, out string? error) { days = DefaultDays; error = null; if (string.IsNullOrEmpty(raw)) return true; if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || parsed < 1 || parsed > MaxDays) { error = $"days must be a positive integer between 1 and {MaxDays}"; return false; } days = parsed; return true; } } }