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

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Logging;
using TrueCV.Application.Interfaces;
using TrueCV.Domain.Entities;
using TrueCV.Infrastructure.Data;
namespace TrueCV.Infrastructure.Services;
public sealed class AuditService : IAuditService
{
private readonly ApplicationDbContext _dbContext;
private readonly ILogger<AuditService> _logger;
public AuditService(ApplicationDbContext dbContext, ILogger<AuditService> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task LogAsync(
Guid userId,
string action,
string? entityType = null,
Guid? entityId = null,
string? details = null,
string? ipAddress = null)
{
var auditLog = new AuditLog
{
Id = Guid.NewGuid(),
UserId = userId,
Action = action,
EntityType = entityType,
EntityId = entityId,
Details = details,
IpAddress = ipAddress,
CreatedAt = DateTime.UtcNow
};
_dbContext.AuditLogs.Add(auditLog);
try
{
await _dbContext.SaveChangesAsync();
_logger.LogDebug("Audit log created: {Action} by user {UserId}", action, userId);
}
catch (Exception ex)
{
// Don't let audit failures break the main flow
_logger.LogError(ex, "Failed to create audit log: {Action} by user {UserId}", action, userId);
}
}
}

View File

@@ -18,17 +18,20 @@ public sealed class CVCheckService : ICVCheckService
private readonly ApplicationDbContext _dbContext;
private readonly IFileStorageService _fileStorageService;
private readonly IBackgroundJobClient _backgroundJobClient;
private readonly IAuditService _auditService;
private readonly ILogger<CVCheckService> _logger;
public CVCheckService(
ApplicationDbContext dbContext,
IFileStorageService fileStorageService,
IBackgroundJobClient backgroundJobClient,
IAuditService auditService,
ILogger<CVCheckService> logger)
{
_dbContext = dbContext;
_fileStorageService = fileStorageService;
_backgroundJobClient = backgroundJobClient;
_auditService = auditService;
_logger = logger;
}
@@ -66,6 +69,8 @@ public sealed class CVCheckService : ICVCheckService
"CV check {CheckId} created for user {UserId}, processing queued",
cvCheck.Id, userId);
await _auditService.LogAsync(userId, AuditActions.CVUploaded, "CVCheck", cvCheck.Id, $"File: {fileName}");
return cvCheck.Id;
}
@@ -150,6 +155,33 @@ public sealed class CVCheckService : ICVCheckService
}
}
public async Task<bool> DeleteCheckAsync(Guid checkId, Guid userId)
{
_logger.LogDebug("Deleting CV check {CheckId} for user {UserId}", checkId, userId);
var cvCheck = await _dbContext.CVChecks
.Include(c => c.Flags)
.FirstOrDefaultAsync(c => c.Id == checkId && c.UserId == userId);
if (cvCheck is null)
{
_logger.LogWarning("CV check {CheckId} not found for user {UserId}", checkId, userId);
return false;
}
var fileName = cvCheck.OriginalFileName;
_dbContext.CVFlags.RemoveRange(cvCheck.Flags);
_dbContext.CVChecks.Remove(cvCheck);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Deleted CV check {CheckId} for user {UserId}", checkId, userId);
await _auditService.LogAsync(userId, AuditActions.CVDeleted, "CVCheck", checkId, $"File: {fileName}");
return true;
}
private static CVCheckDto MapToDto(CVCheck cvCheck)
{
return new CVCheckDto
@@ -158,6 +190,7 @@ public sealed class CVCheckService : ICVCheckService
OriginalFileName = cvCheck.OriginalFileName,
Status = cvCheck.Status.ToString(),
VeracityScore = cvCheck.VeracityScore,
ProcessingStage = cvCheck.ProcessingStage,
CreatedAt = cvCheck.CreatedAt,
CompletedAt = cvCheck.CompletedAt
};

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,