using System.Globalization; using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; [ApiController] [Route("api/admin/audit")] [Authorize(Roles = "Admin")] public sealed class AdminAuditController : ControllerBase { private readonly JobTrackerContext _db; public AdminAuditController(JobTrackerContext db) { _db = db; } public sealed record AuditItemDto( int Id, string Type, string? OldValue, string? NewValue, string? Note, DateTime At, int JobApplicationId, string? JobTitle, string? CompanyName, string? OwnerUserId, string? OwnerEmail, string? OwnerUserName ); [HttpGet] public async Task>> Get([FromQuery] int take = 200, CancellationToken cancellationToken = default) { if (take < 1) take = 1; if (take > 2000) take = 2000; var items = await _db.JobEvents .IgnoreQueryFilters() .AsNoTracking() .OrderByDescending(e => e.At) .Take(take) .Select(e => new { e.Id, e.Type, e.OldValue, e.NewValue, e.Note, e.At, e.JobApplicationId }) .ToListAsync(cancellationToken); var jobIds = items.Select(x => x.JobApplicationId).Distinct().ToList(); var jobs = await _db.JobApplications .IgnoreQueryFilters() .AsNoTracking() .Include(j => j.Company) .Where(j => jobIds.Contains(j.Id)) .Select(j => new { j.Id, j.JobTitle, CompanyName = j.Company.Name, j.OwnerUserId }) .ToDictionaryAsync(x => x.Id, cancellationToken); var ownerIds = jobs.Values .Select(x => x.OwnerUserId) .Where(x => !string.IsNullOrWhiteSpace(x)) .Distinct() .ToList(); var owners = ownerIds.Count == 0 ? new Dictionary() : await _db.Users .AsNoTracking() .Where(u => ownerIds.Contains(u.Id)) .Select(u => new { u.Id, u.Email, u.UserName }) .ToDictionaryAsync(x => x.Id, x => (x.Email, x.UserName), cancellationToken); var outList = new List(items.Count); foreach (var e in items) { jobs.TryGetValue(e.JobApplicationId, out var j); (string? Email, string? UserName) owner = default; if (j?.OwnerUserId is not null) { owners.TryGetValue(j.OwnerUserId, out owner); } outList.Add(new AuditItemDto( e.Id, e.Type, e.OldValue, e.NewValue, e.Note, e.At, e.JobApplicationId, j?.JobTitle, j?.CompanyName, j?.OwnerUserId, owner.Email, owner.UserName )); } return Ok(outList); } public sealed record UndoResultDto(bool Ok, string Message); [HttpPost("{id:int}/undo")] public async Task> Undo([FromRoute] int id, CancellationToken cancellationToken = default) { var ev = await _db.JobEvents .IgnoreQueryFilters() .FirstOrDefaultAsync(e => e.Id == id, cancellationToken); if (ev is null) return NotFound(new UndoResultDto(false, "Event not found.")); var job = await _db.JobApplications .IgnoreQueryFilters() .FirstOrDefaultAsync(j => j.Id == ev.JobApplicationId, cancellationToken); if (job is null) return NotFound(new UndoResultDto(false, "Job application not found.")); string message; string? undoOld = ev.NewValue; string? undoNew = ev.OldValue; switch ((ev.Type ?? "").Trim()) { case "StatusChanged": { if (string.IsNullOrWhiteSpace(ev.OldValue)) return BadRequest(new UndoResultDto(false, "Cannot undo: missing OldValue.")); var prev = job.Status; job.Status = ev.OldValue.Trim(); message = $"Reverted status '{prev}' -> '{job.Status}'."; break; } case "FollowUpSet": { job.FollowUpAt = ParseNullableIso(ev.OldValue); message = "Reverted follow-up date."; break; } case "ResponseUpdated": { if (!TryParseResponse(ev.OldValue, out var received, out var date)) return BadRequest(new UndoResultDto(false, "Cannot undo: invalid OldValue.")); job.ResponseReceived = received; job.ResponseDate = date; message = "Reverted response fields."; break; } case "Deleted": { job.IsDeleted = false; job.DeletedAt = null; message = "Restored job from trash."; break; } case "Restored": { job.IsDeleted = true; job.DeletedAt = DateTime.Now; message = "Moved job back to trash."; break; } default: return BadRequest(new UndoResultDto(false, $"Undo is not supported for event type '{ev.Type}'.")); } _db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Undo", OldValue = undoOld, NewValue = undoNew, Note = $"Undid event #{ev.Id} ({ev.Type}). {message}", At = DateTime.Now }); await _db.SaveChangesAsync(cancellationToken); return Ok(new UndoResultDto(true, message)); } private static DateTime? ParseNullableIso(string? iso) { if (string.IsNullOrWhiteSpace(iso)) return null; if (DateTime.TryParse(iso, null, DateTimeStyles.RoundtripKind, out var d)) return d; if (DateTime.TryParse(iso, out d)) return d; return null; } private static bool TryParseResponse(string? value, out bool received, out DateTime? date) { received = false; date = null; if (string.IsNullOrWhiteSpace(value)) return false; var idx = value.IndexOf(':'); if (idx < 0) return false; var b = value[..idx]; var rest = value[(idx + 1)..]; if (!bool.TryParse(b, out received)) return false; date = ParseNullableIso(rest); return true; } }