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,7 +18,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
private readonly ILogger<CompanyVerifierService> _logger;
private const int FuzzyMatchThreshold = 70;
private const int FuzzyMatchThreshold = 85;
private const int CacheExpirationDays = 30;
// SIC codes for tech/software companies
@@ -135,7 +135,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
MatchedCompanyNumber = match.Item.CompanyNumber,
MatchScore = match.Score,
IsVerified = true,
VerificationNotes = $"Matched with {match.Score}% confidence",
VerificationNotes = null,
ClaimedStartDate = startDate,
ClaimedEndDate = endDate,
CompanyType = companyType,
@@ -549,7 +549,8 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
}
var matches = cachedCompanies
.Select(c => new { Company = c, Score = Fuzz.Ratio(companyName.ToUpperInvariant(), c.CompanyName.ToUpperInvariant()) })
.Where(c => !string.IsNullOrWhiteSpace(c.CompanyName))
.Select(c => new { Company = c, Score = Fuzz.TokenSetRatio(companyName.ToUpperInvariant(), c.CompanyName.ToUpperInvariant()) })
.Where(m => m.Score >= FuzzyMatchThreshold)
.OrderByDescending(m => m.Score)
.FirstOrDefault();
@@ -564,7 +565,8 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
var normalizedSearch = companyName.ToUpperInvariant();
var matches = items
.Select(item => (Item: item, Score: Fuzz.Ratio(normalizedSearch, item.Title.ToUpperInvariant())))
.Where(item => !string.IsNullOrWhiteSpace(item.Title))
.Select(item => (Item: item, Score: Fuzz.TokenSetRatio(normalizedSearch, item.Title.ToUpperInvariant())))
.Where(m => m.Score >= FuzzyMatchThreshold)
.OrderByDescending(m => m.Score)
.ToList();
@@ -574,45 +576,53 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
private async Task CacheCompanyAsync(CompaniesHouseSearchItem item, CompaniesHouseCompany? details)
{
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var existingCache = await dbContext.CompanyCache
.FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber);
var sicCodes = details?.SicCodes ?? item.SicCodes;
var sicCodesJson = sicCodes != null ? JsonSerializer.Serialize(sicCodes) : null;
var accountsCategory = details?.Accounts?.LastAccounts?.Type;
if (existingCache is not null)
try
{
existingCache.CompanyName = item.Title;
existingCache.Status = item.CompanyStatus ?? "Unknown";
existingCache.CompanyType = item.CompanyType;
existingCache.IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation);
existingCache.DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation);
existingCache.AccountsCategory = accountsCategory;
existingCache.SicCodesJson = sicCodesJson;
existingCache.CachedAt = DateTime.UtcNow;
}
else
{
var cacheEntry = new CompanyCache
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
var existingCache = await dbContext.CompanyCache
.FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber);
var sicCodes = details?.SicCodes ?? item.SicCodes;
var sicCodesJson = sicCodes != null ? JsonSerializer.Serialize(sicCodes) : null;
var accountsCategory = details?.Accounts?.LastAccounts?.Type;
if (existingCache is not null)
{
CompanyNumber = item.CompanyNumber,
CompanyName = item.Title,
Status = item.CompanyStatus ?? "Unknown",
CompanyType = item.CompanyType,
IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation),
DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation),
AccountsCategory = accountsCategory,
SicCodesJson = sicCodesJson,
CachedAt = DateTime.UtcNow
};
existingCache.CompanyName = item.Title;
existingCache.Status = item.CompanyStatus ?? "Unknown";
existingCache.CompanyType = item.CompanyType;
existingCache.IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation);
existingCache.DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation);
existingCache.AccountsCategory = accountsCategory;
existingCache.SicCodesJson = sicCodesJson;
existingCache.CachedAt = DateTime.UtcNow;
}
else
{
var cacheEntry = new CompanyCache
{
CompanyNumber = item.CompanyNumber,
CompanyName = item.Title,
Status = item.CompanyStatus ?? "Unknown",
CompanyType = item.CompanyType,
IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation),
DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation),
AccountsCategory = accountsCategory,
SicCodesJson = sicCodesJson,
CachedAt = DateTime.UtcNow
};
dbContext.CompanyCache.Add(cacheEntry);
dbContext.CompanyCache.Add(cacheEntry);
}
await dbContext.SaveChangesAsync();
}
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("PK_CompanyCache") == true)
{
// Race condition: another task already cached this company - ignore
_logger.LogDebug("Company {CompanyNumber} already cached by another task", item.CompanyNumber);
}
await dbContext.SaveChangesAsync();
}
private CompanyVerificationResult CreateResultFromCache(
@@ -623,13 +633,22 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
string? jobTitle,
List<CompanyVerificationFlag> flags)
{
var matchScore = Fuzz.Ratio(
var matchScore = Fuzz.TokenSetRatio(
claimedCompany.ToUpperInvariant(),
cached.CompanyName.ToUpperInvariant());
var sicCodes = !string.IsNullOrEmpty(cached.SicCodesJson)
? JsonSerializer.Deserialize<List<string>>(cached.SicCodesJson)
: null;
List<string>? sicCodes = null;
if (!string.IsNullOrEmpty(cached.SicCodesJson))
{
try
{
sicCodes = JsonSerializer.Deserialize<List<string>>(cached.SicCodesJson);
}
catch (JsonException)
{
// Ignore malformed JSON in cache
}
}
// Run all verification checks
CheckIncorporationDate(flags, startDate, cached.IncorporationDate, cached.CompanyName);
@@ -657,7 +676,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
MatchedCompanyNumber = cached.CompanyNumber,
MatchScore = matchScore,
IsVerified = true,
VerificationNotes = $"Matched from cache with {matchScore}% confidence",
VerificationNotes = null,
ClaimedStartDate = startDate,
ClaimedEndDate = endDate,
CompanyType = cached.CompanyType,