From d56729de42e3034b858776d303b4380c6ace7aba Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 19:11:50 +0300 Subject: [PATCH 1/3] Add Email and ClientIpAddress audit fields to cvSearch.JobSearchSessions and JobSearchResults Captures client IP at job-search link-click time and threads it through to the session. Both Email and ClientIpAddress are copied from session to each result row during processing. Closes #47 Co-Authored-By: Claude Sonnet 4.6 --- .../Clients/Api/Contracts/IJobSearchApi.cs | 2 +- Apis/api/Controllers/CvMatcherController.cs | 3 +- .../Requests/StartJobSearchRequest.cs | 11 + .../Controllers/JobSearchController.cs | 4 +- .../Services/Contracts/IJobTokenService.cs | 3 +- .../Services/JobTokenService.cs | 3 +- Apis/cv-search-data/Data/CvSearchDbContext.cs | 3 + .../Data/Entities/JobSearchResultEntity.cs | 4 + .../Data/Entities/JobSearchSessionEntity.cs | 2 + ..._AddEmailIpToSessionAndResults.Designer.cs | 250 ++++++++++++++++++ ...608161102_AddEmailIpToSessionAndResults.cs | 58 ++++ .../CvSearchDbContextModelSnapshot.cs | 12 + Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 2 + 13 files changed, 351 insertions(+), 6 deletions(-) create mode 100644 Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs create mode 100644 Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs diff --git a/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs index 1a8ec60..05724bf 100644 --- a/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs +++ b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs @@ -10,5 +10,5 @@ public interface IJobSearchApi Task CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct); [Post("/api/cv/job-search/token/{tokenId}/start")] - Task StartSearchAsync(string tokenId, CancellationToken ct); + Task StartSearchAsync(string tokenId, [Body] StartJobSearchRequest request, CancellationToken ct); } diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index d883c1f..cd5970c 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -245,7 +245,8 @@ public sealed class CvMatcherController : ControllerBase { try { - var result = await _jobSearchApi.StartSearchAsync(t, ct); + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var result = await _jobSearchApi.StartSearchAsync(t, new StartJobSearchRequest { ClientIpAddress = userIp }, ct); var lang = "en"; var (title, message) = result.Status switch { diff --git a/Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs b/Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs new file mode 100644 index 0000000..aaf726b --- /dev/null +++ b/Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs @@ -0,0 +1,11 @@ +namespace CvMatcher.Models.Requests; + +/// +/// Request body sent by api when activating a one-time job-search link. +/// Carries the caller's IP address so it can be persisted on the session for auditing. +/// +public sealed class StartJobSearchRequest +{ + /// Client IP address forwarded by the api layer. Null when not available. + public string? ClientIpAddress { get; set; } +} diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index 2b058fa..b3a2318 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -81,11 +81,11 @@ public sealed class JobSearchController : ControllerBase [SwaggerResponse(StatusCodes.Status500InternalServerError, "Session creation failed", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] - public async Task> Start(string tokenId, CancellationToken ct) + public async Task> Start(string tokenId, [FromBody] StartJobSearchRequest? request, CancellationToken ct) { try { - var status = await _tokenService.TriggerStartAsync(tokenId, ct); + var status = await _tokenService.TriggerStartAsync(tokenId, request?.ClientIpAddress, ct); return Ok(new StartJobSearchResponse { Status = status }); } catch (Exception ex) diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 5a40aba..183a4ca 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -25,10 +25,11 @@ public interface IJobTokenService /// Validates the token and, if valid, marks it as used and creates a Pending job search session. /// /// The token ID from the one-click link. + /// Client IP address forwarded by the api layer. Null when not available. /// Cancellation token. /// /// One of the StartJobSearchStatus string constants: /// Started, AlreadyUsed, Expired, or NotFound. /// - Task TriggerStartAsync(string tokenId, CancellationToken ct); + Task TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index c77d298..3925d16 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -63,7 +63,7 @@ public sealed class JobTokenService : IJobTokenService } /// - public async Task TriggerStartAsync(string tokenId, CancellationToken ct) + public async Task TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct) { var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct); if (token is null) return StartJobSearchStatus.NotFound; @@ -94,6 +94,7 @@ public sealed class JobTokenService : IJobTokenService Status = JobSearchStatus.Pending, Keywords = keywords, Location = token.Location, + ClientIpAddress = clientIpAddress, ProviderConfigJson = providerConfigJson, CreatedAt = DateTime.UtcNow }; diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index 6bc1133..ee398f0 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -51,6 +51,7 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.Keywords).HasMaxLength(1000); entity.Property(x => x.ProviderConfigJson).IsRequired(false); entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.HasIndex(x => x.Status); }); @@ -64,6 +65,8 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.ProviderName).HasMaxLength(128); entity.Property(x => x.JobUrl).HasMaxLength(2048); entity.Property(x => x.JobTitle).HasMaxLength(512); + entity.Property(x => x.Email).HasMaxLength(256); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.HasIndex(x => x.SessionId); }); diff --git a/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs index f64f4ec..a1d0f67 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs @@ -11,4 +11,8 @@ public sealed class JobSearchResultEntity : BaseEntity public string JobText { get; set; } = string.Empty; public int Score { get; set; } public string ResultJson { get; set; } = string.Empty; + /// Email address of the user who triggered the search. Copied from the parent session. + public string? Email { get; set; } + /// Client IP address at link-click time. Copied from the parent session. + public string? ClientIpAddress { get; set; } } diff --git a/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs index 0a7db20..e0620c7 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs @@ -10,6 +10,8 @@ public sealed class JobSearchSessionEntity : BaseEntity public string Status { get; set; } = JobSearchStatus.Pending; public string Keywords { get; set; } = string.Empty; public string? Location { get; set; } + /// Client IP address captured when the user clicked the one-time job-search link. Null for sessions created before this field was added. + public string? ClientIpAddress { get; set; } public string? ProviderConfigJson { get; set; } public string Language { get; set; } = "en"; } diff --git a/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs new file mode 100644 index 0000000..b3c4c8e --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs @@ -0,0 +1,250 @@ +// +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("20260608161102_AddEmailIpToSessionAndResults")] + partial class AddEmailIpToSessionAndResults + { + /// + 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("RequireKeywordInAnchor") + .HasColumnType("bit"); + + 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("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + 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("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + 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("Location") + .HasColumnType("nvarchar(max)"); + + 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("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + 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/20260608161102_AddEmailIpToSessionAndResults.cs b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs new file mode 100644 index 0000000..add51d5 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs @@ -0,0 +1,58 @@ +using CvSearch.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddEmailIpToSessionAndResults : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchSessions", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + + migrationBuilder.AddColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults", + type: "nvarchar(256)", + maxLength: 256, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchSessions"); + + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults"); + + migrationBuilder.DropColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index fdc51e0..b0977ab 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -80,11 +80,19 @@ namespace CvSearch.Data.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("datetime2") .HasDefaultValueSql("SYSUTCDATETIME()"); + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + b.Property("JobText") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -129,6 +137,10 @@ namespace CvSearch.Data.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("datetime2") diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index 9c87383..a58899a 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -220,6 +220,8 @@ public sealed class CvSearchJobTask : IJobTask JobText = string.Empty, Score = matchResult.Score, ResultJson = JsonSerializer.Serialize(matchResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)), + Email = session.Email, + ClientIpAddress = session.ClientIpAddress, CreatedAt = DateTime.UtcNow }; -- 2.52.0 From 292d19d5edd23780d76e6ee541065d03b36e35f4 Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 19:13:10 +0300 Subject: [PATCH 2/3] Populate JobText from fetched page content in JobSearchResults Previously always stored empty string; now stores the full page text returned by page-fetcher-api, which is already in scope at save time. Co-Authored-By: Claude Sonnet 4.6 --- Jobs/cv-search-job/Tasks/CvSearchJobTask.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs index a58899a..2fc034e 100644 --- a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -217,7 +217,7 @@ public sealed class CvSearchJobTask : IJobTask ProviderName = GuessProvider(url, providers), JobUrl = url, JobTitle = matchResult.Summary.Split('.').FirstOrDefault()?.Trim() ?? title, - JobText = string.Empty, + JobText = jobText, Score = matchResult.Score, ResultJson = JsonSerializer.Serialize(matchResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)), Email = session.Email, -- 2.52.0 From 473c36d65f41f60ccce30fdad158d561787ec75d Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 19:20:02 +0300 Subject: [PATCH 3/3] Store match-time ClientIpAddress on cvSearch.JobSearchTokens Captures the IP when the user submits the CV match form and stores it on the token, giving a full audit trail: token holds the match-site IP, session holds the email link-click IP. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CvMatcherController.cs | 2 +- .../Requests/CreateJobSearchTokenRequest.cs | 2 + .../Controllers/JobSearchController.cs | 2 +- .../Services/Contracts/IJobTokenService.cs | 2 +- .../Services/JobTokenService.cs | 3 +- Apis/cv-search-data/Data/CvSearchDbContext.cs | 1 + .../Data/Entities/JobSearchTokenEntity.cs | 2 + ...0_AddClientIpToJobSearchTokens.Designer.cs | 254 ++++++++++++++++++ ...0608161930_AddClientIpToJobSearchTokens.cs | 32 +++ .../CvSearchDbContextModelSnapshot.cs | 4 + 10 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs create mode 100644 Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index cd5970c..5a5f60d 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -182,7 +182,7 @@ public sealed class CvMatcherController : ControllerBase try { var tokenResp = await _jobSearchApi.CreateTokenAsync( - new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location }, + new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location, ClientIpAddress = userIp }, ct); if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) { diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs index 6efe6e1..1cd9e2f 100644 --- a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -7,4 +7,6 @@ public sealed class CreateJobSearchTokenRequest public string Language { get; set; } = "en"; public List Keywords { get; set; } = []; public string? Location { get; set; } + /// Client IP address forwarded by the api layer at CV match time. Null when not available. + public string? ClientIpAddress { get; set; } } diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index b3a2318..11149a3 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); - var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, ct); + var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, request.ClientIpAddress, ct); return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); } catch (Exception ex) diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs index 183a4ca..29122df 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -19,7 +19,7 @@ public interface IJobTokenService /// The generated token ID to embed in the one-click job search link, /// or null when no job providers are currently enabled (link should be suppressed). /// - Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, CancellationToken ct); + Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, string? clientIpAddress, CancellationToken ct); /// /// Validates the token and, if valid, marks it as used and creates a Pending job search session. diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs index 3925d16..6c9f404 100644 --- a/Apis/cv-matcher-api/Services/JobTokenService.cs +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -34,7 +34,7 @@ public sealed class JobTokenService : IJobTokenService } /// - public async Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, CancellationToken ct) + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, string? clientIpAddress, CancellationToken ct) { var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct); if (!hasEnabledProviders) @@ -51,6 +51,7 @@ public sealed class JobTokenService : IJobTokenService Language = language, Keywords = string.Join(",", keywords), Location = location, + ClientIpAddress = clientIpAddress, ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), Used = false, CreatedAt = DateTime.UtcNow diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs index ee398f0..06171b2 100644 --- a/Apis/cv-search-data/Data/CvSearchDbContext.cs +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -36,6 +36,7 @@ public sealed class CvSearchDbContext : DbContext entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); entity.Property(x => x.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty); entity.Property(x => x.Used).HasDefaultValue(false); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); }); diff --git a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs index 6c581f2..0b90939 100644 --- a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs +++ b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs @@ -11,4 +11,6 @@ public sealed class JobSearchTokenEntity : BaseEntity public bool Used { get; set; } public string Keywords { get; set; } = string.Empty; public string? Location { get; set; } + /// Client IP address captured when the user submitted the CV match request. Null for tokens created before this field was added. + public string? ClientIpAddress { get; set; } } diff --git a/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs new file mode 100644 index 0000000..1290c19 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs @@ -0,0 +1,254 @@ +// +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("20260608161930_AddClientIpToJobSearchTokens")] + partial class AddClientIpToJobSearchTokens + { + /// + 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("RequireKeywordInAnchor") + .HasColumnType("bit"); + + 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("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + 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("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + 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("Location") + .HasColumnType("nvarchar(max)"); + + 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("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + 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("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + 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/20260608161930_AddClientIpToJobSearchTokens.cs b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs new file mode 100644 index 0000000..dbaed5c --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs @@ -0,0 +1,32 @@ +using CvSearch.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddClientIpToJobSearchTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs index b0977ab..baae9f9 100644 --- a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -197,6 +197,10 @@ namespace CvSearch.Data.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("datetime2") -- 2.52.0