From 7c09f5a871f15c382ebf42b3900f4b3e868b74d2 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 29 May 2026 11:46:34 +0300 Subject: [PATCH] feat(cv-search-data): add JobProviders table to cvSearch schema New JobProviderEntity persists provider config (name, URL template, link filter, initial keywords, max results, display order) in the DB instead of appsettings. Migration seeds three disabled defaults: ejobs.ro, bestjobs.eu, and linkedin.com. Closes #35 Co-Authored-By: Claude Sonnet 4.6 --- Apis/cv-search-data/Data/CvSearchDbContext.cs | 14 ++ .../Data/Entities/JobProviderEntity.cs | 33 +++ ...20260529084440_AddJobProviders.Designer.cs | 222 ++++++++++++++++++ .../20260529084440_AddJobProviders.cs | 55 +++++ .../CvSearchDbContextModelSnapshot.cs | 50 +++- 5 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 Apis/cv-search-data/Data/Entities/JobProviderEntity.cs create mode 100644 Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index 00837ee..891243d 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -13,6 +13,7 @@ public sealed class CvSearchDbContext : DbContext public DbSet JobSearchTokens => Set(); public DbSet JobSearchSessions => Set(); public DbSet JobSearchResults => Set(); + public DbSet JobProviders => Set(); protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -65,5 +66,18 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.HasIndex(x => x.SessionId); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("JobProviders"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).UseIdentityColumn(); + entity.Property(x => x.Name).HasMaxLength(128).IsRequired(); + entity.Property(x => x.SearchUrlTemplate).HasMaxLength(1024).IsRequired(); + entity.Property(x => x.JobLinkContains).HasMaxLength(256).IsRequired(); + entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired(); + entity.Property(x => x.MaxResults).HasDefaultValue(20); + entity.Property(x => x.DisplayOrder).HasDefaultValue(0); + }); } } diff --git a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs new file mode 100644 index 0000000..79e76e2 --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs @@ -0,0 +1,33 @@ +namespace CvSearch.Data.Entities; + +/// +/// Persisted job-board provider configuration. Stored in cvSearch.JobProviders. +/// Providers are loaded from here at session-creation time and snapshotted into +/// JobSearchSessionEntity.ProviderConfigJson so runtime config changes do not +/// affect already-queued sessions. +/// +public sealed class JobProviderEntity +{ + public int Id { get; set; } + + /// Display name (e.g. "ejobs.ro"). + public string Name { get; set; } = string.Empty; + + /// When false the provider is skipped at session-creation and the job-search link is hidden. + public bool Enabled { get; set; } + + /// URL template with {keywords} placeholder (URL-encoded keywords are substituted at runtime). + public string SearchUrlTemplate { get; set; } = string.Empty; + + /// Substring that must appear in an anchor href to pass the stage-1 link filter. + public string JobLinkContains { get; set; } = string.Empty; + + /// JSON array of baseline keywords merged with CV keywords before building the search URL. + public string InitialKeywordsJson { get; set; } = "[]"; + + /// Maximum number of job URLs to collect from this provider per session. + public int MaxResults { get; set; } = 20; + + /// Controls display ordering in future admin UIs. + public int DisplayOrder { get; set; } +} diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs new file mode 100644 index 0000000..a079dc6 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs @@ -0,0 +1,222 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260529084440_AddJobProviders")] + partial class AddJobProviders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs new file mode 100644 index 0000000..40761cd --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddJobProviders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "JobProviders", + schema: "cvSearch", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + SearchUrlTemplate = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), + JobLinkContains = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + InitialKeywordsJson = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"), + MaxResults = table.Column(type: "int", nullable: false, defaultValue: 20), + DisplayOrder = table.Column(type: "int", nullable: false, defaultValue: 0) + }, + constraints: table => + { + table.PrimaryKey("PK_JobProviders", x => x.Id); + }); + + // Seed the three default providers — all disabled so the feature is opt-in per environment. + // Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI. + migrationBuilder.InsertData( + schema: "cvSearch", + table: "JobProviders", + columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"], + values: new object[,] + { + { "ejobs.ro", false, "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", "[]", 20, 0 }, + { "bestjobs.eu", false, "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}", "/ro/locuri-de-munca/", "[]", 20, 1 }, + { "linkedin.com", false, "https://www.linkedin.com/jobs/search/?keywords={keywords}", "/jobs/view/", "[]", 20, 2 }, + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobProviders", + schema: "cvSearch"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index 1cb9f20..2b7a10c 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using CvSearch.Data; using Microsoft.EntityFrameworkCore; @@ -23,6 +23,54 @@ namespace CvSearch.Data.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => { b.Property("Id")