Add audit logging, processing stages, delete functionality, and bug fixes

- Add audit logging system for tracking CV uploads, processing, deletion,
  report views, and PDF exports for billing/reference purposes
- Add processing stage display on dashboard instead of generic "Processing"
- Add delete button for CV checks on dashboard
- Fix duplicate primary key error in CompanyCache (race condition)
- Fix DbContext concurrency in Dashboard (concurrent delete/load operations)
- Fix ProcessCVCheckJob to handle deleted records gracefully
- Fix duplicate flags in verification report by deduplicating on Title+Description
- Remove internal cache notes from verification results
- Add EF migrations for ProcessingStage and AuditLog table

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-20 20:58:12 +01:00
parent 652aa2e612
commit 0eee5473e4
21 changed files with 1559 additions and 123 deletions

View File

@@ -9,6 +9,7 @@
@inject ILogger<Dashboard> Logger
@inject IJSRuntime JSRuntime
@inject TrueCV.Web.Services.PdfReportService PdfReportService
@inject IAuditService AuditService
<PageTitle>Dashboard - TrueCV</PageTitle>
@@ -179,7 +180,7 @@
case "Processing":
<span class="badge bg-primary">
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" style="width: 0.7rem; height: 0.7rem;"></span>
Processing
@(check.ProcessingStage ?? "Processing")
</span>
break;
case "Pending":
@@ -206,24 +207,32 @@
}
</td>
<td class="text-end">
@if (check.Status == "Completed")
{
<a href="/report/@check.Id" class="btn btn-sm btn-outline-primary" @onclick:stopPropagation="true">
View Report
</a>
}
else if (check.Status is "Pending" or "Processing")
{
<button class="btn btn-sm btn-outline-secondary" disabled>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<div class="btn-group" role="group">
@if (check.Status == "Completed")
{
<a href="/report/@check.Id" class="btn btn-sm btn-outline-primary" @onclick:stopPropagation="true">
View Report
</a>
}
else if (check.Status is "Pending" or "Processing")
{
<button class="btn btn-sm btn-outline-secondary" disabled>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</button>
}
else
{
<a href="/check" class="btn btn-sm btn-outline-warning" @onclick:stopPropagation="true">
Retry
</a>
}
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteCheck(check.Id)" @onclick:stopPropagation="true" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/>
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/>
</svg>
</button>
}
else
{
<a href="/check" class="btn btn-sm btn-outline-warning">
Retry
</a>
}
</div>
</td>
</tr>
}
@@ -252,7 +261,9 @@
private string? _errorMessage;
private Guid _userId;
private System.Threading.Timer? _pollingTimer;
private bool _isPolling;
private volatile bool _isPolling;
private volatile bool _disposed;
private volatile bool _isOperationInProgress;
protected override async Task OnInitializedAsync()
{
@@ -262,20 +273,31 @@
private void StartPollingIfNeeded()
{
if (HasProcessingChecks() && !_isPolling)
if (HasProcessingChecks() && !_isPolling && !_disposed)
{
_isPolling = true;
_pollingTimer = new System.Threading.Timer(async _ =>
{
await InvokeAsync(async () =>
if (_disposed) return;
try
{
await LoadChecks();
if (!HasProcessingChecks())
await InvokeAsync(async () =>
{
StopPolling();
}
StateHasChanged();
});
if (_disposed) return;
await LoadChecks();
if (!HasProcessingChecks())
{
StopPolling();
}
StateHasChanged();
});
}
catch (ObjectDisposedException)
{
// Component was disposed, ignore
}
}, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3));
}
}
@@ -298,11 +320,15 @@
public void Dispose()
{
_disposed = true;
StopPolling();
}
private async Task LoadChecks()
{
if (_isOperationInProgress) return;
_isOperationInProgress = true;
_isLoading = true;
_errorMessage = null;
@@ -317,7 +343,7 @@
return;
}
_checks = await CVCheckService.GetUserChecksAsync(_userId);
_checks = await CVCheckService.GetUserChecksAsync(_userId) ?? [];
}
catch (Exception ex)
{
@@ -327,6 +353,7 @@
finally
{
_isLoading = false;
_isOperationInProgress = false;
}
}
@@ -400,6 +427,8 @@
var base64 = Convert.ToBase64String(pdfBytes);
var fileName = "TrueCV_Report_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ".pdf";
await JSRuntime.InvokeVoidAsync("downloadFile", fileName, base64, "application/pdf");
await AuditService.LogAsync(_userId, AuditActions.ReportExported, null, null, $"Exported {reportDataList.Count} reports to PDF");
}
catch (Exception ex)
{
@@ -420,4 +449,29 @@
}
return false;
}
private async Task DeleteCheck(Guid checkId)
{
if (_isOperationInProgress) return;
_isOperationInProgress = true;
try
{
var success = await CVCheckService.DeleteCheckAsync(checkId, _userId);
if (success)
{
_checks.RemoveAll(c => c.Id == checkId);
StateHasChanged();
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error deleting CV check {CheckId}", checkId);
_errorMessage = "Failed to delete the CV check. Please try again.";
}
finally
{
_isOperationInProgress = false;
}
}
}

View File

@@ -8,6 +8,7 @@
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Report> Logger
@inject IJSRuntime JSRuntime
@inject IAuditService AuditService
<PageTitle>Verification Report - TrueCV</PageTitle>
@@ -546,6 +547,10 @@
{
_errorMessage = "Unable to load the report data.";
}
else
{
await AuditService.LogAsync(_userId, AuditActions.ReportViewed, "CVCheck", Id, $"Score: {_report.OverallScore}");
}
}
}
catch (Exception ex)