refactor: Rename TrueCV to RealCV throughout codebase

- Renamed all directories (TrueCV.* -> RealCV.*)
- Renamed all project files (.csproj)
- Renamed solution file (TrueCV.sln -> RealCV.sln)
- Updated all namespaces in C# and Razor files
- Updated project references
- Updated CSS variable names

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-22 20:47:55 +00:00
parent 6f384f8d09
commit 92a3b60878
107 changed files with 693 additions and 554 deletions

View File

@@ -0,0 +1,8 @@
namespace RealCV.Infrastructure.Configuration;
public sealed class AnthropicSettings
{
public const string SectionName = "Anthropic";
public required string ApiKey { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace RealCV.Infrastructure.Configuration;
public sealed class AzureBlobSettings
{
public const string SectionName = "AzureBlob";
public required string ConnectionString { get; init; }
public required string ContainerName { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace RealCV.Infrastructure.Configuration;
public sealed class CompaniesHouseSettings
{
public const string SectionName = "CompaniesHouse";
public required string BaseUrl { get; init; }
public required string ApiKey { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace RealCV.Infrastructure.Configuration;
public sealed class LocalStorageSettings
{
public const string SectionName = "LocalStorage";
public string StoragePath { get; set; } = "./uploads";
}

View File

@@ -0,0 +1,127 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using RealCV.Domain.Entities;
using RealCV.Infrastructure.Identity;
namespace RealCV.Infrastructure.Data;
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
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)
{
base.OnModelCreating(builder);
ConfigureApplicationUser(builder);
ConfigureCVCheck(builder);
ConfigureCVFlag(builder);
ConfigureCompanyCache(builder);
ConfigureAuditLog(builder);
}
private static void ConfigureApplicationUser(ModelBuilder builder)
{
builder.Entity<ApplicationUser>(entity =>
{
entity.Property(u => u.Plan)
.HasConversion<string>()
.HasMaxLength(32);
entity.Property(u => u.StripeCustomerId)
.HasMaxLength(256);
entity.HasMany(u => u.CVChecks)
.WithOne()
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
}
private static void ConfigureCVCheck(ModelBuilder builder)
{
builder.Entity<CVCheck>(entity =>
{
entity.Property(c => c.Status)
.HasConversion<string>()
.HasMaxLength(32);
entity.HasIndex(c => c.UserId)
.HasDatabaseName("IX_CVChecks_UserId");
entity.HasIndex(c => c.Status)
.HasDatabaseName("IX_CVChecks_Status");
entity.HasMany(c => c.Flags)
.WithOne(f => f.CVCheck)
.HasForeignKey(f => f.CVCheckId)
.OnDelete(DeleteBehavior.Cascade);
});
}
private static void ConfigureCVFlag(ModelBuilder builder)
{
builder.Entity<CVFlag>(entity =>
{
entity.Property(f => f.Category)
.HasConversion<string>()
.HasMaxLength(32);
entity.Property(f => f.Severity)
.HasConversion<string>()
.HasMaxLength(32);
entity.HasIndex(f => f.CVCheckId)
.HasDatabaseName("IX_CVFlags_CVCheckId");
});
}
private static void ConfigureCompanyCache(ModelBuilder builder)
{
builder.Entity<CompanyCache>(entity =>
{
entity.HasKey(c => c.CompanyNumber);
entity.Property(c => c.CompanyNumber)
.HasMaxLength(32);
});
}
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>()
.Where(e => e.State == EntityState.Added)
.Select(e => e.Entity);
foreach (var cvCheck in newCVChecks)
{
cvCheck.CreatedAt = DateTime.UtcNow;
}
return await base.SaveChangesAsync(cancellationToken);
}
}

View File

@@ -0,0 +1,480 @@
// <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 RealCV.Infrastructure.Data;
#nullable disable
namespace RealCV.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20260118182916_InitialCreate")]
partial class InitialCreate
{
/// <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("RealCV.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>("ReportJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid?>("UserId1")
.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.HasIndex("UserId1");
b.ToTable("CVChecks");
});
modelBuilder.Entity("RealCV.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("RealCV.Domain.Entities.CompanyCache", b =>
{
b.Property<string>("CompanyNumber")
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTime>("CachedAt")
.HasColumnType("datetime2");
b.Property<string>("CompanyName")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<DateOnly?>("DissolutionDate")
.HasColumnType("date");
b.Property<DateOnly?>("IncorporationDate")
.HasColumnType("date");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("CompanyNumber");
b.ToTable("CompanyCache");
});
modelBuilder.Entity("RealCV.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("RealCV.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("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("RealCV.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("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("RealCV.Domain.Entities.User", null)
.WithMany("CVChecks")
.HasForeignKey("UserId1");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CVCheck");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("RealCV.Domain.Entities.User", b =>
{
b.Navigation("CVChecks");
});
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Navigation("CVChecks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,345 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RealCV.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Plan = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
StripeCustomerId = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ChecksUsedThisMonth = table.Column<int>(type: "int", nullable: false),
UserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "bit", nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(max)", nullable: true),
SecurityStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "bit", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "bit", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LockoutEnabled = table.Column<bool>(type: "bit", nullable: false),
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "CompanyCache",
columns: table => new
{
CompanyNumber = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
CompanyName = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
Status = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
IncorporationDate = table.Column<DateOnly>(type: "date", nullable: true),
DissolutionDate = table.Column<DateOnly>(type: "date", nullable: true),
CachedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CompanyCache", x => x.CompanyNumber);
});
migrationBuilder.CreateTable(
name: "User",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", 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),
ChecksUsedThisMonth = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_User", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
RoleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderDisplayName = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
RoleId = table.Column<Guid>(type: "uniqueidentifier", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(450)", nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CVChecks",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OriginalFileName = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
BlobUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ExtractedDataJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
VeracityScore = table.Column<int>(type: "int", nullable: true),
ReportJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UserId1 = table.Column<Guid>(type: "uniqueidentifier", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_CVChecks", x => x.Id);
table.ForeignKey(
name: "FK_CVChecks_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CVChecks_User_UserId1",
column: x => x.UserId1,
principalTable: "User",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "CVFlags",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
CVCheckId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Category = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Severity = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Title = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
Description = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
ScoreImpact = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CVFlags", x => x.Id);
table.ForeignKey(
name: "FK_CVFlags_CVChecks_CVCheckId",
column: x => x.CVCheckId,
principalTable: "CVChecks",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true,
filter: "[NormalizedName] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true,
filter: "[NormalizedUserName] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_CVChecks_Status",
table: "CVChecks",
column: "Status");
migrationBuilder.CreateIndex(
name: "IX_CVChecks_UserId",
table: "CVChecks",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_CVChecks_UserId1",
table: "CVChecks",
column: "UserId1");
migrationBuilder.CreateIndex(
name: "IX_CVFlags_CVCheckId",
table: "CVFlags",
column: "CVCheckId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "CompanyCache");
migrationBuilder.DropTable(
name: "CVFlags");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "CVChecks");
migrationBuilder.DropTable(
name: "AspNetUsers");
migrationBuilder.DropTable(
name: "User");
}
}
}

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 RealCV.Infrastructure.Data;
#nullable disable
namespace RealCV.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("RealCV.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("RealCV.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("RealCV.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("RealCV.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("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("RealCV.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("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CVCheck");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("RealCV.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 RealCV.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 RealCV.Infrastructure.Data;
#nullable disable
namespace RealCV.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("RealCV.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("RealCV.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("RealCV.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("RealCV.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("RealCV.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("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("RealCV.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("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CVCheck");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("RealCV.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 RealCV.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

@@ -0,0 +1,499 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RealCV.Infrastructure.Data;
#nullable disable
namespace RealCV.Infrastructure.Data.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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("RealCV.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("RealCV.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("RealCV.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("RealCV.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("RealCV.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("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("RealCV.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("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.HasOne("RealCV.Infrastructure.Identity.ApplicationUser", null)
.WithMany("CVChecks")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("RealCV.Domain.Entities.CVFlag", b =>
{
b.HasOne("RealCV.Domain.Entities.CVCheck", "CVCheck")
.WithMany("Flags")
.HasForeignKey("CVCheckId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CVCheck");
});
modelBuilder.Entity("RealCV.Domain.Entities.CVCheck", b =>
{
b.Navigation("Flags");
});
modelBuilder.Entity("RealCV.Infrastructure.Identity.ApplicationUser", b =>
{
b.Navigation("CVChecks");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,132 @@
using Hangfire;
using Hangfire.SqlServer;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Polly;
using Polly.Extensions.Http;
using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Data;
using RealCV.Infrastructure.ExternalApis;
using RealCV.Infrastructure.Jobs;
using RealCV.Infrastructure.Services;
namespace RealCV.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
// Configure DbContext with SQL Server
// AddDbContextFactory enables thread-safe parallel operations
services.AddDbContextFactory<ApplicationDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
}));
// Also register DbContext for scoped injection (non-parallel scenarios)
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: null);
}));
// Configure Hangfire with SQL Server storage
services.AddHangfire(config => config
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(
configuration.GetConnectionString("HangfireConnection"),
new SqlServerStorageOptions
{
CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
QueuePollInterval = TimeSpan.Zero,
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true
}));
services.AddHangfireServer();
// Configure options
services.Configure<CompaniesHouseSettings>(
configuration.GetSection(CompaniesHouseSettings.SectionName));
services.Configure<AnthropicSettings>(
configuration.GetSection(AnthropicSettings.SectionName));
services.Configure<AzureBlobSettings>(
configuration.GetSection(AzureBlobSettings.SectionName));
services.Configure<LocalStorageSettings>(
configuration.GetSection(LocalStorageSettings.SectionName));
// Configure HttpClient for CompaniesHouseClient with retry policy
services.AddHttpClient<CompaniesHouseClient>((serviceProvider, client) =>
{
var settings = configuration
.GetSection(CompaniesHouseSettings.SectionName)
.Get<CompaniesHouseSettings>();
if (settings is not null)
{
client.BaseAddress = new Uri(settings.BaseUrl);
}
})
.AddPolicyHandler(GetRetryPolicy());
// Register services
services.AddScoped<ICVParserService, CVParserService>();
services.AddScoped<ICompanyNameMatcherService, AICompanyNameMatcherService>();
services.AddScoped<ICompanyVerifierService, CompanyVerifierService>();
services.AddScoped<IEducationVerifierService, EducationVerifierService>();
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");
if (useLocalStorage)
{
services.AddScoped<IFileStorageService, LocalFileStorageService>();
}
else
{
services.AddScoped<IFileStorageService, FileStorageService>();
}
// Register Hangfire jobs
services.AddTransient<ProcessCVCheckJob>();
return services;
}
private static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
return HttpPolicyExtensions
.HandleTransientHttpError()
.OrResult(msg => msg.StatusCode == System.Net.HttpStatusCode.TooManyRequests)
.WaitAndRetryAsync(
retryCount: 3,
sleepDurationProvider: retryAttempt =>
TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
onRetry: (outcome, timespan, retryAttempt, context) =>
{
// Logging could be added here via ILogger if injected
});
}
}

View File

@@ -0,0 +1,248 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.DTOs;
using RealCV.Infrastructure.Configuration;
namespace RealCV.Infrastructure.ExternalApis;
public sealed class CompaniesHouseClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<CompaniesHouseClient> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true
};
public CompaniesHouseClient(
HttpClient httpClient,
IOptions<CompaniesHouseSettings> settings,
ILogger<CompaniesHouseClient> logger)
{
_httpClient = httpClient;
_logger = logger;
var apiKey = settings.Value.ApiKey;
var authValue = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{apiKey}:"));
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", authValue);
_httpClient.BaseAddress = new Uri(settings.Value.BaseUrl);
}
public async Task<CompaniesHouseSearchResponse?> SearchCompaniesAsync(
string query,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
var encodedQuery = Uri.EscapeDataString(query);
var requestUrl = $"/search/companies?q={encodedQuery}";
_logger.LogDebug("Searching Companies House for: {Query}", query);
try
{
var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.");
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CompaniesHouseSearchResponse>(
JsonOptions,
cancellationToken);
_logger.LogDebug("Found {Count} companies matching query: {Query}",
result?.Items?.Count ?? 0, query);
return result;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex);
}
}
public async Task<CompaniesHouseCompany?> GetCompanyAsync(
string companyNumber,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(companyNumber);
var requestUrl = $"/company/{companyNumber}";
_logger.LogDebug("Fetching company details for: {CompanyNumber}", companyNumber);
try
{
var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("Company not found: {CompanyNumber}", companyNumber);
return null;
}
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.");
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CompaniesHouseCompany>(
JsonOptions,
cancellationToken);
_logger.LogDebug("Retrieved company: {CompanyName} ({CompanyNumber})",
result?.CompanyName, companyNumber);
return result;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex);
}
}
public async Task<CompaniesHouseOfficersResponse?> GetOfficersAsync(
string companyNumber,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(companyNumber);
var requestUrl = $"/company/{companyNumber}/officers";
_logger.LogDebug("Fetching officers for company: {CompanyNumber}", companyNumber);
try
{
var response = await _httpClient.GetAsync(requestUrl, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("No officers found for company: {CompanyNumber}", companyNumber);
return null;
}
if (response.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.");
}
response.EnsureSuccessStatusCode();
var result = await response.Content.ReadFromJsonAsync<CompaniesHouseOfficersResponse>(
JsonOptions,
cancellationToken);
_logger.LogDebug("Retrieved {Count} officers for company {CompanyNumber}",
result?.Items?.Count ?? 0, companyNumber);
return result;
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
_logger.LogWarning("Rate limit exceeded for Companies House API");
throw new CompaniesHouseRateLimitException("Rate limit exceeded. Please try again later.", ex);
}
}
}
// DTOs for Companies House API responses
public sealed record CompaniesHouseSearchResponse
{
public int TotalResults { get; init; }
public int ItemsPerPage { get; init; }
public int StartIndex { get; init; }
public List<CompaniesHouseSearchItem> Items { get; init; } = [];
}
public sealed record CompaniesHouseSearchItem
{
public required string CompanyNumber { get; init; }
public required string Title { get; init; }
public string? CompanyStatus { get; init; }
public string? CompanyType { get; init; }
public string? DateOfCreation { get; init; }
public string? DateOfCessation { get; init; }
public CompaniesHouseAddress? Address { get; init; }
public string? AddressSnippet { get; init; }
public List<string>? SicCodes { get; init; }
}
public sealed record CompaniesHouseCompany
{
public required string CompanyNumber { get; init; }
public required string CompanyName { get; init; }
public string? CompanyStatus { get; init; }
public string? Type { get; init; }
public string? DateOfCreation { get; init; }
public string? DateOfCessation { get; init; }
public CompaniesHouseAddress? RegisteredOfficeAddress { get; init; }
public List<string>? SicCodes { get; init; }
public CompaniesHouseAccounts? Accounts { get; init; }
}
public sealed record CompaniesHouseAccounts
{
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; }
public string? Type { get; init; } // e.g., "micro-entity", "small", "medium", "dormant", etc.
}
public sealed record CompaniesHouseOfficersResponse
{
public int TotalResults { get; init; }
public List<CompaniesHouseOfficer> Items { get; init; } = [];
}
public sealed record CompaniesHouseOfficer
{
public required string Name { get; init; }
public string? OfficerRole { get; init; }
public string? AppointedOn { get; init; }
public string? ResignedOn { get; init; }
}
public sealed record CompaniesHouseAddress
{
public string? Premises { get; init; }
public string? AddressLine1 { get; init; }
public string? AddressLine2 { get; init; }
public string? Locality { get; init; }
public string? Region { get; init; }
public string? PostalCode { get; init; }
public string? Country { get; init; }
}
public class CompaniesHouseRateLimitException : Exception
{
public CompaniesHouseRateLimitException(string message) : base(message) { }
public CompaniesHouseRateLimitException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,32 @@
namespace RealCV.Infrastructure.Helpers;
/// <summary>
/// Helper methods for processing AI/LLM JSON responses.
/// </summary>
public static class JsonResponseHelper
{
/// <summary>
/// Cleans a JSON response by removing markdown code block formatting.
/// </summary>
public static string CleanJsonResponse(string response)
{
var trimmed = response.Trim();
// Remove markdown code blocks
if (trimmed.StartsWith("```json", StringComparison.OrdinalIgnoreCase))
{
trimmed = trimmed[7..];
}
else if (trimmed.StartsWith("```"))
{
trimmed = trimmed[3..];
}
if (trimmed.EndsWith("```"))
{
trimmed = trimmed[..^3];
}
return trimmed.Trim();
}
}

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Identity;
using RealCV.Domain.Entities;
using RealCV.Domain.Enums;
namespace RealCV.Infrastructure.Identity;
public class ApplicationUser : IdentityUser<Guid>
{
public UserPlan Plan { get; set; }
public string? StripeCustomerId { get; set; }
public int ChecksUsedThisMonth { get; set; }
public ICollection<CVCheck> CVChecks { get; set; } = new List<CVCheck>();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\RealCV.Application\RealCV.Application.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Anthropic.SDK" Version="5.8.0" />
<PackageReference Include="Azure.Storage.Blobs" Version="12.27.0" />
<PackageReference Include="DocumentFormat.OpenXml" Version="3.4.1" />
<PackageReference Include="FuzzySharp" Version="2.0.2" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.*" />
<PackageReference Include="Hangfire.SqlServer" Version="1.8.*" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.*" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.*">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.*" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.*" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="UglyToad.PdfPig" Version="1.7.0-custom-5" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,167 @@
using System.Text.Json;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Helpers;
namespace RealCV.Infrastructure.Services;
public sealed class AICompanyNameMatcherService : ICompanyNameMatcherService
{
private readonly AnthropicClient _anthropicClient;
private readonly ILogger<AICompanyNameMatcherService> _logger;
private const string SystemPrompt = """
You are a UK company name matching expert. Your task is to determine if a company name
from a CV matches any of the official company names from Companies House records.
You understand:
- Trading names vs registered names (e.g., "Tesco" = "TESCO PLC")
- Subsidiaries vs parent companies (e.g., "ASDA" might work for "ASDA STORES LIMITED")
- Common abbreviations (Ltd = Limited, PLC = Public Limited Company, CiC = Community Interest Company)
- That completely different words mean different companies (e.g., "Families First" "Families Against Conformity")
You must respond ONLY with valid JSON, no other text or markdown.
""";
private const string MatchingPrompt = """
Compare the company name from a CV against official Companies House records.
CV Company Name: "{CV_COMPANY}"
Companies House Candidates:
{CANDIDATES}
Determine which candidate (if any) is the SAME company as the CV entry.
Rules:
1. A match requires the companies to be the SAME organisation, not just similar names
2. "Families First CiC" is NOT the same as "FAMILIES AGAINST CONFORMITY LTD" - different words = different companies
3. Trading names should match their registered entity (e.g., "Tesco" matches "TESCO PLC")
4. Subsidiaries can match if clearly the same organisation (e.g., "ASDA" could match "ASDA STORES LIMITED")
5. Acronyms in parentheses are abbreviations of the full name (e.g., "North Halifax Partnership (NHP)" = "NORTH HALIFAX PARTNERSHIP")
6. CiC/CIC = Community Interest Company, LLP = Limited Liability Partnership - these are legal suffixes
7. If the CV name contains all the key words of a candidate (ignoring Ltd/Limited/CIC/etc.), it's likely a match
8. If NO candidate is clearly the same company, return "NONE" as the best match
Respond with this exact JSON structure:
{
"bestMatchCompanyNumber": "string (company number of best match, or 'NONE' if no valid match)",
"confidenceScore": number (0-100, where 100 = certain match, 0 = no match),
"matchType": "string (Exact, TradingName, Subsidiary, Parent, NoMatch)",
"reasoning": "string (brief explanation of why this is or isn't a match)"
}
""";
public AICompanyNameMatcherService(
IOptions<AnthropicSettings> settings,
ILogger<AICompanyNameMatcherService> logger)
{
_logger = logger;
_anthropicClient = new AnthropicClient(settings.Value.ApiKey);
}
public async Task<SemanticMatchResult?> FindBestMatchAsync(
string cvCompanyName,
List<CompanyCandidate> candidates,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(cvCompanyName) || candidates.Count == 0)
{
return null;
}
_logger.LogDebug("Using AI to match '{CVCompany}' against {Count} candidates",
cvCompanyName, candidates.Count);
try
{
var candidatesText = string.Join("\n", candidates.Select((c, i) =>
$"{i + 1}. {c.CompanyName} (Number: {c.CompanyNumber}, Status: {c.CompanyStatus ?? "Unknown"})"));
var prompt = MatchingPrompt
.Replace("{CV_COMPANY}", cvCompanyName)
.Replace("{CANDIDATES}", candidatesText);
var messages = new List<Message>
{
new(RoleType.User, prompt)
};
var parameters = new MessageParameters
{
Model = "claude-sonnet-4-20250514",
MaxTokens = 1024,
Messages = messages,
System = [new SystemMessage(SystemPrompt)]
};
var response = await _anthropicClient.Messages.GetClaudeMessageAsync(parameters, cancellationToken);
var responseText = response.Content
.OfType<TextContent>()
.FirstOrDefault()?.Text;
if (string.IsNullOrWhiteSpace(responseText))
{
_logger.LogWarning("AI returned empty response for company matching");
return null;
}
responseText = JsonResponseHelper.CleanJsonResponse(responseText);
var aiResponse = JsonSerializer.Deserialize<AIMatchResponse>(responseText, JsonDefaults.CamelCase);
if (aiResponse is null)
{
_logger.LogWarning("Failed to deserialize AI response: {Response}", responseText);
return null;
}
_logger.LogDebug("AI match result: {CompanyNumber} with {Score}% confidence - {Reasoning}",
aiResponse.BestMatchCompanyNumber, aiResponse.ConfidenceScore, aiResponse.Reasoning);
// Find the matched candidate
if (aiResponse.BestMatchCompanyNumber == "NONE" || aiResponse.ConfidenceScore < 50)
{
return new SemanticMatchResult
{
CandidateCompanyName = "No match",
CandidateCompanyNumber = "NONE",
ConfidenceScore = 0,
MatchType = "NoMatch",
Reasoning = aiResponse.Reasoning
};
}
var matchedCandidate = candidates.FirstOrDefault(c =>
c.CompanyNumber.Equals(aiResponse.BestMatchCompanyNumber, StringComparison.OrdinalIgnoreCase));
if (matchedCandidate is null)
{
_logger.LogWarning("AI returned company number {Number} not in candidates list",
aiResponse.BestMatchCompanyNumber);
return null;
}
return new SemanticMatchResult
{
CandidateCompanyName = matchedCandidate.CompanyName,
CandidateCompanyNumber = matchedCandidate.CompanyNumber,
ConfidenceScore = aiResponse.ConfidenceScore,
MatchType = aiResponse.MatchType,
Reasoning = aiResponse.Reasoning
};
}
catch (Exception ex)
{
_logger.LogError(ex, "AI company matching failed for '{CVCompany}'", cvCompanyName);
return null; // Fall back to fuzzy matching
}
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Domain.Entities;
using RealCV.Infrastructure.Data;
namespace RealCV.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

@@ -0,0 +1,198 @@
using System.Text.Json;
using Hangfire;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using RealCV.Application.DTOs;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Domain.Entities;
using RealCV.Domain.Enums;
using RealCV.Infrastructure.Data;
using RealCV.Infrastructure.Jobs;
namespace RealCV.Infrastructure.Services;
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;
}
public async Task<Guid> CreateCheckAsync(Guid userId, Stream file, string fileName)
{
ArgumentNullException.ThrowIfNull(file);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
_logger.LogDebug("Creating CV check for user {UserId}, file: {FileName}", userId, fileName);
// Upload file to blob storage
var blobUrl = await _fileStorageService.UploadAsync(file, fileName);
_logger.LogDebug("File uploaded to: {BlobUrl}", blobUrl);
// Create CV check record
var cvCheck = new CVCheck
{
Id = Guid.NewGuid(),
UserId = userId,
OriginalFileName = fileName,
BlobUrl = blobUrl,
Status = CheckStatus.Pending
};
_dbContext.CVChecks.Add(cvCheck);
await _dbContext.SaveChangesAsync();
_logger.LogDebug("CV check record created with ID: {CheckId}", cvCheck.Id);
// Queue background job for processing
_backgroundJobClient.Enqueue<ProcessCVCheckJob>(job => job.ExecuteAsync(cvCheck.Id, CancellationToken.None));
_logger.LogInformation(
"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;
}
public async Task<CVCheckDto?> GetCheckAsync(Guid id)
{
_logger.LogDebug("Retrieving CV check: {CheckId}", id);
var cvCheck = await _dbContext.CVChecks
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id);
if (cvCheck is null)
{
_logger.LogDebug("CV check not found: {CheckId}", id);
return null;
}
return MapToDto(cvCheck);
}
public async Task<List<CVCheckDto>> GetUserChecksAsync(Guid userId)
{
_logger.LogDebug("Retrieving CV checks for user: {UserId}", userId);
var checks = await _dbContext.CVChecks
.AsNoTracking()
.Where(c => c.UserId == userId)
.OrderByDescending(c => c.CreatedAt)
.ToListAsync();
_logger.LogDebug("Found {Count} CV checks for user {UserId}", checks.Count, userId);
return checks.Select(MapToDto).ToList();
}
public async Task<CVCheckDto?> GetCheckForUserAsync(Guid id, Guid userId)
{
_logger.LogDebug("Retrieving CV check {CheckId} for user {UserId}", id, userId);
var cvCheck = await _dbContext.CVChecks
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId);
if (cvCheck is null)
{
_logger.LogDebug("CV check not found: {CheckId} for user {UserId}", id, userId);
return null;
}
return MapToDto(cvCheck);
}
public async Task<VeracityReport?> GetReportAsync(Guid checkId, Guid userId)
{
_logger.LogDebug("Retrieving report for CV check {CheckId}, user {UserId}", checkId, userId);
var cvCheck = await _dbContext.CVChecks
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == checkId && c.UserId == userId);
if (cvCheck is null)
{
_logger.LogWarning("CV check not found: {CheckId} for user {UserId}", checkId, userId);
return null;
}
if (cvCheck.Status != CheckStatus.Completed || string.IsNullOrEmpty(cvCheck.ReportJson))
{
_logger.LogDebug("CV check {CheckId} not completed or has no report", checkId);
return null;
}
try
{
var report = JsonSerializer.Deserialize<VeracityReport>(cvCheck.ReportJson, JsonDefaults.CamelCase);
return report;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to deserialize report JSON for check {CheckId}", checkId);
return null;
}
}
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
{
Id = cvCheck.Id,
OriginalFileName = cvCheck.OriginalFileName,
Status = cvCheck.Status.ToString(),
VeracityScore = cvCheck.VeracityScore,
ProcessingStage = cvCheck.ProcessingStage,
CreatedAt = cvCheck.CreatedAt,
CompletedAt = cvCheck.CompletedAt
};
}
}

View File

@@ -0,0 +1,398 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using DocumentFormat.OpenXml.Packaging;
using DocumentFormat.OpenXml.Wordprocessing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Helpers;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
using RealCV.Infrastructure.Configuration;
using RealCV.Infrastructure.Helpers;
using UglyToad.PdfPig;
namespace RealCV.Infrastructure.Services;
public sealed class CVParserService : ICVParserService
{
private readonly AnthropicClient _anthropicClient;
private readonly ILogger<CVParserService> _logger;
private const string SystemPrompt = """
You are a CV/Resume parser. Your task is to extract structured information from CV text.
You must respond ONLY with valid JSON, no other text or markdown.
""";
private const string ExtractionPrompt = """
Parse the following CV text and extract the information into this exact JSON structure:
{
"fullName": "string (required)",
"email": "string or null",
"phone": "string or null",
"employment": [
{
"companyName": "string (required)",
"jobTitle": "string (required)",
"location": "string or null",
"startDate": "YYYY-MM-DD or null",
"endDate": "YYYY-MM-DD or null (null if current)",
"isCurrent": "boolean",
"description": "string or null"
}
],
"education": [
{
"institution": "string (required)",
"qualification": "string or null (e.g., BSc, MSc, PhD)",
"subject": "string or null",
"grade": "string or null",
"startDate": "YYYY-MM-DD or null",
"endDate": "YYYY-MM-DD or null"
}
],
"skills": ["array of skill strings"]
}
Rules:
- For dates, use the first day of the month if only month/year is given (e.g., "Jan 2020" becomes "2020-01-01")
- For dates with only year, use January 1st (e.g., "2020" becomes "2020-01-01")
- Set isCurrent to true if the job appears to be ongoing (e.g., "Present", "Current", no end date mentioned with recent start)
- Extract all employment history in chronological order
- If information is not available, use null
- Do not invent or assume information not present in the text
CV TEXT:
{CV_TEXT}
""";
public CVParserService(
IOptions<AnthropicSettings> settings,
ILogger<CVParserService> logger)
{
_logger = logger;
_anthropicClient = new AnthropicClient(settings.Value.ApiKey);
}
public async Task<CVData> ParseAsync(Stream fileStream, string fileName, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(fileStream);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
_logger.LogDebug("Parsing CV file: {FileName}", fileName);
var extension = Path.GetExtension(fileName).ToLowerInvariant();
// Handle JSON files directly (debug/test format)
if (extension == ".json")
{
var cvData = await ParseJsonFileAsync(fileStream, cancellationToken);
_logger.LogInformation(
"Successfully loaded JSON CV for {FullName} with {EmploymentCount} employment entries and {EducationCount} education entries",
cvData.FullName,
cvData.Employment.Count,
cvData.Education.Count);
return cvData;
}
var text = await ExtractTextAsync(fileStream, fileName, cancellationToken);
if (string.IsNullOrWhiteSpace(text))
{
_logger.LogWarning("No text content extracted from file: {FileName}", fileName);
throw new InvalidOperationException($"Could not extract text content from file: {fileName}");
}
_logger.LogDebug("Extracted {CharCount} characters from {FileName}", text.Length, fileName);
var cvDataFromAI = await ParseWithClaudeAsync(text, cancellationToken);
_logger.LogInformation(
"Successfully parsed CV for {FullName} with {EmploymentCount} employment entries and {EducationCount} education entries",
cvDataFromAI.FullName,
cvDataFromAI.Employment.Count,
cvDataFromAI.Education.Count);
return cvDataFromAI;
}
private async Task<string> ExtractTextAsync(Stream fileStream, string fileName, CancellationToken cancellationToken)
{
var extension = Path.GetExtension(fileName).ToLowerInvariant();
return extension switch
{
".pdf" => await ExtractTextFromPdfAsync(fileStream, cancellationToken),
".docx" => ExtractTextFromDocx(fileStream),
_ => throw new NotSupportedException($"File type '{extension}' is not supported. Only PDF and DOCX files are accepted.")
};
}
private async Task<CVData> ParseJsonFileAsync(Stream fileStream, CancellationToken cancellationToken)
{
var testCv = await JsonSerializer.DeserializeAsync<TestCVData>(fileStream, TestJsonOptions, cancellationToken)
?? throw new InvalidOperationException("Failed to deserialize JSON CV file");
return new CVData
{
FullName = testCv.Personal?.Name ?? "Unknown",
Email = testCv.Personal?.Email,
Phone = testCv.Personal?.Phone,
Employment = testCv.Employment?.Select(e => new EmploymentEntry
{
CompanyName = e.Company ?? "Unknown",
JobTitle = e.JobTitle ?? "Unknown",
Location = e.Location,
StartDate = ParseTestDate(e.StartDate),
EndDate = ParseTestDate(e.EndDate),
IsCurrent = e.EndDate == null,
Description = e.Description
}).ToList() ?? [],
Education = testCv.Education?.Select(e => new EducationEntry
{
Institution = e.Institution ?? "Unknown",
Qualification = e.Qualification,
Subject = e.Subject,
StartDate = ParseTestDate(e.StartDate),
EndDate = ParseTestDate(e.EndDate)
}).ToList() ?? [],
Skills = testCv.Skills ?? []
};
}
private static DateOnly? ParseTestDate(string? dateStr)
{
if (string.IsNullOrEmpty(dateStr)) return null;
// Try parsing YYYY-MM format
if (dateStr.Length == 7 && dateStr[4] == '-')
{
if (int.TryParse(dateStr[..4], out var year) && int.TryParse(dateStr[5..], out var month))
{
return new DateOnly(year, month, 1);
}
}
// Try standard parsing
if (DateOnly.TryParse(dateStr, out var date))
{
return date;
}
return null;
}
private async Task<string> ExtractTextFromPdfAsync(Stream fileStream, CancellationToken cancellationToken)
{
// Copy stream to memory for PdfPig (requires seekable stream)
using var memoryStream = new MemoryStream();
await fileStream.CopyToAsync(memoryStream, cancellationToken);
memoryStream.Position = 0;
using var document = PdfDocument.Open(memoryStream);
var textBuilder = new StringBuilder();
foreach (var page in document.GetPages())
{
cancellationToken.ThrowIfCancellationRequested();
var pageText = page.Text;
textBuilder.AppendLine(pageText);
}
return textBuilder.ToString();
}
private static string ExtractTextFromDocx(Stream fileStream)
{
using var document = WordprocessingDocument.Open(fileStream, false);
var body = document.MainDocumentPart?.Document?.Body;
if (body is null)
{
return string.Empty;
}
var textBuilder = new StringBuilder();
foreach (var paragraph in body.Elements<Paragraph>())
{
var paragraphText = paragraph.InnerText;
if (!string.IsNullOrWhiteSpace(paragraphText))
{
textBuilder.AppendLine(paragraphText);
}
}
return textBuilder.ToString();
}
private async Task<CVData> ParseWithClaudeAsync(string cvText, CancellationToken cancellationToken)
{
var prompt = ExtractionPrompt.Replace("{CV_TEXT}", cvText);
var messages = new List<Message>
{
new(RoleType.User, prompt)
};
var parameters = new MessageParameters
{
Model = "claude-sonnet-4-20250514",
MaxTokens = 4096,
Messages = messages,
System = [new SystemMessage(SystemPrompt)]
};
_logger.LogDebug("Sending CV text to Claude API for parsing");
var response = await _anthropicClient.Messages.GetClaudeMessageAsync(parameters, cancellationToken);
var responseText = response.Content
.OfType<TextContent>()
.FirstOrDefault()?.Text;
if (string.IsNullOrWhiteSpace(responseText))
{
_logger.LogError("Claude API returned empty response");
throw new InvalidOperationException("Failed to parse CV: AI returned empty response");
}
// Clean up response - remove markdown code blocks if present
responseText = JsonResponseHelper.CleanJsonResponse(responseText);
_logger.LogDebug("Received response from Claude API, parsing JSON");
try
{
var parsedResponse = JsonSerializer.Deserialize<ClaudeCVResponse>(responseText, JsonDefaults.CamelCase);
if (parsedResponse is null)
{
throw new InvalidOperationException("Failed to deserialize CV data from AI response");
}
return MapToCVData(parsedResponse);
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to parse Claude response as JSON: {Response}", responseText);
throw new InvalidOperationException("Failed to parse CV: AI returned invalid JSON", ex);
}
}
private static CVData MapToCVData(ClaudeCVResponse response)
{
return new CVData
{
FullName = response.FullName ?? "Unknown",
Email = response.Email,
Phone = response.Phone,
Employment = response.Employment?.Select(e => new EmploymentEntry
{
CompanyName = e.CompanyName ?? "Unknown Company",
JobTitle = e.JobTitle ?? "Unknown Position",
Location = e.Location,
StartDate = DateHelpers.ParseDate(e.StartDate),
EndDate = DateHelpers.ParseDate(e.EndDate),
IsCurrent = e.IsCurrent ?? false,
Description = e.Description
}).ToList() ?? [],
Education = response.Education?.Select(e => new EducationEntry
{
Institution = e.Institution ?? "Unknown Institution",
Qualification = e.Qualification,
Subject = e.Subject,
Grade = e.Grade,
StartDate = DateHelpers.ParseDate(e.StartDate),
EndDate = DateHelpers.ParseDate(e.EndDate)
}).ToList() ?? [],
Skills = response.Skills ?? []
};
}
// JSON options for test/debug CV format (snake_case)
private static readonly JsonSerializerOptions TestJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
Converters = { new JsonStringEnumConverter() }
};
// DTOs for test JSON format (snake_case with nested personal object)
private sealed record TestCVData
{
public string? CvId { get; init; }
public string? Category { get; init; }
public List<string>? ExpectedFlags { get; init; }
public TestPersonalData? Personal { get; init; }
public string? Profile { get; init; }
public List<TestEmploymentEntry>? Employment { get; init; }
public List<TestEducationEntry>? Education { get; init; }
public List<string>? Skills { get; init; }
}
private sealed record TestPersonalData
{
public string? Name { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
public string? Address { get; init; }
public string? LinkedIn { get; init; }
}
private sealed record TestEmploymentEntry
{
public string? Company { get; init; }
public string? JobTitle { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
public string? Location { get; init; }
public string? Description { get; init; }
public List<string>? Achievements { get; init; }
}
private sealed record TestEducationEntry
{
public string? Institution { get; init; }
public string? Qualification { get; init; }
public string? Subject { get; init; }
public string? Classification { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
}
// Internal DTOs for Claude response parsing
private sealed record ClaudeCVResponse
{
public string? FullName { get; init; }
public string? Email { get; init; }
public string? Phone { get; init; }
public List<ClaudeEmploymentEntry>? Employment { get; init; }
public List<ClaudeEducationEntry>? Education { get; init; }
public List<string>? Skills { get; init; }
}
private sealed record ClaudeEmploymentEntry
{
public string? CompanyName { get; init; }
public string? JobTitle { get; init; }
public string? Location { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
public bool? IsCurrent { get; init; }
public string? Description { get; init; }
}
private sealed record ClaudeEducationEntry
{
public string? Institution { get; init; }
public string? Qualification { get; init; }
public string? Subject { get; init; }
public string? Grade { get; init; }
public string? StartDate { get; init; }
public string? EndDate { get; init; }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
using RealCV.Application.Data;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
namespace RealCV.Infrastructure.Services;
public sealed class EducationVerifierService : IEducationVerifierService
{
private const int MinimumDegreeYears = 1;
private const int MaximumDegreeYears = 8;
private const int MinimumGraduationAge = 18;
public EducationVerificationResult Verify(EducationEntry education)
{
var institution = education.Institution;
// Check for diploma mill first (highest priority flag)
if (DiplomaMills.IsDiplomaMill(institution))
{
return new EducationVerificationResult
{
ClaimedInstitution = institution,
Status = "DiplomaMill",
IsVerified = false,
IsDiplomaMill = true,
IsSuspicious = true,
VerificationNotes = "Institution is on the diploma mill blacklist",
ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate,
DatesArePlausible = true,
ClaimedQualification = education.Qualification,
ClaimedSubject = education.Subject
};
}
// Check for suspicious patterns
if (DiplomaMills.HasSuspiciousPattern(institution))
{
return new EducationVerificationResult
{
ClaimedInstitution = institution,
Status = "Suspicious",
IsVerified = false,
IsDiplomaMill = false,
IsSuspicious = true,
VerificationNotes = "Institution name contains suspicious patterns common in diploma mills",
ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate,
DatesArePlausible = true,
ClaimedQualification = education.Qualification,
ClaimedSubject = education.Subject
};
}
// Check if it's a recognised UK institution
var officialName = UKInstitutions.GetOfficialName(institution);
if (officialName != null)
{
var (datesPlausible, dateNotes) = CheckDatePlausibility(education.StartDate, education.EndDate);
return new EducationVerificationResult
{
ClaimedInstitution = institution,
MatchedInstitution = officialName,
Status = "Recognised",
IsVerified = true,
IsDiplomaMill = false,
IsSuspicious = false,
VerificationNotes = institution.Equals(officialName, StringComparison.OrdinalIgnoreCase)
? "Verified UK higher education institution"
: $"Matched to official name: {officialName}",
ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate,
DatesArePlausible = datesPlausible,
DatePlausibilityNotes = dateNotes,
ClaimedQualification = education.Qualification,
ClaimedSubject = education.Subject
};
}
// Not in our database - could be international or unrecognised
var isUnknownInstitution = string.IsNullOrWhiteSpace(institution) ||
institution.Equals("Unknown Institution", StringComparison.OrdinalIgnoreCase) ||
institution.Equals("Unknown", StringComparison.OrdinalIgnoreCase);
return new EducationVerificationResult
{
ClaimedInstitution = institution,
Status = "Unknown",
IsVerified = false,
IsDiplomaMill = false,
IsSuspicious = false,
VerificationNotes = isUnknownInstitution ? null : "Institution not found in UK recognised institutions database. May be an international institution.",
ClaimedStartDate = education.StartDate,
ClaimedEndDate = education.EndDate,
DatesArePlausible = true,
ClaimedQualification = education.Qualification,
ClaimedSubject = education.Subject
};
}
public List<EducationVerificationResult> VerifyAll(
List<EducationEntry> education,
List<EmploymentEntry>? employment = null)
{
var results = new List<EducationVerificationResult>();
foreach (var edu in education)
{
var result = Verify(edu);
// If we have employment data, check for timeline issues
if (employment?.Count > 0 && result.ClaimedEndDate.HasValue)
{
var (timelinePlausible, timelineNotes) = CheckEducationEmploymentTimeline(
result.ClaimedEndDate.Value,
employment);
if (!timelinePlausible)
{
result = result with
{
DatesArePlausible = false,
DatePlausibilityNotes = CombineNotes(result.DatePlausibilityNotes, timelineNotes)
};
}
}
results.Add(result);
}
// Check for overlapping education periods
CheckOverlappingEducation(results);
return results;
}
private static (bool isPlausible, string? notes) CheckDatePlausibility(DateOnly? startDate, DateOnly? endDate)
{
if (!startDate.HasValue || !endDate.HasValue)
{
return (true, null);
}
var start = startDate.Value;
var end = endDate.Value;
// End date should be after start date
if (end <= start)
{
return (false, "End date is before or equal to start date");
}
// Check course duration is reasonable
var years = (end.ToDateTime(TimeOnly.MinValue) - start.ToDateTime(TimeOnly.MinValue)).TotalDays / 365.25;
if (years < MinimumDegreeYears)
{
return (false, $"Course duration ({years:F1} years) is unusually short for a degree");
}
if (years > MaximumDegreeYears)
{
return (false, $"Course duration ({years:F1} years) is unusually long");
}
// Check if graduation date is in the future
if (end > DateOnly.FromDateTime(DateTime.UtcNow))
{
return (true, "Graduation date is in the future - possibly currently studying");
}
return (true, null);
}
private static (bool isPlausible, string? notes) CheckEducationEmploymentTimeline(
DateOnly graduationDate,
List<EmploymentEntry> employment)
{
// Find the earliest employment start date
var earliestEmployment = employment
.Where(e => e.StartDate.HasValue)
.OrderBy(e => e.StartDate)
.FirstOrDefault();
if (earliestEmployment?.StartDate == null)
{
return (true, null);
}
var employmentStart = earliestEmployment.StartDate.Value;
// If someone claims to have started full-time work significantly before graduating,
// that's suspicious (unless it's clearly an internship/part-time role)
var monthsBeforeGraduation = (graduationDate.ToDateTime(TimeOnly.MinValue) -
employmentStart.ToDateTime(TimeOnly.MinValue)).TotalDays / 30;
if (monthsBeforeGraduation > 24) // More than 2 years before graduation
{
var isLikelyInternship = earliestEmployment.JobTitle.Contains("intern", StringComparison.OrdinalIgnoreCase) ||
earliestEmployment.JobTitle.Contains("placement", StringComparison.OrdinalIgnoreCase) ||
earliestEmployment.JobTitle.Contains("trainee", StringComparison.OrdinalIgnoreCase);
if (!isLikelyInternship)
{
return (false, $"Employment at {earliestEmployment.CompanyName} started {monthsBeforeGraduation:F0} months before claimed graduation");
}
}
return (true, null);
}
private static void CheckOverlappingEducation(List<EducationVerificationResult> results)
{
var datedResults = results
.Where(r => r.ClaimedStartDate.HasValue && r.ClaimedEndDate.HasValue)
.ToList();
for (var i = 0; i < datedResults.Count; i++)
{
for (var j = i + 1; j < datedResults.Count; j++)
{
var edu1 = datedResults[i];
var edu2 = datedResults[j];
if (PeriodsOverlap(
edu1.ClaimedStartDate!.Value, edu1.ClaimedEndDate!.Value,
edu2.ClaimedStartDate!.Value, edu2.ClaimedEndDate!.Value))
{
// Find the actual index in the original results list
var idx1 = results.IndexOf(edu1);
var idx2 = results.IndexOf(edu2);
if (idx1 >= 0)
{
results[idx1] = edu1 with
{
DatePlausibilityNotes = CombineNotes(
edu1.DatePlausibilityNotes,
$"Overlaps with education at {edu2.ClaimedInstitution}")
};
}
if (idx2 >= 0)
{
results[idx2] = edu2 with
{
DatePlausibilityNotes = CombineNotes(
edu2.DatePlausibilityNotes,
$"Overlaps with education at {edu1.ClaimedInstitution}")
};
}
}
}
}
}
private static bool PeriodsOverlap(DateOnly start1, DateOnly end1, DateOnly start2, DateOnly end2)
{
return start1 < end2 && start2 < end1;
}
private static string? CombineNotes(string? existing, string? additional)
{
if (string.IsNullOrEmpty(additional))
return existing;
if (string.IsNullOrEmpty(existing))
return additional;
return $"{existing}; {additional}";
}
}

View File

@@ -0,0 +1,133 @@
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Configuration;
namespace RealCV.Infrastructure.Services;
public sealed class FileStorageService : IFileStorageService
{
private readonly BlobContainerClient _containerClient;
private readonly ILogger<FileStorageService> _logger;
public FileStorageService(
IOptions<AzureBlobSettings> settings,
ILogger<FileStorageService> logger)
{
_logger = logger;
var blobServiceClient = new BlobServiceClient(settings.Value.ConnectionString);
_containerClient = blobServiceClient.GetBlobContainerClient(settings.Value.ContainerName);
}
public async Task<string> UploadAsync(Stream fileStream, string fileName)
{
ArgumentNullException.ThrowIfNull(fileStream);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
var extension = Path.GetExtension(fileName);
var uniqueBlobName = $"{Guid.NewGuid()}{extension}";
_logger.LogDebug("Uploading file {FileName} as blob {BlobName}", fileName, uniqueBlobName);
var blobClient = _containerClient.GetBlobClient(uniqueBlobName);
await _containerClient.CreateIfNotExistsAsync();
var httpHeaders = new BlobHttpHeaders
{
ContentType = GetContentType(extension)
};
await blobClient.UploadAsync(fileStream, new BlobUploadOptions
{
HttpHeaders = httpHeaders,
Metadata = new Dictionary<string, string>
{
["originalFileName"] = fileName,
["uploadedAt"] = DateTime.UtcNow.ToString("O")
}
});
var blobUrl = blobClient.Uri.ToString();
_logger.LogInformation("Successfully uploaded file {FileName} to {BlobUrl}", fileName, blobUrl);
return blobUrl;
}
public async Task<Stream> DownloadAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var blobName = ExtractBlobNameFromUrl(blobUrl);
_logger.LogDebug("Downloading blob {BlobName} from {BlobUrl}", blobName, blobUrl);
var blobClient = _containerClient.GetBlobClient(blobName);
// Download to memory stream to ensure proper resource management
// The caller will own and dispose this stream
var memoryStream = new MemoryStream();
await blobClient.DownloadToAsync(memoryStream);
memoryStream.Position = 0;
_logger.LogDebug("Successfully downloaded blob {BlobName}", blobName);
return memoryStream;
}
public async Task DeleteAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var blobName = ExtractBlobNameFromUrl(blobUrl);
_logger.LogDebug("Deleting blob {BlobName}", blobName);
var blobClient = _containerClient.GetBlobClient(blobName);
var deleted = await blobClient.DeleteIfExistsAsync();
if (deleted)
{
_logger.LogInformation("Successfully deleted blob {BlobName}", blobName);
}
else
{
_logger.LogWarning("Blob {BlobName} did not exist when attempting to delete", blobName);
}
}
private static string ExtractBlobNameFromUrl(string blobUrl)
{
if (!Uri.TryCreate(blobUrl, UriKind.Absolute, out var uri))
{
throw new ArgumentException($"Invalid blob URL format: '{blobUrl}'", nameof(blobUrl));
}
var segments = uri.Segments;
// The blob name is the last segment after the container name
// URL format: https://account.blob.core.windows.net/container/blobname
if (segments.Length <= 2)
{
throw new ArgumentException($"Blob URL does not contain a valid blob name: '{blobUrl}'", nameof(blobUrl));
}
return segments[^1];
}
private static string GetContentType(string extension)
{
return extension.ToLowerInvariant() switch
{
".pdf" => "application/pdf",
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".doc" => "application/msword",
_ => "application/octet-stream"
};
}
}

View File

@@ -0,0 +1,117 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RealCV.Application.Interfaces;
using RealCV.Infrastructure.Configuration;
namespace RealCV.Infrastructure.Services;
public sealed class LocalFileStorageService : IFileStorageService
{
private readonly string _storagePath;
private readonly ILogger<LocalFileStorageService> _logger;
public LocalFileStorageService(
IOptions<LocalStorageSettings> settings,
ILogger<LocalFileStorageService> logger)
{
_logger = logger;
_storagePath = settings.Value.StoragePath;
if (!Directory.Exists(_storagePath))
{
Directory.CreateDirectory(_storagePath);
_logger.LogInformation("Created local storage directory: {Path}", _storagePath);
}
}
public async Task<string> UploadAsync(Stream fileStream, string fileName)
{
ArgumentNullException.ThrowIfNull(fileStream);
ArgumentException.ThrowIfNullOrWhiteSpace(fileName);
var extension = Path.GetExtension(fileName);
var uniqueFileName = $"{Guid.NewGuid()}{extension}";
var filePath = Path.Combine(_storagePath, uniqueFileName);
_logger.LogDebug("Uploading file {FileName} to {FilePath}", fileName, filePath);
await using var fileStreamOut = new FileStream(filePath, FileMode.Create, FileAccess.Write);
await fileStream.CopyToAsync(fileStreamOut);
// Return a file:// URL for local storage
var fileUrl = $"file://{filePath}";
_logger.LogInformation("Successfully uploaded file {FileName} to {FileUrl}", fileName, fileUrl);
return fileUrl;
}
public async Task<Stream> DownloadAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var filePath = ExtractFilePathFromUrl(blobUrl);
_logger.LogDebug("Downloading file from {FilePath}", filePath);
if (!File.Exists(filePath))
{
throw new FileNotFoundException($"File not found: {filePath}");
}
var memoryStream = new MemoryStream();
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
await fileStream.CopyToAsync(memoryStream);
memoryStream.Position = 0;
_logger.LogDebug("Successfully downloaded file from {FilePath}", filePath);
return memoryStream;
}
public Task DeleteAsync(string blobUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(blobUrl);
var filePath = ExtractFilePathFromUrl(blobUrl);
_logger.LogDebug("Deleting file {FilePath}", filePath);
if (File.Exists(filePath))
{
File.Delete(filePath);
_logger.LogInformation("Successfully deleted file {FilePath}", filePath);
}
else
{
_logger.LogWarning("File {FilePath} did not exist when attempting to delete", filePath);
}
return Task.CompletedTask;
}
private string ExtractFilePathFromUrl(string fileUrl)
{
string filePath;
if (fileUrl.StartsWith("file://", StringComparison.OrdinalIgnoreCase))
{
filePath = fileUrl[7..];
}
else
{
filePath = fileUrl;
}
// Resolve to absolute path and validate it's within storage directory
var fullPath = Path.GetFullPath(filePath);
var storagePath = Path.GetFullPath(_storagePath);
if (!fullPath.StartsWith(storagePath, StringComparison.OrdinalIgnoreCase))
{
throw new UnauthorizedAccessException($"Access denied: path is outside storage directory");
}
return fullPath;
}
}

View File

@@ -0,0 +1,205 @@
using Microsoft.Extensions.Logging;
using RealCV.Application.Interfaces;
using RealCV.Application.Models;
namespace RealCV.Infrastructure.Services;
public sealed class TimelineAnalyserService : ITimelineAnalyserService
{
private readonly ILogger<TimelineAnalyserService> _logger;
private const int MinimumGapMonths = 3;
private const int AllowedOverlapMonths = 2;
public TimelineAnalyserService(ILogger<TimelineAnalyserService> logger)
{
_logger = logger;
}
public TimelineAnalysisResult Analyse(List<EmploymentEntry> employmentHistory)
{
ArgumentNullException.ThrowIfNull(employmentHistory);
if (employmentHistory.Count == 0)
{
_logger.LogDebug("No employment history to analyse");
return new TimelineAnalysisResult
{
TotalGapMonths = 0,
TotalOverlapMonths = 0,
Gaps = [],
Overlaps = []
};
}
// Filter entries with valid dates and sort by start date
var sortedEmployment = employmentHistory
.Where(e => e.StartDate.HasValue)
.OrderBy(e => e.StartDate!.Value)
.ToList();
if (sortedEmployment.Count == 0)
{
_logger.LogDebug("No employment entries with valid dates to analyse");
return new TimelineAnalysisResult
{
TotalGapMonths = 0,
TotalOverlapMonths = 0,
Gaps = [],
Overlaps = []
};
}
var gaps = DetectGaps(sortedEmployment);
var overlaps = DetectOverlaps(sortedEmployment);
var totalGapMonths = gaps.Sum(g => g.Months);
var totalOverlapMonths = overlaps.Sum(o => o.Months);
_logger.LogInformation(
"Timeline analysis complete: {GapCount} gaps ({TotalGapMonths} months), {OverlapCount} overlaps ({TotalOverlapMonths} months)",
gaps.Count, totalGapMonths, overlaps.Count, totalOverlapMonths);
return new TimelineAnalysisResult
{
TotalGapMonths = totalGapMonths,
TotalOverlapMonths = totalOverlapMonths,
Gaps = gaps,
Overlaps = overlaps
};
}
private List<TimelineGap> DetectGaps(List<EmploymentEntry> sortedEmployment)
{
var gaps = new List<TimelineGap>();
for (var i = 0; i < sortedEmployment.Count - 1; i++)
{
var current = sortedEmployment[i];
var next = sortedEmployment[i + 1];
// Get the effective end date for the current position
var currentEndDate = GetEffectiveEndDate(current);
var nextStartDate = next.StartDate!.Value;
// Skip if there's no gap or overlap
if (currentEndDate >= nextStartDate)
{
continue;
}
var gapMonths = CalculateMonthsDifference(currentEndDate, nextStartDate);
// Only report gaps of 3+ months
if (gapMonths >= MinimumGapMonths)
{
_logger.LogDebug(
"Detected {Months} month gap between {EndDate} and {StartDate}",
gapMonths, currentEndDate, nextStartDate);
gaps.Add(new TimelineGap
{
StartDate = currentEndDate,
EndDate = nextStartDate,
Months = gapMonths
});
}
}
return gaps;
}
private List<TimelineOverlap> DetectOverlaps(List<EmploymentEntry> sortedEmployment)
{
var overlaps = new List<TimelineOverlap>();
for (var i = 0; i < sortedEmployment.Count; i++)
{
for (var j = i + 1; j < sortedEmployment.Count; j++)
{
var earlier = sortedEmployment[i];
var later = sortedEmployment[j];
var overlap = CalculateOverlap(earlier, later);
if (overlap is not null && overlap.Value.Months > AllowedOverlapMonths)
{
_logger.LogDebug(
"Detected {Months} month overlap between {Company1} and {Company2}",
overlap.Value.Months, earlier.CompanyName, later.CompanyName);
overlaps.Add(new TimelineOverlap
{
Company1 = earlier.CompanyName,
Company2 = later.CompanyName,
OverlapStart = overlap.Value.Start,
OverlapEnd = overlap.Value.End,
Months = overlap.Value.Months
});
}
}
}
return overlaps;
}
private static (DateOnly Start, DateOnly End, int Months)? CalculateOverlap(
EmploymentEntry earlier,
EmploymentEntry later)
{
if (!earlier.StartDate.HasValue || !later.StartDate.HasValue)
{
return null;
}
var earlierEnd = GetEffectiveEndDate(earlier);
var laterStart = later.StartDate.Value;
// No overlap if earlier job ended before later job started
if (earlierEnd <= laterStart)
{
return null;
}
var laterEnd = GetEffectiveEndDate(later);
// The overlap period
var overlapStart = laterStart;
var overlapEnd = earlierEnd < laterEnd ? earlierEnd : laterEnd;
if (overlapStart >= overlapEnd)
{
return null;
}
var months = CalculateMonthsDifference(overlapStart, overlapEnd);
return (overlapStart, overlapEnd, months);
}
private static DateOnly GetEffectiveEndDate(EmploymentEntry entry)
{
if (entry.EndDate.HasValue)
{
return entry.EndDate.Value;
}
// If marked as current or no end date, use today
return DateOnly.FromDateTime(DateTime.UtcNow);
}
private static int CalculateMonthsDifference(DateOnly startDate, DateOnly endDate)
{
var yearDiff = endDate.Year - startDate.Year;
var monthDiff = endDate.Month - startDate.Month;
var totalMonths = (yearDiff * 12) + monthDiff;
// Add a month if we've passed the day in the month
if (endDate.Day >= startDate.Day)
{
totalMonths++;
}
return Math.Max(0, totalMonths);
}
}

View File

@@ -0,0 +1,28 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using RealCV.Application.Interfaces;
namespace RealCV.Infrastructure.Services;
public sealed class UserContextService : IUserContextService
{
private readonly AuthenticationStateProvider _authenticationStateProvider;
public UserContextService(AuthenticationStateProvider authenticationStateProvider)
{
_authenticationStateProvider = authenticationStateProvider;
}
public async Task<Guid?> GetCurrentUserIdAsync()
{
var authState = await _authenticationStateProvider.GetAuthenticationStateAsync();
var userIdClaim = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userIdClaim) || !Guid.TryParse(userIdClaim, out var userId))
{
return null;
}
return userId;
}
}