First Commit
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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<ActionResult<List<AuditItemDto>>> 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<string, (string? Email, string? UserName)>()
|
||||
: 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<AuditItemDto>(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<ActionResult<UndoResultDto>> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user