From d56729de42e3034b858776d303b4380c6ace7aba Mon Sep 17 00:00:00 2001 From: claude Date: Mon, 8 Jun 2026 19:11:50 +0300 Subject: [PATCH] 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 };