220 lines
6.8 KiB
C#
220 lines
6.8 KiB
C#
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;
|
|
}
|
|
}
|