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