using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; using PowderCoating.Web.Services; using System.Text; namespace PowderCoating.Web.Controllers; // Intentional exception: queries ASP.NET Identity ApplicationUser across all tenants with Include(u => u.Company); Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class UserActivityController : Controller { private readonly ApplicationDbContext _db; private readonly IOnlineUserTracker _tracker; public UserActivityController(ApplicationDbContext db, IOnlineUserTracker tracker) { _db = db; _tracker = tracker; } /// /// Renders a paginated, filterable table of all platform users across every tenant /// with their last-login date and account status (SuperAdmin only). /// /// Filters are composable: company, role, active/inactive status, login-age bucket, /// and free-text search can all be combined. Sorting is handled via a switch /// expression; the default sort puts users who have never logged in first (NULL /// last-login dates sorted before oldest dates), then ascending by date, so the /// most at-risk accounts appear at the top by default. /// /// /// Platform-wide summary KPI stats (total users, active users, never logged in, /// inactive 30+ days) are computed from a separate un-filtered base query /// rather than the current filter query so the header cards always reflect /// platform totals regardless of which filter is active. /// /// /// All queries use IgnoreQueryFilters() so SuperAdmins see users from all /// companies, including those in inactive or deleted tenants. /// /// public async Task Index( int? companyId, string? role, string? activeStatus, string? loginAge, string? search, string sortCol = "LastLogin", string sortDir = "asc", int page = 1, int pageSize = 50) { pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50; page = Math.Max(1, page); var query = _db.Users.AsNoTracking().IgnoreQueryFilters() .Include(u => u.Company) .AsQueryable(); if (companyId.HasValue) query = query.Where(u => u.CompanyId == companyId); if (!string.IsNullOrWhiteSpace(role)) query = query.Where(u => u.CompanyRole == role); if (activeStatus == "active") query = query.Where(u => u.IsActive); else if (activeStatus == "inactive") query = query.Where(u => !u.IsActive); if (!string.IsNullOrWhiteSpace(search)) query = query.Where(u => u.FirstName.Contains(search) || u.LastName.Contains(search) || u.Email!.Contains(search)); var now = DateTime.UtcNow; if (loginAge == "never") query = query.Where(u => u.LastLoginDate == null); else if (loginAge == "90plus") query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-90)); else if (loginAge == "30plus") query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-30)); else if (loginAge == "7plus") query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-7)); else if (loginAge == "recent") query = query.Where(u => u.LastLoginDate != null && u.LastLoginDate >= now.AddDays(-7)); query = (sortCol, sortDir) switch { ("Company", "asc") => query.OrderBy(u => u.Company!.CompanyName), ("Company", "desc") => query.OrderByDescending(u => u.Company!.CompanyName), ("Name", "asc") => query.OrderBy(u => u.LastName).ThenBy(u => u.FirstName), ("Name", "desc") => query.OrderByDescending(u => u.LastName).ThenBy(u => u.FirstName), ("Role", "asc") => query.OrderBy(u => u.CompanyRole), ("Role", "desc") => query.OrderByDescending(u => u.CompanyRole), ("LastLogin", "desc") => query.OrderByDescending(u => u.LastLoginDate), ("Created", "asc") => query.OrderBy(u => u.CreatedAt), ("Created", "desc") => query.OrderByDescending(u => u.CreatedAt), _ => query.OrderBy(u => u.LastLoginDate == null).ThenBy(u => u.LastLoginDate) }; var totalCount = await query.CountAsync(); var users = await query .Skip((page - 1) * pageSize) .Take(pageSize) .Select(u => new UserActivityRow { Id = u.Id, CompanyId = u.CompanyId, CompanyName = u.Company != null ? u.Company.CompanyName : "—", FullName = u.FirstName + " " + u.LastName, Email = u.Email ?? string.Empty, CompanyRole = u.CompanyRole ?? "—", IsActive = u.IsActive, LastLoginDate = u.LastLoginDate, CreatedAt = u.CreatedAt, DaysSinceLogin = u.LastLoginDate.HasValue ? (int)(now - u.LastLoginDate.Value).TotalDays : (int?)null }) .ToListAsync(); // Summary stats — CountAsync queries instead of loading all users into memory var statsBase = _db.Users.AsNoTracking().IgnoreQueryFilters(); var cutoff30 = now.AddDays(-30); ViewBag.TotalUsers = await statsBase.CountAsync(); ViewBag.ActiveUsers = await statsBase.CountAsync(u => u.IsActive); ViewBag.NeverLoggedIn = await statsBase.CountAsync(u => u.LastLoginDate == null); ViewBag.Inactive30 = await statsBase.CountAsync(u => u.LastLoginDate == null || u.LastLoginDate < cutoff30); // Company list for filter dropdown ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters() .Where(c => !c.IsDeleted) .OrderBy(c => c.CompanyName) .Select(c => new { c.Id, c.CompanyName }) .ToListAsync(); ViewBag.CompanyIdFilter = companyId; ViewBag.RoleFilter = role; ViewBag.ActiveStatusFilter = activeStatus; ViewBag.LoginAgeFilter = loginAge; ViewBag.Search = search; ViewBag.SortCol = sortCol; ViewBag.SortDir = sortDir; ViewBag.Page = page; ViewBag.PageSize = pageSize; ViewBag.TotalCount = totalCount; ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize); return View(users); } /// /// Renders a live view of users who have been active within the last 15 minutes, /// sourced from the in-memory service. /// /// The tracker only holds lightweight session data (userId, email, display name, /// current path, IP). Company name and role are enriched from the database in a /// single batch query keyed by user IDs, avoiding N+1 lookups. The window of 15 /// minutes is hardcoded here (passed to the tracker) and echoed in ViewBag so the /// view can display "active in last 15 min" without embedding a magic number in /// the template. /// /// public async Task Online() { var active = _tracker.GetActiveUsers(windowMinutes: 15); // Enrich with company name + role from DB (batch query by user IDs) var userIds = active.Select(e => e.UserId).ToList(); var dbUsers = await _db.Users.AsNoTracking().IgnoreQueryFilters() .Where(u => userIds.Contains(u.Id)) .Include(u => u.Company) .Select(u => new { u.Id, u.CompanyRole, CompanyName = u.Company != null ? u.Company.CompanyName : null, u.CompanyId }) .ToDictionaryAsync(u => u.Id); var rows = active.Select(e => { dbUsers.TryGetValue(e.UserId, out var db); return new OnlineUserRow { UserId = e.UserId, Email = e.Email, DisplayName = e.DisplayName, IsSuperAdmin = e.IsSuperAdmin, CompanyName = db?.CompanyName ?? e.CompanyName ?? (e.IsSuperAdmin ? "Platform" : "—"), CompanyId = db?.CompanyId ?? e.CompanyId, Role = e.IsSuperAdmin ? "SuperAdmin" : (db?.CompanyRole ?? "—"), CurrentPath = e.CurrentPath, IpAddress = e.IpAddress, LastSeen = e.LastSeen }; }).ToList(); ViewBag.WindowMinutes = 15; return View(rows); } /// /// Exports the current user-activity query result as a UTF-8 CSV file for /// offline analysis in Excel or similar tools. /// /// Applies the same filter logic as (minus sorting and /// pagination) and streams the full result set. Column values that contain /// commas, quotes, or newlines are wrapped in double-quotes via /// to produce a valid RFC 4180 CSV. /// The filename is date-stamped (user-activity-yyyyMMdd.csv) so multiple /// exports on different days do not overwrite each other when saved to a folder. /// /// public async Task ExportCsv( int? companyId, string? role, string? activeStatus, string? loginAge, string? search) { var query = _db.Users.AsNoTracking().IgnoreQueryFilters() .Include(u => u.Company) .AsQueryable(); if (companyId.HasValue) query = query.Where(u => u.CompanyId == companyId); if (!string.IsNullOrWhiteSpace(role)) query = query.Where(u => u.CompanyRole == role); if (activeStatus == "active") query = query.Where(u => u.IsActive); else if (activeStatus == "inactive") query = query.Where(u => !u.IsActive); if (!string.IsNullOrWhiteSpace(search)) query = query.Where(u => u.FirstName.Contains(search) || u.LastName.Contains(search) || u.Email!.Contains(search)); var now = DateTime.UtcNow; if (loginAge == "never") query = query.Where(u => u.LastLoginDate == null); else if (loginAge == "90plus") query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-90)); else if (loginAge == "30plus") query = query.Where(u => u.LastLoginDate == null || u.LastLoginDate < now.AddDays(-30)); else if (loginAge == "recent") query = query.Where(u => u.LastLoginDate != null && u.LastLoginDate >= now.AddDays(-7)); var users = await query.OrderBy(u => u.Company!.CompanyName).ThenBy(u => u.LastName).ToListAsync(); var sb = new StringBuilder(); sb.AppendLine("Company,Name,Email,Role,Active,Last Login,Days Since Login,Created"); foreach (var u in users) { var days = u.LastLoginDate.HasValue ? (int)(now - u.LastLoginDate.Value).TotalDays : -1; sb.AppendLine(string.Join(",", CsvEscape(u.Company?.CompanyName ?? ""), CsvEscape(u.FirstName + " " + u.LastName), CsvEscape(u.Email ?? ""), CsvEscape(u.CompanyRole ?? ""), u.IsActive ? "Yes" : "No", u.LastLoginDate.HasValue ? u.LastLoginDate.Value.ToString("MM/dd/yyyy") : "Never", days >= 0 ? days.ToString() : "—", u.CreatedAt.ToString("MM/dd/yyyy"))); } var bytes = Encoding.UTF8.GetBytes(sb.ToString()); return File(bytes, "text/csv", $"user-activity-{DateTime.UtcNow:yyyyMMdd}.csv"); } /// /// Wraps in double-quotes and escapes any internal /// double-quotes by doubling them, conforming to RFC 4180 CSV quoting rules. /// Values that contain no commas, quotes, or newlines are returned as-is to /// keep the output clean and readable. /// private static string CsvEscape(string value) => value.Contains(',') || value.Contains('"') || value.Contains('\n') ? $"\"{value.Replace("\"", "\"\"")}\"" : value; } public class OnlineUserRow { public string UserId { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty; public bool IsSuperAdmin { get; set; } public string? CompanyName { get; set; } public int? CompanyId { get; set; } public string? Role { get; set; } public string? CurrentPath { get; set; } public string? IpAddress { get; set; } public DateTime LastSeen { get; set; } } public class UserActivityRow { public string Id { get; set; } = string.Empty; public int CompanyId { get; set; } public string CompanyName { get; set; } = string.Empty; public string FullName { get; set; } = string.Empty; public string Email { get; set; } = string.Empty; public string CompanyRole { get; set; } = string.Empty; public bool IsActive { get; set; } public DateTime? LastLoginDate { get; set; } public DateTime CreatedAt { get; set; } public int? DaysSinceLogin { get; set; } }