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")