From 0eee5473e4207fff4cd1fd7ae2f816cf75c812de Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 20 Jan 2026 20:58:12 +0100 Subject: [PATCH] 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 --- src/TrueCV.Application/DTOs/CVCheckDto.cs | 1 + .../Interfaces/IAuditService.cs | 18 + .../Interfaces/ICVCheckService.cs | 1 + src/TrueCV.Domain/Entities/AuditLog.cs | 28 + src/TrueCV.Domain/Entities/CVCheck.cs | 3 + .../Data/ApplicationDbContext.cs | 17 + ...0191035_AddProcessingStageToCV.Designer.cs | 456 ++++++++++++++++ .../20260120191035_AddProcessingStageToCV.cs | 111 ++++ ...0260120194532_AddAuditLogTable.Designer.cs | 502 ++++++++++++++++++ .../20260120194532_AddAuditLogTable.cs | 55 ++ .../ApplicationDbContextModelSnapshot.cs | 102 ++-- .../DependencyInjection.cs | 1 + .../ExternalApis/CompaniesHouseClient.cs | 8 +- .../Jobs/ProcessCVCheckJob.cs | 66 ++- .../Services/AuditService.cs | 52 ++ .../Services/CVCheckService.cs | 33 ++ .../Services/CompanyVerifierService.cs | 107 ++-- .../Components/Pages/Dashboard.razor | 110 +++- src/TrueCV.Web/Components/Pages/Report.razor | 5 + .../Jobs/ProcessCVCheckJobTests.cs | 3 + .../Services/CVCheckServiceTests.cs | 3 + 21 files changed, 1559 insertions(+), 123 deletions(-) create mode 100644 src/TrueCV.Application/Interfaces/IAuditService.cs create mode 100644 src/TrueCV.Domain/Entities/AuditLog.cs create mode 100644 src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.Designer.cs create mode 100644 src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.cs create mode 100644 src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.Designer.cs create mode 100644 src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.cs create mode 100644 src/TrueCV.Infrastructure/Services/AuditService.cs diff --git a/src/TrueCV.Application/DTOs/CVCheckDto.cs b/src/TrueCV.Application/DTOs/CVCheckDto.cs index a293574..da762e7 100644 --- a/src/TrueCV.Application/DTOs/CVCheckDto.cs +++ b/src/TrueCV.Application/DTOs/CVCheckDto.cs @@ -6,6 +6,7 @@ public sealed record CVCheckDto public required string OriginalFileName { get; init; } public required string Status { get; init; } public int? VeracityScore { get; init; } + public string? ProcessingStage { get; init; } public required DateTime CreatedAt { get; init; } public DateTime? CompletedAt { get; init; } } diff --git a/src/TrueCV.Application/Interfaces/IAuditService.cs b/src/TrueCV.Application/Interfaces/IAuditService.cs new file mode 100644 index 0000000..8f113e4 --- /dev/null +++ b/src/TrueCV.Application/Interfaces/IAuditService.cs @@ -0,0 +1,18 @@ +namespace TrueCV.Application.Interfaces; + +public interface IAuditService +{ + Task LogAsync(Guid userId, string action, string? entityType = null, Guid? entityId = null, string? details = null, string? ipAddress = null); +} + +public static class AuditActions +{ + public const string CVUploaded = "CV_UPLOADED"; + public const string CVProcessed = "CV_PROCESSED"; + public const string CVDeleted = "CV_DELETED"; + public const string ReportViewed = "REPORT_VIEWED"; + public const string ReportExported = "REPORT_EXPORTED"; + public const string UserLogin = "USER_LOGIN"; + public const string UserLogout = "USER_LOGOUT"; + public const string UserRegistered = "USER_REGISTERED"; +} diff --git a/src/TrueCV.Application/Interfaces/ICVCheckService.cs b/src/TrueCV.Application/Interfaces/ICVCheckService.cs index 9a1b7a1..5121753 100644 --- a/src/TrueCV.Application/Interfaces/ICVCheckService.cs +++ b/src/TrueCV.Application/Interfaces/ICVCheckService.cs @@ -10,4 +10,5 @@ public interface ICVCheckService Task GetCheckForUserAsync(Guid id, Guid userId); Task> GetUserChecksAsync(Guid userId); Task GetReportAsync(Guid checkId, Guid userId); + Task DeleteCheckAsync(Guid checkId, Guid userId); } diff --git a/src/TrueCV.Domain/Entities/AuditLog.cs b/src/TrueCV.Domain/Entities/AuditLog.cs new file mode 100644 index 0000000..fd7de1f --- /dev/null +++ b/src/TrueCV.Domain/Entities/AuditLog.cs @@ -0,0 +1,28 @@ +using System.ComponentModel.DataAnnotations; + +namespace TrueCV.Domain.Entities; + +public class AuditLog +{ + [Key] + public Guid Id { get; set; } + + public Guid UserId { get; set; } + + [Required] + [MaxLength(64)] + public string Action { get; set; } = string.Empty; + + [MaxLength(128)] + public string? EntityType { get; set; } + + public Guid? EntityId { get; set; } + + [MaxLength(1024)] + public string? Details { get; set; } + + [MaxLength(64)] + public string? IpAddress { get; set; } + + public DateTime CreatedAt { get; set; } +} diff --git a/src/TrueCV.Domain/Entities/CVCheck.cs b/src/TrueCV.Domain/Entities/CVCheck.cs index 79bdd5a..500f68f 100644 --- a/src/TrueCV.Domain/Entities/CVCheck.cs +++ b/src/TrueCV.Domain/Entities/CVCheck.cs @@ -24,6 +24,9 @@ public class CVCheck public int? VeracityScore { get; set; } + [MaxLength(64)] + public string? ProcessingStage { get; set; } + public string? ReportJson { get; set; } public DateTime CreatedAt { get; set; } diff --git a/src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs b/src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs index d7477c4..2c3230e 100644 --- a/src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/TrueCV.Infrastructure/Data/ApplicationDbContext.cs @@ -16,6 +16,7 @@ public class ApplicationDbContext : IdentityDbContext CVChecks => Set(); public DbSet CVFlags => Set(); public DbSet CompanyCache => Set(); + public DbSet AuditLogs => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -25,6 +26,7 @@ public class ApplicationDbContext : IdentityDbContext(entity => + { + entity.HasIndex(a => a.UserId) + .HasDatabaseName("IX_AuditLogs_UserId"); + + entity.HasIndex(a => a.CreatedAt) + .HasDatabaseName("IX_AuditLogs_CreatedAt"); + + entity.HasIndex(a => a.Action) + .HasDatabaseName("IX_AuditLogs_Action"); + }); + } + public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) { var newCVChecks = ChangeTracker.Entries() diff --git a/src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.Designer.cs b/src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.Designer.cs new file mode 100644 index 0000000..f0a3a79 --- /dev/null +++ b/src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.Designer.cs @@ -0,0 +1,456 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrueCV.Infrastructure.Data; + +#nullable disable + +namespace TrueCV.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260120191035_AddProcessingStageToCV")] + partial class AddProcessingStageToCV + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BlobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExtractedDataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ProcessingStage") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ReportJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("VeracityScore") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_CVChecks_Status"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_CVChecks_UserId"); + + b.ToTable("CVChecks"); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CVCheckId") + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ScoreImpact") + .HasColumnType("int"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("CVCheckId") + .HasDatabaseName("IX_CVFlags_CVCheckId"); + + b.ToTable("CVFlags"); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CompanyCache", b => + { + b.Property("CompanyNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("AccountsCategory") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CachedAt") + .HasColumnType("datetime2"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("CompanyType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DissolutionDate") + .HasColumnType("date"); + + b.Property("IncorporationDate") + .HasColumnType("date"); + + b.Property("SicCodesJson") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("CompanyNumber"); + + b.ToTable("CompanyCache"); + }); + + modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ChecksUsedThisMonth") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeCustomerId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany("CVChecks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b => + { + b.HasOne("TrueCV.Domain.Entities.CVCheck", "CVCheck") + .WithMany("Flags") + .HasForeignKey("CVCheckId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CVCheck"); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b => + { + b.Navigation("Flags"); + }); + + modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b => + { + b.Navigation("CVChecks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.cs b/src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.cs new file mode 100644 index 0000000..38e8b9e --- /dev/null +++ b/src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.cs @@ -0,0 +1,111 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrueCV.Infrastructure.Data.Migrations +{ + /// + public partial class AddProcessingStageToCV : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_CVChecks_User_UserId1", + table: "CVChecks"); + + migrationBuilder.DropTable( + name: "User"); + + migrationBuilder.DropIndex( + name: "IX_CVChecks_UserId1", + table: "CVChecks"); + + migrationBuilder.DropColumn( + name: "UserId1", + table: "CVChecks"); + + migrationBuilder.AddColumn( + name: "ProcessingStage", + table: "CVChecks", + type: "nvarchar(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "AccountsCategory", + table: "CompanyCache", + type: "nvarchar(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "CompanyType", + table: "CompanyCache", + type: "nvarchar(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.AddColumn( + name: "SicCodesJson", + table: "CompanyCache", + type: "nvarchar(256)", + maxLength: 256, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ProcessingStage", + table: "CVChecks"); + + migrationBuilder.DropColumn( + name: "AccountsCategory", + table: "CompanyCache"); + + migrationBuilder.DropColumn( + name: "CompanyType", + table: "CompanyCache"); + + migrationBuilder.DropColumn( + name: "SicCodesJson", + table: "CompanyCache"); + + migrationBuilder.AddColumn( + name: "UserId1", + table: "CVChecks", + type: "uniqueidentifier", + nullable: true); + + migrationBuilder.CreateTable( + name: "User", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + ChecksUsedThisMonth = table.Column(type: "int", nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Plan = table.Column(type: "int", nullable: false), + StripeCustomerId = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_User", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_CVChecks_UserId1", + table: "CVChecks", + column: "UserId1"); + + migrationBuilder.AddForeignKey( + name: "FK_CVChecks_User_UserId1", + table: "CVChecks", + column: "UserId1", + principalTable: "User", + principalColumn: "Id"); + } + } +} diff --git a/src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.Designer.cs b/src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.Designer.cs new file mode 100644 index 0000000..306cf79 --- /dev/null +++ b/src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.Designer.cs @@ -0,0 +1,502 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TrueCV.Infrastructure.Data; + +#nullable disable + +namespace TrueCV.Infrastructure.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260120194532_AddAuditLogTable")] + partial class AddAuditLogTable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.23") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Details") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Action") + .HasDatabaseName("IX_AuditLogs_Action"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_AuditLogs_CreatedAt"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_AuditLogs_UserId"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BlobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExtractedDataJson") + .HasColumnType("nvarchar(max)"); + + b.Property("OriginalFileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("ProcessingStage") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ReportJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("VeracityScore") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Status") + .HasDatabaseName("IX_CVChecks_Status"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_CVChecks_UserId"); + + b.ToTable("CVChecks"); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("CVCheckId") + .HasColumnType("uniqueidentifier"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ScoreImpact") + .HasColumnType("int"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("CVCheckId") + .HasDatabaseName("IX_CVFlags_CVCheckId"); + + b.ToTable("CVFlags"); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CompanyCache", b => + { + b.Property("CompanyNumber") + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("AccountsCategory") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CachedAt") + .HasColumnType("datetime2"); + + b.Property("CompanyName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("CompanyType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("DissolutionDate") + .HasColumnType("date"); + + b.Property("IncorporationDate") + .HasColumnType("date"); + + b.Property("SicCodesJson") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("CompanyNumber"); + + b.ToTable("CompanyCache"); + }); + + modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ChecksUsedThisMonth") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("Plan") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("StripeCustomerId") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b => + { + b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null) + .WithMany("CVChecks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b => + { + b.HasOne("TrueCV.Domain.Entities.CVCheck", "CVCheck") + .WithMany("Flags") + .HasForeignKey("CVCheckId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CVCheck"); + }); + + modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b => + { + b.Navigation("Flags"); + }); + + modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b => + { + b.Navigation("CVChecks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.cs b/src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.cs new file mode 100644 index 0000000..115e53b --- /dev/null +++ b/src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TrueCV.Infrastructure.Data.Migrations +{ + /// + public partial class AddAuditLogTable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AuditLogs", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + UserId = table.Column(type: "uniqueidentifier", nullable: false), + Action = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + EntityType = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: true), + EntityId = table.Column(type: "uniqueidentifier", nullable: true), + Details = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: true), + IpAddress = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AuditLogs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_Action", + table: "AuditLogs", + column: "Action"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_CreatedAt", + table: "AuditLogs", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_AuditLogs_UserId", + table: "AuditLogs", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AuditLogs"); + } + } +} diff --git a/src/TrueCV.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/src/TrueCV.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 11e65ac..47c6c2d 100644 --- a/src/TrueCV.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/TrueCV.Infrastructure/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -153,6 +153,52 @@ namespace TrueCV.Infrastructure.Data.Migrations b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("TrueCV.Domain.Entities.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Details") + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("EntityId") + .HasColumnType("uniqueidentifier"); + + b.Property("EntityType") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("IpAddress") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("Action") + .HasDatabaseName("IX_AuditLogs_Action"); + + b.HasIndex("CreatedAt") + .HasDatabaseName("IX_AuditLogs_CreatedAt"); + + b.HasIndex("UserId") + .HasDatabaseName("IX_AuditLogs_UserId"); + + b.ToTable("AuditLogs"); + }); + modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b => { b.Property("Id") @@ -178,6 +224,10 @@ namespace TrueCV.Infrastructure.Data.Migrations .HasMaxLength(512) .HasColumnType("nvarchar(512)"); + b.Property("ProcessingStage") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + b.Property("ReportJson") .HasColumnType("nvarchar(max)"); @@ -189,9 +239,6 @@ namespace TrueCV.Infrastructure.Data.Migrations b.Property("UserId") .HasColumnType("uniqueidentifier"); - b.Property("UserId1") - .HasColumnType("uniqueidentifier"); - b.Property("VeracityScore") .HasColumnType("int"); @@ -203,8 +250,6 @@ namespace TrueCV.Infrastructure.Data.Migrations b.HasIndex("UserId") .HasDatabaseName("IX_CVChecks_UserId"); - b.HasIndex("UserId1"); - b.ToTable("CVChecks"); }); @@ -254,6 +299,10 @@ namespace TrueCV.Infrastructure.Data.Migrations .HasMaxLength(32) .HasColumnType("nvarchar(32)"); + b.Property("AccountsCategory") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + b.Property("CachedAt") .HasColumnType("datetime2"); @@ -262,12 +311,20 @@ namespace TrueCV.Infrastructure.Data.Migrations .HasMaxLength(512) .HasColumnType("nvarchar(512)"); + b.Property("CompanyType") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + b.Property("DissolutionDate") .HasColumnType("date"); b.Property("IncorporationDate") .HasColumnType("date"); + b.Property("SicCodesJson") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + b.Property("Status") .IsRequired() .HasMaxLength(64) @@ -278,32 +335,6 @@ namespace TrueCV.Infrastructure.Data.Migrations b.ToTable("CompanyCache"); }); - modelBuilder.Entity("TrueCV.Domain.Entities.User", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("uniqueidentifier"); - - b.Property("ChecksUsedThisMonth") - .HasColumnType("int"); - - b.Property("Email") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("Plan") - .HasColumnType("int"); - - b.Property("StripeCustomerId") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.ToTable("User"); - }); - modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b => { b.Property("Id") @@ -440,10 +471,6 @@ namespace TrueCV.Infrastructure.Data.Migrations .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - - b.HasOne("TrueCV.Domain.Entities.User", null) - .WithMany("CVChecks") - .HasForeignKey("UserId1"); }); modelBuilder.Entity("TrueCV.Domain.Entities.CVFlag", b => @@ -462,11 +489,6 @@ namespace TrueCV.Infrastructure.Data.Migrations b.Navigation("Flags"); }); - modelBuilder.Entity("TrueCV.Domain.Entities.User", b => - { - b.Navigation("CVChecks"); - }); - modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b => { b.Navigation("CVChecks"); diff --git a/src/TrueCV.Infrastructure/DependencyInjection.cs b/src/TrueCV.Infrastructure/DependencyInjection.cs index af33a34..d7b3db6 100644 --- a/src/TrueCV.Infrastructure/DependencyInjection.cs +++ b/src/TrueCV.Infrastructure/DependencyInjection.cs @@ -95,6 +95,7 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Register file storage - use local storage if configured, otherwise Azure var useLocalStorage = configuration.GetValue("UseLocalStorage"); diff --git a/src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs b/src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs index 5b7cd28..ddff207 100644 --- a/src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs +++ b/src/TrueCV.Infrastructure/ExternalApis/CompaniesHouseClient.cs @@ -200,10 +200,16 @@ public sealed record CompaniesHouseCompany public sealed record CompaniesHouseAccounts { - public string? AccountingReferenceDate { get; init; } + public CompaniesHouseAccountingReferenceDate? AccountingReferenceDate { get; init; } public CompaniesHouseLastAccounts? LastAccounts { get; init; } } +public sealed record CompaniesHouseAccountingReferenceDate +{ + public string? Day { get; init; } + public string? Month { get; init; } +} + public sealed record CompaniesHouseLastAccounts { public string? MadeUpTo { get; init; } diff --git a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs index 2b4052d..12be535 100644 --- a/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs +++ b/src/TrueCV.Infrastructure/Jobs/ProcessCVCheckJob.cs @@ -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 _logger; private const int BaseScore = 100; @@ -41,6 +42,7 @@ public sealed class ProcessCVCheckJob ICompanyVerifierService companyVerifierService, IEducationVerifierService educationVerifierService, ITimelineAnalyserService timelineAnalyserService, + IAuditService auditService, ILogger 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 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( diff --git a/src/TrueCV.Infrastructure/Services/AuditService.cs b/src/TrueCV.Infrastructure/Services/AuditService.cs new file mode 100644 index 0000000..2199281 --- /dev/null +++ b/src/TrueCV.Infrastructure/Services/AuditService.cs @@ -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 _logger; + + public AuditService(ApplicationDbContext dbContext, ILogger 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); + } + } +} diff --git a/src/TrueCV.Infrastructure/Services/CVCheckService.cs b/src/TrueCV.Infrastructure/Services/CVCheckService.cs index 7f9b4fc..d99d1bc 100644 --- a/src/TrueCV.Infrastructure/Services/CVCheckService.cs +++ b/src/TrueCV.Infrastructure/Services/CVCheckService.cs @@ -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 _logger; public CVCheckService( ApplicationDbContext dbContext, IFileStorageService fileStorageService, IBackgroundJobClient backgroundJobClient, + IAuditService auditService, ILogger 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 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 }; diff --git a/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs b/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs index c0ab1c7..e1342bf 100644 --- a/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs +++ b/src/TrueCV.Infrastructure/Services/CompanyVerifierService.cs @@ -18,7 +18,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService private readonly IDbContextFactory _dbContextFactory; private readonly ILogger _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 flags) { - var matchScore = Fuzz.Ratio( + var matchScore = Fuzz.TokenSetRatio( claimedCompany.ToUpperInvariant(), cached.CompanyName.ToUpperInvariant()); - var sicCodes = !string.IsNullOrEmpty(cached.SicCodesJson) - ? JsonSerializer.Deserialize>(cached.SicCodesJson) - : null; + List? sicCodes = null; + if (!string.IsNullOrEmpty(cached.SicCodesJson)) + { + try + { + sicCodes = JsonSerializer.Deserialize>(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, diff --git a/src/TrueCV.Web/Components/Pages/Dashboard.razor b/src/TrueCV.Web/Components/Pages/Dashboard.razor index df6cc09..75f5eed 100644 --- a/src/TrueCV.Web/Components/Pages/Dashboard.razor +++ b/src/TrueCV.Web/Components/Pages/Dashboard.razor @@ -9,6 +9,7 @@ @inject ILogger Logger @inject IJSRuntime JSRuntime @inject TrueCV.Web.Services.PdfReportService PdfReportService +@inject IAuditService AuditService Dashboard - TrueCV @@ -179,7 +180,7 @@ case "Processing": - Processing + @(check.ProcessingStage ?? "Processing") break; case "Pending": @@ -206,24 +207,32 @@ } - @if (check.Status == "Completed") - { - - View Report - - } - else if (check.Status is "Pending" or "Processing") - { - + } + else + { + + Retry + + } + - } - else - { - - Retry - - } + } @@ -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; + } + } } diff --git a/src/TrueCV.Web/Components/Pages/Report.razor b/src/TrueCV.Web/Components/Pages/Report.razor index 47a1266..d1c4503 100644 --- a/src/TrueCV.Web/Components/Pages/Report.razor +++ b/src/TrueCV.Web/Components/Pages/Report.razor @@ -8,6 +8,7 @@ @inject AuthenticationStateProvider AuthenticationStateProvider @inject ILogger Logger @inject IJSRuntime JSRuntime +@inject IAuditService AuditService Verification Report - TrueCV @@ -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) diff --git a/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs b/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs index cdfa41e..bb8c85a 100644 --- a/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs +++ b/tests/TrueCV.Tests/Jobs/ProcessCVCheckJobTests.cs @@ -20,6 +20,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable private readonly Mock _companyVerifierServiceMock; private readonly Mock _educationVerifierServiceMock; private readonly Mock _timelineAnalyserServiceMock; + private readonly Mock _auditServiceMock; private readonly Mock> _loggerMock; private readonly ProcessCVCheckJob _sut; @@ -40,6 +41,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable _companyVerifierServiceMock = new Mock(); _educationVerifierServiceMock = new Mock(); _timelineAnalyserServiceMock = new Mock(); + _auditServiceMock = new Mock(); _loggerMock = new Mock>(); _sut = new ProcessCVCheckJob( @@ -49,6 +51,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable _companyVerifierServiceMock.Object, _educationVerifierServiceMock.Object, _timelineAnalyserServiceMock.Object, + _auditServiceMock.Object, _loggerMock.Object); } diff --git a/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs b/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs index c05aa2a..0ae8818 100644 --- a/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs +++ b/tests/TrueCV.Tests/Services/CVCheckServiceTests.cs @@ -23,6 +23,7 @@ public sealed class CVCheckServiceTests : IDisposable private readonly ApplicationDbContext _dbContext; private readonly Mock _fileStorageServiceMock; private readonly Mock _backgroundJobClientMock; + private readonly Mock _auditServiceMock; private readonly Mock> _loggerMock; private readonly CVCheckService _sut; @@ -35,12 +36,14 @@ public sealed class CVCheckServiceTests : IDisposable _dbContext = new ApplicationDbContext(options); _fileStorageServiceMock = new Mock(); _backgroundJobClientMock = new Mock(); + _auditServiceMock = new Mock(); _loggerMock = new Mock>(); _sut = new CVCheckService( _dbContext, _fileStorageServiceMock.Object, _backgroundJobClientMock.Object, + _auditServiceMock.Object, _loggerMock.Object); }