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

@@ -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; }
}

View File

@@ -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";
}

View File

@@ -10,4 +10,5 @@ public interface ICVCheckService
Task<CVCheckDto?> GetCheckForUserAsync(Guid id, Guid userId);
Task<List<CVCheckDto>> GetUserChecksAsync(Guid userId);
Task<VeracityReport?> GetReportAsync(Guid checkId, Guid userId);
Task<bool> DeleteCheckAsync(Guid checkId, Guid userId);
}

View File

@@ -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; }
}

View File

@@ -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; }

View File

@@ -16,6 +16,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
public DbSet<CVCheck> CVChecks => Set<CVCheck>();
public DbSet<CVFlag> CVFlags => Set<CVFlag>();
public DbSet<CompanyCache> CompanyCache => Set<CompanyCache>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -25,6 +26,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
ConfigureCVCheck(builder);
ConfigureCVFlag(builder);
ConfigureCompanyCache(builder);
ConfigureAuditLog(builder);
}
private static void ConfigureApplicationUser(ModelBuilder builder)
@@ -94,6 +96,21 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
});
}
private static void ConfigureAuditLog(ModelBuilder builder)
{
builder.Entity<AuditLog>(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<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
var newCVChecks = ChangeTracker.Entries<CVCheck>()

View File

@@ -0,0 +1,456 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrueCV.Domain.Entities.CVCheck", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("ExtractedDataJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("ProcessingStage")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ReportJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CVCheckId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<int>("ScoreImpact")
.HasColumnType("int");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("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<string>("CompanyNumber")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("AccountsCategory")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CachedAt")
.HasColumnType("datetime2");
b.Property<string>("CompanyName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("CompanyType")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateOnly?>("DissolutionDate")
.HasColumnType("date");
b.Property<DateOnly?>("IncorporationDate")
.HasColumnType("date");
b.Property<string>("SicCodesJson")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("CompanyNumber");
b.ToTable("CompanyCache");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<int>("ChecksUsedThisMonth")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<string>("StripeCustomerId")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", 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<System.Guid>", 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
}
}
}

View File

@@ -0,0 +1,111 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TrueCV.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddProcessingStageToCV : Migration
{
/// <inheritdoc />
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<string>(
name: "ProcessingStage",
table: "CVChecks",
type: "nvarchar(64)",
maxLength: 64,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AccountsCategory",
table: "CompanyCache",
type: "nvarchar(64)",
maxLength: 64,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CompanyType",
table: "CompanyCache",
type: "nvarchar(64)",
maxLength: 64,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "SicCodesJson",
table: "CompanyCache",
type: "nvarchar(256)",
maxLength: 256,
nullable: true);
}
/// <inheritdoc />
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<Guid>(
name: "UserId1",
table: "CVChecks",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.CreateTable(
name: "User",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ChecksUsedThisMonth = table.Column<int>(type: "int", nullable: false),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Plan = table.Column<int>(type: "int", nullable: false),
StripeCustomerId = table.Column<string>(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");
}
}
}

View File

@@ -0,0 +1,502 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrueCV.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Details")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<Guid?>("EntityId")
.HasColumnType("uniqueidentifier");
b.Property<string>("EntityType")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("BlobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("datetime2");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("ExtractedDataJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("OriginalFileName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("ProcessingStage")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ReportJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<int?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<Guid>("CVCheckId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Description")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<int>("ScoreImpact")
.HasColumnType("int");
b.Property<string>("Severity")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("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<string>("CompanyNumber")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("AccountsCategory")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CachedAt")
.HasColumnType("datetime2");
b.Property<string>("CompanyName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("CompanyType")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateOnly?>("DissolutionDate")
.HasColumnType("date");
b.Property<DateOnly?>("IncorporationDate")
.HasColumnType("date");
b.Property<string>("SicCodesJson")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("CompanyNumber");
b.ToTable("CompanyCache");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<int>("ChecksUsedThisMonth")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("Plan")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<string>("StripeCustomerId")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("TrueCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", 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<System.Guid>", 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
}
}
}

View File

@@ -0,0 +1,55 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TrueCV.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddAuditLogTable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AuditLogs",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Action = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
EntityType = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: true),
EntityId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Details = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: true),
IpAddress = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: true),
CreatedAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AuditLogs");
}
}
}

View File

@@ -153,6 +153,52 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("TrueCV.Domain.Entities.AuditLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("Details")
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<Guid?>("EntityId")
.HasColumnType("uniqueidentifier");
b.Property<string>("EntityType")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("IpAddress")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<Guid>("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<Guid>("Id")
@@ -178,6 +224,10 @@ namespace TrueCV.Infrastructure.Data.Migrations
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("ProcessingStage")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ReportJson")
.HasColumnType("nvarchar(max)");
@@ -189,9 +239,6 @@ namespace TrueCV.Infrastructure.Data.Migrations
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId1")
.HasColumnType("uniqueidentifier");
b.Property<int?>("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<string>("AccountsCategory")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CachedAt")
.HasColumnType("datetime2");
@@ -262,12 +311,20 @@ namespace TrueCV.Infrastructure.Data.Migrations
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("CompanyType")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateOnly?>("DissolutionDate")
.HasColumnType("date");
b.Property<DateOnly?>("IncorporationDate")
.HasColumnType("date");
b.Property<string>("SicCodesJson")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("ChecksUsedThisMonth")
.HasColumnType("int");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("Plan")
.HasColumnType("int");
b.Property<string>("StripeCustomerId")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.ToTable("User");
});
modelBuilder.Entity("TrueCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<Guid>("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");

View File

@@ -95,6 +95,7 @@ public static class DependencyInjection
services.AddScoped<ITimelineAnalyserService, TimelineAnalyserService>();
services.AddScoped<ICVCheckService, CVCheckService>();
services.AddScoped<IUserContextService, UserContextService>();
services.AddScoped<IAuditService, AuditService>();
// Register file storage - use local storage if configured, otherwise Azure
var useLocalStorage = configuration.GetValue<bool>("UseLocalStorage");

View File

@@ -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; }

View File

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

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,

View File

@@ -9,6 +9,7 @@
@inject ILogger<Dashboard> Logger
@inject IJSRuntime JSRuntime
@inject TrueCV.Web.Services.PdfReportService PdfReportService
@inject IAuditService AuditService
<PageTitle>Dashboard - TrueCV</PageTitle>
@@ -179,7 +180,7 @@
case "Processing":
<span class="badge bg-primary">
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true" style="width: 0.7rem; height: 0.7rem;"></span>
Processing
@(check.ProcessingStage ?? "Processing")
</span>
break;
case "Pending":
@@ -206,24 +207,32 @@
}
</td>
<td class="text-end">
@if (check.Status == "Completed")
{
<a href="/report/@check.Id" class="btn btn-sm btn-outline-primary" @onclick:stopPropagation="true">
View Report
</a>
}
else if (check.Status is "Pending" or "Processing")
{
<button class="btn btn-sm btn-outline-secondary" disabled>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<div class="btn-group" role="group">
@if (check.Status == "Completed")
{
<a href="/report/@check.Id" class="btn btn-sm btn-outline-primary" @onclick:stopPropagation="true">
View Report
</a>
}
else if (check.Status is "Pending" or "Processing")
{
<button class="btn btn-sm btn-outline-secondary" disabled>
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</button>
}
else
{
<a href="/check" class="btn btn-sm btn-outline-warning" @onclick:stopPropagation="true">
Retry
</a>
}
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteCheck(check.Id)" @onclick:stopPropagation="true" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/>
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/>
</svg>
</button>
}
else
{
<a href="/check" class="btn btn-sm btn-outline-warning">
Retry
</a>
}
</div>
</td>
</tr>
}
@@ -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;
}
}
}

View File

@@ -8,6 +8,7 @@
@inject AuthenticationStateProvider AuthenticationStateProvider
@inject ILogger<Report> Logger
@inject IJSRuntime JSRuntime
@inject IAuditService AuditService
<PageTitle>Verification Report - TrueCV</PageTitle>
@@ -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)

View File

@@ -20,6 +20,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
private readonly Mock<ICompanyVerifierService> _companyVerifierServiceMock;
private readonly Mock<IEducationVerifierService> _educationVerifierServiceMock;
private readonly Mock<ITimelineAnalyserService> _timelineAnalyserServiceMock;
private readonly Mock<IAuditService> _auditServiceMock;
private readonly Mock<ILogger<ProcessCVCheckJob>> _loggerMock;
private readonly ProcessCVCheckJob _sut;
@@ -40,6 +41,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
_companyVerifierServiceMock = new Mock<ICompanyVerifierService>();
_educationVerifierServiceMock = new Mock<IEducationVerifierService>();
_timelineAnalyserServiceMock = new Mock<ITimelineAnalyserService>();
_auditServiceMock = new Mock<IAuditService>();
_loggerMock = new Mock<ILogger<ProcessCVCheckJob>>();
_sut = new ProcessCVCheckJob(
@@ -49,6 +51,7 @@ public sealed class ProcessCVCheckJobTests : IDisposable
_companyVerifierServiceMock.Object,
_educationVerifierServiceMock.Object,
_timelineAnalyserServiceMock.Object,
_auditServiceMock.Object,
_loggerMock.Object);
}

View File

@@ -23,6 +23,7 @@ public sealed class CVCheckServiceTests : IDisposable
private readonly ApplicationDbContext _dbContext;
private readonly Mock<IFileStorageService> _fileStorageServiceMock;
private readonly Mock<IBackgroundJobClient> _backgroundJobClientMock;
private readonly Mock<IAuditService> _auditServiceMock;
private readonly Mock<ILogger<CVCheckService>> _loggerMock;
private readonly CVCheckService _sut;
@@ -35,12 +36,14 @@ public sealed class CVCheckServiceTests : IDisposable
_dbContext = new ApplicationDbContext(options);
_fileStorageServiceMock = new Mock<IFileStorageService>();
_backgroundJobClientMock = new Mock<IBackgroundJobClient>();
_auditServiceMock = new Mock<IAuditService>();
_loggerMock = new Mock<ILogger<CVCheckService>>();
_sut = new CVCheckService(
_dbContext,
_fileStorageServiceMock.Object,
_backgroundJobClientMock.Object,
_auditServiceMock.Object,
_loggerMock.Object);
}