Initial commit
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// SuperAdmin-only viewer for the application audit log, which records who changed
|
||||
/// what entity and when. Audit log records are never soft-deleted or modified by
|
||||
/// the application — they are append-only by design to maintain an unambiguous
|
||||
/// trail of changes across all tenants.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class AuditLogController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly ILogger<AuditLogController> _logger;
|
||||
|
||||
private readonly IPlatformSettingsService _platformSettings;
|
||||
|
||||
public AuditLogController(ApplicationDbContext db, ILogger<AuditLogController> logger,
|
||||
IPlatformSettingsService platformSettings)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
_platformSettings = platformSettings;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a paginated, filterable audit log across all companies. Supports
|
||||
/// free-text search on username, entity description, and entity Id; and
|
||||
/// structured filters for entity type, action verb, company, and date range.
|
||||
/// <para>
|
||||
/// Date filters are converted to UTC before querying because audit log timestamps
|
||||
/// are stored in UTC. The <c>to</c> date filter adds one day so that entries
|
||||
/// timestamped at any point during the selected end-date are included.
|
||||
/// Entity-type and action dropdowns are populated from distinct values in the
|
||||
/// table (not from a static enum) so the UI always reflects exactly what types
|
||||
/// and actions are present in the log.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index(
|
||||
string? search,
|
||||
string? entityType,
|
||||
[FromQuery] string? action,
|
||||
int? companyId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
int page = 1,
|
||||
int pageSize = 50)
|
||||
{
|
||||
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
|
||||
page = Math.Max(1, page);
|
||||
|
||||
var query = _db.AuditLogs.AsNoTracking();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(a =>
|
||||
a.UserName.Contains(search) ||
|
||||
(a.EntityDescription != null && a.EntityDescription.Contains(search)) ||
|
||||
(a.EntityId != null && a.EntityId.Contains(search)));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(entityType))
|
||||
query = query.Where(a => a.EntityType == entityType);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(action))
|
||||
query = query.Where(a => a.Action == action);
|
||||
|
||||
if (companyId.HasValue)
|
||||
query = query.Where(a => a.CompanyId == companyId);
|
||||
|
||||
if (from.HasValue)
|
||||
query = query.Where(a => a.Timestamp >= from.Value.ToUniversalTime());
|
||||
|
||||
if (to.HasValue)
|
||||
query = query.Where(a => a.Timestamp < to.Value.ToUniversalTime().AddDays(1));
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
var logs = await query
|
||||
.OrderByDescending(a => a.Timestamp)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
// Populate filter dropdowns from distinct values
|
||||
ViewBag.EntityTypes = await _db.AuditLogs.AsNoTracking()
|
||||
.Select(a => a.EntityType).Distinct().OrderBy(x => x).ToListAsync();
|
||||
ViewBag.Companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName }).ToListAsync();
|
||||
|
||||
ViewBag.Search = search;
|
||||
ViewBag.EntityType = entityType;
|
||||
ViewBag.Action = action;
|
||||
ViewBag.CompanyId = companyId;
|
||||
ViewBag.From = from?.ToString("yyyy-MM-dd");
|
||||
ViewBag.To = to?.ToString("yyyy-MM-dd");
|
||||
ViewBag.Page = page;
|
||||
ViewBag.PageSize = pageSize;
|
||||
ViewBag.TotalCount = totalCount;
|
||||
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||
|
||||
return View(logs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the full detail view for a single audit log entry, including the
|
||||
/// before/after JSON snapshot stored in the record. Uses <c>long</c> for the
|
||||
/// Id parameter because audit log primary keys are 64-bit integers to
|
||||
/// accommodate high-volume writes without risk of overflow.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(long id)
|
||||
{
|
||||
var log = await _db.AuditLogs.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id);
|
||||
if (log == null) return NotFound();
|
||||
return View(log);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic action — tests a live INSERT into AuditLogs and reports table state.
|
||||
/// Useful for diagnosing why prod shows 0 entries: isolates whether the table is
|
||||
/// writable, what the retention setting is, and whether any rows exist at all.
|
||||
/// Returns JSON so it can be called from the browser address bar without a view.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Diagnostics()
|
||||
{
|
||||
var results = new Dictionary<string, object?>();
|
||||
|
||||
// 1. Row count
|
||||
try
|
||||
{
|
||||
results["rowCount"] = await _db.AuditLogs.AsNoTracking().CountAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results["rowCountError"] = ex.Message;
|
||||
}
|
||||
|
||||
// 2. Oldest and newest timestamps
|
||||
try
|
||||
{
|
||||
var oldest = await _db.AuditLogs.AsNoTracking().MinAsync(a => (DateTime?)a.Timestamp);
|
||||
var newest = await _db.AuditLogs.AsNoTracking().MaxAsync(a => (DateTime?)a.Timestamp);
|
||||
results["oldestEntry"] = oldest;
|
||||
results["newestEntry"] = newest;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results["timestampError"] = ex.Message;
|
||||
}
|
||||
|
||||
// 3. Retention setting
|
||||
try
|
||||
{
|
||||
var raw = await _platformSettings.GetAsync(PlatformSettingKeys.AuditLogRetentionDays);
|
||||
results["retentionRaw"] = raw;
|
||||
results["retentionDays"] = int.TryParse(raw, out var d) ? d : 365;
|
||||
results["retentionCutoff"] = DateTime.UtcNow.AddDays(-(int.TryParse(raw, out var d2) ? d2 : 365));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results["retentionError"] = ex.Message;
|
||||
}
|
||||
|
||||
// 4. Test direct INSERT via EF (same path used by WriteLoginAuditAsync)
|
||||
string? insertError = null;
|
||||
long? insertedId = null;
|
||||
try
|
||||
{
|
||||
var testEntry = new AuditLog
|
||||
{
|
||||
UserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value,
|
||||
UserName = User.Identity?.Name ?? "Diagnostics",
|
||||
Action = "DiagnosticsTest",
|
||||
EntityType = "AuditLog",
|
||||
EntityId = "diag",
|
||||
EntityDescription = "Diagnostic test write",
|
||||
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
_db.AuditLogs.Add(testEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
insertedId = testEntry.Id;
|
||||
results["testInsertId"] = insertedId;
|
||||
results["testInsertSuccess"] = true;
|
||||
|
||||
// Clean it up immediately
|
||||
_db.AuditLogs.Remove(testEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
results["testInsertCleanedUp"] = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
insertError = ex.Message;
|
||||
results["testInsertSuccess"] = false;
|
||||
results["testInsertError"] = ex.Message;
|
||||
results["testInsertInner"] = ex.InnerException?.Message;
|
||||
}
|
||||
|
||||
// 5. Connection string info (safe — shows server/db only, no credentials)
|
||||
try
|
||||
{
|
||||
var conn = _db.Database.GetConnectionString() ?? "";
|
||||
// Extract Server= and Database= segments only (no passwords)
|
||||
var safeConn = string.Join("; ", conn.Split(';')
|
||||
.Where(p => p.TrimStart().StartsWith("Server", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.TrimStart().StartsWith("Database", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.TrimStart().StartsWith("Data Source", StringComparison.OrdinalIgnoreCase)
|
||||
|| p.TrimStart().StartsWith("Initial Catalog", StringComparison.OrdinalIgnoreCase)));
|
||||
results["connectionInfo"] = safeConn;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
results["connectionError"] = ex.Message;
|
||||
}
|
||||
|
||||
results["serverUtcNow"] = DateTime.UtcNow;
|
||||
results["environment"] = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "unknown";
|
||||
|
||||
_logger.LogInformation("Audit log diagnostics run by {User}: {@Results}", User.Identity?.Name, results);
|
||||
|
||||
return Json(results, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user