1cb7a8ca4a
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers, routing all data access through IUnitOfWork. Added IPlainRepository<T> for the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote) that intentionally don't extend BaseEntity and therefore can't use the constrained IRepository<T>. Added permanent-exception comments to the 18 controllers that legitimately retain direct DbContext access (Identity infra, cross-tenant platform ops, bulk streaming exports). Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup gate that reflects over every Controller subclass and throws at boot if any non-exempt controller injects ApplicationDbContext. The app cannot start with a violation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
303 lines
13 KiB
C#
303 lines
13 KiB
C#
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;
|
|
}
|
|
|
|
/// <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; }
|
|
}
|