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:
@@ -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; }
|
||||
}
|
||||
|
||||
18
src/TrueCV.Application/Interfaces/IAuditService.cs
Normal file
18
src/TrueCV.Application/Interfaces/IAuditService.cs
Normal 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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
28
src/TrueCV.Domain/Entities/AuditLog.cs
Normal file
28
src/TrueCV.Domain/Entities/AuditLog.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>()
|
||||
|
||||
456
src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.Designer.cs
generated
Normal file
456
src/TrueCV.Infrastructure/Data/Migrations/20260120191035_AddProcessingStageToCV.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
502
src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.Designer.cs
generated
Normal file
502
src/TrueCV.Infrastructure/Data/Migrations/20260120194532_AddAuditLogTable.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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(
|
||||
|
||||
52
src/TrueCV.Infrastructure/Services/AuditService.cs
Normal file
52
src/TrueCV.Infrastructure/Services/AuditService.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TrueCV.Application.Interfaces;
|
||||
using TrueCV.Domain.Entities;
|
||||
using TrueCV.Infrastructure.Data;
|
||||
|
||||
namespace TrueCV.Infrastructure.Services;
|
||||
|
||||
public sealed class AuditService : IAuditService
|
||||
{
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
private readonly ILogger<AuditService> _logger;
|
||||
|
||||
public AuditService(ApplicationDbContext dbContext, ILogger<AuditService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task LogAsync(
|
||||
Guid userId,
|
||||
string action,
|
||||
string? entityType = null,
|
||||
Guid? entityId = null,
|
||||
string? details = null,
|
||||
string? ipAddress = null)
|
||||
{
|
||||
var auditLog = new AuditLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Action = action,
|
||||
EntityType = entityType,
|
||||
EntityId = entityId,
|
||||
Details = details,
|
||||
IpAddress = ipAddress,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_dbContext.AuditLogs.Add(auditLog);
|
||||
|
||||
try
|
||||
{
|
||||
await _dbContext.SaveChangesAsync();
|
||||
_logger.LogDebug("Audit log created: {Action} by user {UserId}", action, userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't let audit failures break the main flow
|
||||
_logger.LogError(ex, "Failed to create audit log: {Action} by user {UserId}", action, userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,17 +18,20 @@ public sealed class CVCheckService : ICVCheckService
|
||||
private readonly ApplicationDbContext _dbContext;
|
||||
private readonly IFileStorageService _fileStorageService;
|
||||
private readonly IBackgroundJobClient _backgroundJobClient;
|
||||
private readonly IAuditService _auditService;
|
||||
private readonly ILogger<CVCheckService> _logger;
|
||||
|
||||
public CVCheckService(
|
||||
ApplicationDbContext dbContext,
|
||||
IFileStorageService fileStorageService,
|
||||
IBackgroundJobClient backgroundJobClient,
|
||||
IAuditService auditService,
|
||||
ILogger<CVCheckService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_fileStorageService = fileStorageService;
|
||||
_backgroundJobClient = backgroundJobClient;
|
||||
_auditService = auditService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -66,6 +69,8 @@ public sealed class CVCheckService : ICVCheckService
|
||||
"CV check {CheckId} created for user {UserId}, processing queued",
|
||||
cvCheck.Id, userId);
|
||||
|
||||
await _auditService.LogAsync(userId, AuditActions.CVUploaded, "CVCheck", cvCheck.Id, $"File: {fileName}");
|
||||
|
||||
return cvCheck.Id;
|
||||
}
|
||||
|
||||
@@ -150,6 +155,33 @@ public sealed class CVCheckService : ICVCheckService
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteCheckAsync(Guid checkId, Guid userId)
|
||||
{
|
||||
_logger.LogDebug("Deleting CV check {CheckId} for user {UserId}", checkId, userId);
|
||||
|
||||
var cvCheck = await _dbContext.CVChecks
|
||||
.Include(c => c.Flags)
|
||||
.FirstOrDefaultAsync(c => c.Id == checkId && c.UserId == userId);
|
||||
|
||||
if (cvCheck is null)
|
||||
{
|
||||
_logger.LogWarning("CV check {CheckId} not found for user {UserId}", checkId, userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
var fileName = cvCheck.OriginalFileName;
|
||||
|
||||
_dbContext.CVFlags.RemoveRange(cvCheck.Flags);
|
||||
_dbContext.CVChecks.Remove(cvCheck);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation("Deleted CV check {CheckId} for user {UserId}", checkId, userId);
|
||||
|
||||
await _auditService.LogAsync(userId, AuditActions.CVDeleted, "CVCheck", checkId, $"File: {fileName}");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static CVCheckDto MapToDto(CVCheck cvCheck)
|
||||
{
|
||||
return new CVCheckDto
|
||||
@@ -158,6 +190,7 @@ public sealed class CVCheckService : ICVCheckService
|
||||
OriginalFileName = cvCheck.OriginalFileName,
|
||||
Status = cvCheck.Status.ToString(),
|
||||
VeracityScore = cvCheck.VeracityScore,
|
||||
ProcessingStage = cvCheck.ProcessingStage,
|
||||
CreatedAt = cvCheck.CreatedAt,
|
||||
CompletedAt = cvCheck.CompletedAt
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
private readonly IDbContextFactory<ApplicationDbContext> _dbContextFactory;
|
||||
private readonly ILogger<CompanyVerifierService> _logger;
|
||||
|
||||
private const int FuzzyMatchThreshold = 70;
|
||||
private const int FuzzyMatchThreshold = 85;
|
||||
private const int CacheExpirationDays = 30;
|
||||
|
||||
// SIC codes for tech/software companies
|
||||
@@ -135,7 +135,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
MatchedCompanyNumber = match.Item.CompanyNumber,
|
||||
MatchScore = match.Score,
|
||||
IsVerified = true,
|
||||
VerificationNotes = $"Matched with {match.Score}% confidence",
|
||||
VerificationNotes = null,
|
||||
ClaimedStartDate = startDate,
|
||||
ClaimedEndDate = endDate,
|
||||
CompanyType = companyType,
|
||||
@@ -549,7 +549,8 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
}
|
||||
|
||||
var matches = cachedCompanies
|
||||
.Select(c => new { Company = c, Score = Fuzz.Ratio(companyName.ToUpperInvariant(), c.CompanyName.ToUpperInvariant()) })
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.CompanyName))
|
||||
.Select(c => new { Company = c, Score = Fuzz.TokenSetRatio(companyName.ToUpperInvariant(), c.CompanyName.ToUpperInvariant()) })
|
||||
.Where(m => m.Score >= FuzzyMatchThreshold)
|
||||
.OrderByDescending(m => m.Score)
|
||||
.FirstOrDefault();
|
||||
@@ -564,7 +565,8 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
var normalizedSearch = companyName.ToUpperInvariant();
|
||||
|
||||
var matches = items
|
||||
.Select(item => (Item: item, Score: Fuzz.Ratio(normalizedSearch, item.Title.ToUpperInvariant())))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Title))
|
||||
.Select(item => (Item: item, Score: Fuzz.TokenSetRatio(normalizedSearch, item.Title.ToUpperInvariant())))
|
||||
.Where(m => m.Score >= FuzzyMatchThreshold)
|
||||
.OrderByDescending(m => m.Score)
|
||||
.ToList();
|
||||
@@ -574,45 +576,53 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
|
||||
private async Task CacheCompanyAsync(CompaniesHouseSearchItem item, CompaniesHouseCompany? details)
|
||||
{
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var existingCache = await dbContext.CompanyCache
|
||||
.FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber);
|
||||
|
||||
var sicCodes = details?.SicCodes ?? item.SicCodes;
|
||||
var sicCodesJson = sicCodes != null ? JsonSerializer.Serialize(sicCodes) : null;
|
||||
var accountsCategory = details?.Accounts?.LastAccounts?.Type;
|
||||
|
||||
if (existingCache is not null)
|
||||
try
|
||||
{
|
||||
existingCache.CompanyName = item.Title;
|
||||
existingCache.Status = item.CompanyStatus ?? "Unknown";
|
||||
existingCache.CompanyType = item.CompanyType;
|
||||
existingCache.IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation);
|
||||
existingCache.DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation);
|
||||
existingCache.AccountsCategory = accountsCategory;
|
||||
existingCache.SicCodesJson = sicCodesJson;
|
||||
existingCache.CachedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
var cacheEntry = new CompanyCache
|
||||
await using var dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
var existingCache = await dbContext.CompanyCache
|
||||
.FirstOrDefaultAsync(c => c.CompanyNumber == item.CompanyNumber);
|
||||
|
||||
var sicCodes = details?.SicCodes ?? item.SicCodes;
|
||||
var sicCodesJson = sicCodes != null ? JsonSerializer.Serialize(sicCodes) : null;
|
||||
var accountsCategory = details?.Accounts?.LastAccounts?.Type;
|
||||
|
||||
if (existingCache is not null)
|
||||
{
|
||||
CompanyNumber = item.CompanyNumber,
|
||||
CompanyName = item.Title,
|
||||
Status = item.CompanyStatus ?? "Unknown",
|
||||
CompanyType = item.CompanyType,
|
||||
IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation),
|
||||
DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation),
|
||||
AccountsCategory = accountsCategory,
|
||||
SicCodesJson = sicCodesJson,
|
||||
CachedAt = DateTime.UtcNow
|
||||
};
|
||||
existingCache.CompanyName = item.Title;
|
||||
existingCache.Status = item.CompanyStatus ?? "Unknown";
|
||||
existingCache.CompanyType = item.CompanyType;
|
||||
existingCache.IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation);
|
||||
existingCache.DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation);
|
||||
existingCache.AccountsCategory = accountsCategory;
|
||||
existingCache.SicCodesJson = sicCodesJson;
|
||||
existingCache.CachedAt = DateTime.UtcNow;
|
||||
}
|
||||
else
|
||||
{
|
||||
var cacheEntry = new CompanyCache
|
||||
{
|
||||
CompanyNumber = item.CompanyNumber,
|
||||
CompanyName = item.Title,
|
||||
Status = item.CompanyStatus ?? "Unknown",
|
||||
CompanyType = item.CompanyType,
|
||||
IncorporationDate = DateHelpers.ParseDate(item.DateOfCreation),
|
||||
DissolutionDate = DateHelpers.ParseDate(item.DateOfCessation),
|
||||
AccountsCategory = accountsCategory,
|
||||
SicCodesJson = sicCodesJson,
|
||||
CachedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.CompanyCache.Add(cacheEntry);
|
||||
dbContext.CompanyCache.Add(cacheEntry);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("PK_CompanyCache") == true)
|
||||
{
|
||||
// Race condition: another task already cached this company - ignore
|
||||
_logger.LogDebug("Company {CompanyNumber} already cached by another task", item.CompanyNumber);
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private CompanyVerificationResult CreateResultFromCache(
|
||||
@@ -623,13 +633,22 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
string? jobTitle,
|
||||
List<CompanyVerificationFlag> flags)
|
||||
{
|
||||
var matchScore = Fuzz.Ratio(
|
||||
var matchScore = Fuzz.TokenSetRatio(
|
||||
claimedCompany.ToUpperInvariant(),
|
||||
cached.CompanyName.ToUpperInvariant());
|
||||
|
||||
var sicCodes = !string.IsNullOrEmpty(cached.SicCodesJson)
|
||||
? JsonSerializer.Deserialize<List<string>>(cached.SicCodesJson)
|
||||
: null;
|
||||
List<string>? sicCodes = null;
|
||||
if (!string.IsNullOrEmpty(cached.SicCodesJson))
|
||||
{
|
||||
try
|
||||
{
|
||||
sicCodes = JsonSerializer.Deserialize<List<string>>(cached.SicCodesJson);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Ignore malformed JSON in cache
|
||||
}
|
||||
}
|
||||
|
||||
// Run all verification checks
|
||||
CheckIncorporationDate(flags, startDate, cached.IncorporationDate, cached.CompanyName);
|
||||
@@ -657,7 +676,7 @@ public sealed class CompanyVerifierService : ICompanyVerifierService
|
||||
MatchedCompanyNumber = cached.CompanyNumber,
|
||||
MatchScore = matchScore,
|
||||
IsVerified = true,
|
||||
VerificationNotes = $"Matched from cache with {matchScore}% confidence",
|
||||
VerificationNotes = null,
|
||||
ClaimedStartDate = startDate,
|
||||
ClaimedEndDate = endDate,
|
||||
CompanyType = cached.CompanyType,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user