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:
52
src/TrueCV.Infrastructure/Services/AuditService.cs
Normal file
52
src/TrueCV.Infrastructure/Services/AuditService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user