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

@@ -18,6 +18,7 @@ public sealed class ProcessCVCheckJob
private readonly ICompanyVerifierService _companyVerifierService;
private readonly IEducationVerifierService _educationVerifierService;
private readonly ITimelineAnalyserService _timelineAnalyserService;
private readonly IAuditService _auditService;
private readonly ILogger<ProcessCVCheckJob> _logger;
private const int BaseScore = 100;
@@ -41,6 +42,7 @@ public sealed class ProcessCVCheckJob
ICompanyVerifierService companyVerifierService,
IEducationVerifierService educationVerifierService,
ITimelineAnalyserService timelineAnalyserService,
IAuditService auditService,
ILogger<ProcessCVCheckJob> logger)
{
_dbContext = dbContext;
@@ -49,6 +51,7 @@ public sealed class ProcessCVCheckJob
_companyVerifierService = companyVerifierService;
_educationVerifierService = educationVerifierService;
_timelineAnalyserService = timelineAnalyserService;
_auditService = auditService;
_logger = logger;
}
@@ -69,6 +72,7 @@ public sealed class ProcessCVCheckJob
{
// Step 1: Update status to Processing
cvCheck.Status = CheckStatus.Processing;
cvCheck.ProcessingStage = "Downloading CV";
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogDebug("CV check {CheckId} status updated to Processing", cvCheckId);
@@ -79,6 +83,9 @@ public sealed class ProcessCVCheckJob
_logger.LogDebug("Downloaded CV file for check {CheckId}", cvCheckId);
// Step 3: Parse CV
cvCheck.ProcessingStage = "Parsing CV";
await _dbContext.SaveChangesAsync(cancellationToken);
var cvData = await _cvParserService.ParseAsync(fileStream, cvCheck.OriginalFileName, cancellationToken);
_logger.LogDebug(
@@ -87,6 +94,7 @@ public sealed class ProcessCVCheckJob
// Step 4: Save extracted data
cvCheck.ExtractedDataJson = JsonSerializer.Serialize(cvData, JsonDefaults.CamelCaseIndented);
cvCheck.ProcessingStage = "Verifying Employment";
await _dbContext.SaveChangesAsync(cancellationToken);
// Step 5: Verify each employment entry (parallelized with rate limiting)
@@ -128,9 +136,14 @@ public sealed class ProcessCVCheckJob
}
// Step 5b: Verify director claims against Companies House officers
cvCheck.ProcessingStage = "Verifying Directors";
await _dbContext.SaveChangesAsync(cancellationToken);
await VerifyDirectorClaims(cvData.FullName, verificationResults, cancellationToken);
// Step 6: Verify education entries
cvCheck.ProcessingStage = "Verifying Education";
await _dbContext.SaveChangesAsync(cancellationToken);
var educationResults = _educationVerifierService.VerifyAll(
cvData.Education,
cvData.Employment);
@@ -143,6 +156,9 @@ public sealed class ProcessCVCheckJob
educationResults.Count(e => e.IsDiplomaMill));
// Step 7: Analyse timeline
cvCheck.ProcessingStage = "Analyzing Timeline";
await _dbContext.SaveChangesAsync(cancellationToken);
var timelineAnalysis = _timelineAnalyserService.Analyse(cvData.Employment);
_logger.LogDebug(
@@ -150,6 +166,8 @@ public sealed class ProcessCVCheckJob
cvCheckId, timelineAnalysis.Gaps.Count, timelineAnalysis.Overlaps.Count);
// Step 8: Calculate veracity score
cvCheck.ProcessingStage = "Calculating Score";
await _dbContext.SaveChangesAsync(cancellationToken);
var (score, flags) = CalculateVeracityScore(verificationResults, educationResults, timelineAnalysis, cvData);
_logger.LogDebug("Calculated veracity score for check {CheckId}: {Score}", cvCheckId, score);
@@ -184,6 +202,9 @@ public sealed class ProcessCVCheckJob
}
// Step 10: Generate veracity report
cvCheck.ProcessingStage = "Generating Report";
await _dbContext.SaveChangesAsync(cancellationToken);
var report = new VeracityReport
{
OverallScore = score,
@@ -200,20 +221,32 @@ public sealed class ProcessCVCheckJob
// Step 11: Update status to Completed
cvCheck.Status = CheckStatus.Completed;
cvCheck.ProcessingStage = null; // Clear stage on completion
cvCheck.CompletedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation(
"CV check {CheckId} completed successfully with score {Score}",
cvCheckId, score);
await _auditService.LogAsync(cvCheck.UserId, AuditActions.CVProcessed, "CVCheck", cvCheckId, $"Score: {score}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing CV check {CheckId}", cvCheckId);
cvCheck.Status = CheckStatus.Failed;
// Use CancellationToken.None to ensure failure status is saved even if original token is cancelled
await _dbContext.SaveChangesAsync(CancellationToken.None);
try
{
cvCheck.Status = CheckStatus.Failed;
// Use CancellationToken.None to ensure failure status is saved even if original token is cancelled
await _dbContext.SaveChangesAsync(CancellationToken.None);
}
catch (DbUpdateConcurrencyException)
{
// Record was deleted during processing - nothing to update
_logger.LogWarning("CV check {CheckId} was deleted during processing", cvCheckId);
return;
}
throw;
}
@@ -382,10 +415,19 @@ public sealed class ProcessCVCheckJob
});
}
// Ensure score doesn't go below 0
score = Math.Max(0, score);
// Deduplicate flags based on Title + Description
var uniqueFlags = flags
.GroupBy(f => (f.Title, f.Description))
.Select(g => g.First())
.ToList();
return (score, flags);
// Recalculate score based on unique flags
var uniqueScore = BaseScore + uniqueFlags.Sum(f => f.ScoreImpact);
// Ensure score doesn't go below 0
uniqueScore = Math.Max(0, uniqueScore);
return (uniqueScore, uniqueFlags);
}
private static string GetScoreLabel(int score)
@@ -420,8 +462,13 @@ public sealed class ProcessCVCheckJob
List<CompanyVerificationResult> verificationResults,
CancellationToken cancellationToken)
{
// Find all director claims at verified companies
foreach (var result in verificationResults.Where(v => v.IsVerified && !string.IsNullOrEmpty(v.MatchedCompanyNumber)))
// Find all director claims at verified companies - use ToList() to avoid modifying during enumeration
var directorCandidates = verificationResults
.Select((result, index) => (result, index))
.Where(x => x.result.IsVerified && !string.IsNullOrEmpty(x.result.MatchedCompanyNumber))
.ToList();
foreach (var (result, index) in directorCandidates)
{
var jobTitle = result.ClaimedJobTitle?.ToLowerInvariant() ?? "";
@@ -446,7 +493,7 @@ public sealed class ProcessCVCheckJob
if (isVerifiedDirector == false)
{
// Add a flag for unverified director claim
var flags = result.Flags.ToList();
var flags = (result.Flags ?? []).ToList();
flags.Add(new CompanyVerificationFlag
{
Type = "UnverifiedDirectorClaim",
@@ -456,7 +503,6 @@ public sealed class ProcessCVCheckJob
});
// Update the result with the new flag
var index = verificationResults.IndexOf(result);
verificationResults[index] = result with { Flags = flags };
_logger.LogWarning(