Initial commit
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
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;
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a paginated, filterable table of all platform users across every tenant
|
||||
/// with their last-login date and account status (SuperAdmin only).
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Platform-wide summary KPI stats (total users, active users, never logged in,
|
||||
/// inactive 30+ days) are computed from a <em>separate</em> un-filtered base query
|
||||
/// rather than the current filter query so the header cards always reflect
|
||||
/// platform totals regardless of which filter is active.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All queries use <c>IgnoreQueryFilters()</c> so SuperAdmins see users from all
|
||||
/// companies, including those in inactive or deleted tenants.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a live view of users who have been active within the last 15 minutes,
|
||||
/// sourced from the in-memory <see cref="IOnlineUserTracker"/> service.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Exports the current user-activity query result as a UTF-8 CSV file for
|
||||
/// offline analysis in Excel or similar tools.
|
||||
/// <para>
|
||||
/// Applies the same filter logic as <see cref="Index"/> (minus sorting and
|
||||
/// pagination) and streams the full result set. Column values that contain
|
||||
/// commas, quotes, or newlines are wrapped in double-quotes via
|
||||
/// <see cref="CsvEscape"/> to produce a valid RFC 4180 CSV.
|
||||
/// The filename is date-stamped (<c>user-activity-yyyyMMdd.csv</c>) so multiple
|
||||
/// exports on different days do not overwrite each other when saved to a folder.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<IActionResult> 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps <paramref name="value"/> 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
Reference in New Issue
Block a user