First Commit

This commit is contained in:
cesnimda
2026-03-21 11:55:27 +01:00
commit 2e8a29b4d0
1757 changed files with 166084 additions and 0 deletions
@@ -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;
}
}