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; /// /// 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. /// // Intentional exception: platform audit log with a long PK; append-only infrastructure table outside the business entity graph; same reasoning as SystemLogsController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions. [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class AuditLogController : Controller { private readonly ApplicationDbContext _db; private readonly ILogger _logger; private readonly IPlatformSettingsService _platformSettings; public AuditLogController(ApplicationDbContext db, ILogger logger, IPlatformSettingsService platformSettings) { _db = db; _logger = logger; _platformSettings = platformSettings; } /// /// 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. /// /// Date filters are converted to UTC before querying because audit log timestamps /// are stored in UTC. The to 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. /// /// public async Task 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); } /// /// Returns the full detail view for a single audit log entry, including the /// before/after JSON snapshot stored in the record. Uses long for the /// Id parameter because audit log primary keys are 64-bit integers to /// accommodate high-volume writes without risk of overflow. /// public async Task Details(long id) { var log = await _db.AuditLogs.AsNoTracking().FirstOrDefaultAsync(a => a.Id == id); if (log == null) return NotFound(); return View(log); } /// /// 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. /// [HttpGet] public async Task Diagnostics() { var results = new Dictionary(); // 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 }); } }