diff --git a/Apis/api-models/Requests/JobMatchRequest.cs b/Apis/api-models/Requests/JobMatchRequest.cs
index 35f9013..dbd5ae6 100644
--- a/Apis/api-models/Requests/JobMatchRequest.cs
+++ b/Apis/api-models/Requests/JobMatchRequest.cs
@@ -10,4 +10,6 @@ public sealed class JobMatchRequest
public string? CaptchaToken { get; set; }
/// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".
public string? Language { get; set; }
+ /// Client IP address — set by the api layer from the HTTP context before forwarding. Not supplied by the browser.
+ public string? ClientIpAddress { get; set; }
}
diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs
index 5669c25..d883c1f 100644
--- a/Apis/api/Controllers/CvMatcherController.cs
+++ b/Apis/api/Controllers/CvMatcherController.cs
@@ -163,6 +163,7 @@ public sealed class CvMatcherController : ControllerBase
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
}
+ request.ClientIpAddress = userIp;
_logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}",
request.CvDocumentId,
!string.IsNullOrWhiteSpace(request.JobUrl),
diff --git a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs
index d3366da..2a6abe2 100644
--- a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs
+++ b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs
@@ -9,5 +9,7 @@
public string? Email { get; set; }
/// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".
public string? Language { get; set; }
+ /// Client IP address forwarded by the api layer. Null when called from a background job.
+ public string? ClientIpAddress { get; set; }
}
}
diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs
index a35f8fe..65b7327 100644
--- a/Apis/cv-matcher-api/Services/CvMatcherService.cs
+++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs
@@ -77,7 +77,7 @@ public sealed class CvMatcherService : ICvMatcherService
{
var job = await _rag.GetDocumentAsync(result.DocumentId, ct);
if (job is null) continue;
- jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, NormalizeLanguage(null), ct));
+ jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, null, NormalizeLanguage(null), ct));
}
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
@@ -107,7 +107,7 @@ public sealed class CvMatcherService : ICvMatcherService
.FirstOrDefault(x => x.DocumentId == job.DocumentId)?
.MatchedChunks.Select(x => x.Text).ToArray() ?? [];
- return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), ct);
+ return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, request.ClientIpAddress, NormalizeLanguage(request.Language), ct);
}
///
@@ -115,7 +115,7 @@ public sealed class CvMatcherService : ICvMatcherService
/// Returns a cached result immediately when the same (CV, job, language) triple has been scored before.
/// When no evidence chunks are available from the vector search, falls back to the raw job text.
///
- private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, string language, CancellationToken ct)
+ private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, string? clientIpAddress, string language, CancellationToken ct)
{
var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct);
if (cached is not null) return cached;
@@ -145,7 +145,7 @@ public sealed class CvMatcherService : ICvMatcherService
result.JobDocumentId = job.Id;
result.JobUrl = job.SourceUrl;
result.Cached = false;
- await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct);
+ await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, email, clientIpAddress, ct);
return result;
}
diff --git a/Apis/cv-matcher-data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs
index d2cde82..941d0b7 100644
--- a/Apis/cv-matcher-data/CvMatcherDbContext.cs
+++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs
@@ -36,6 +36,8 @@ public sealed class CvMatcherDbContext : DbContext
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.ResultJson).IsRequired();
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
+ entity.Property(x => x.Email).HasMaxLength(256);
+ entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique();
});
diff --git a/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs b/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs
index ac0a9c5..4c32054 100644
--- a/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs
+++ b/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs
@@ -9,4 +9,6 @@ public sealed class CvMatchResultEntity : BaseEntity
public string Language { get; set; } = "en";
public string ResultJson { get; set; } = string.Empty;
public int Score { get; set; }
+ public string? Email { get; set; }
+ public string? ClientIpAddress { get; set; }
}
diff --git a/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs
new file mode 100644
index 0000000..f11d46b
--- /dev/null
+++ b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs
@@ -0,0 +1,138 @@
+//
+using System;
+using CvMatcher.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace CvMatcher.Data.Migrations
+{
+ [DbContext(typeof(CvMatcherDbContext))]
+ [Migration("20260608155310_AddEmailAndIpToResults")]
+ partial class AddEmailAndIpToResults
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("cvMatcher")
+ .HasAnnotation("ProductVersion", "10.0.7")
+ .HasAnnotation("Relational:MaxIdentifierLength", 128);
+
+ SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
+
+ modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(128)
+ .HasColumnType("nvarchar(128)");
+
+ b.Property("Language")
+ .HasMaxLength(8)
+ .HasColumnType("nvarchar(8)");
+
+ b.Property("Description")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(500)
+ .HasColumnType("nvarchar(500)")
+ .HasDefaultValue("");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.HasKey("Key", "Language");
+
+ b.ToTable("AiPrompts", "cvMatcher");
+ });
+
+ modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", 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")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
+ b.Property("JobDocumentId")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasColumnType("nvarchar(450)");
+
+ b.Property("ResultJson")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Score")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
+ .IsUnique();
+
+ b.ToTable("Results", "cvMatcher");
+ });
+
+ modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
+ {
+ b.Property("CacheKey")
+ .HasMaxLength(64)
+ .HasColumnType("nvarchar(64)");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("datetime2")
+ .HasDefaultValueSql("SYSUTCDATETIME()");
+
+ b.Property("Model")
+ .IsRequired()
+ .HasMaxLength(120)
+ .HasColumnType("nvarchar(120)");
+
+ b.Property("ResponseText")
+ .IsRequired()
+ .HasColumnType("nvarchar(max)");
+
+ b.Property("Temperature")
+ .HasColumnType("decimal(4,2)");
+
+ b.HasKey("CacheKey");
+
+ b.ToTable("ChatCache", "cvMatcher");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs
new file mode 100644
index 0000000..897bbda
--- /dev/null
+++ b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs
@@ -0,0 +1,45 @@
+using CvMatcher.Data;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace CvMatcher.Data.Migrations
+{
+ ///
+ public partial class AddEmailAndIpToResults : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "ClientIpAddress",
+ schema: MigrationConstants.SchemaName,
+ table: "Results",
+ type: "nvarchar(45)",
+ maxLength: 45,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "Email",
+ schema: MigrationConstants.SchemaName,
+ table: "Results",
+ type: "nvarchar(256)",
+ maxLength: 256,
+ nullable: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "ClientIpAddress",
+ schema: MigrationConstants.SchemaName,
+ table: "Results");
+
+ migrationBuilder.DropColumn(
+ name: "Email",
+ schema: MigrationConstants.SchemaName,
+ table: "Results");
+ }
+ }
+}
diff --git a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs
index 64a6262..6a8d37a 100644
--- a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs
+++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs
@@ -60,6 +60,10 @@ namespace CvMatcher.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
+ b.Property("ClientIpAddress")
+ .HasMaxLength(45)
+ .HasColumnType("nvarchar(45)");
+
b.Property("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
@@ -70,6 +74,10 @@ namespace CvMatcher.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
+ b.Property("Email")
+ .HasMaxLength(256)
+ .HasColumnType("nvarchar(256)");
+
b.Property("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
diff --git a/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs
index 6241862..069f93b 100644
--- a/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs
+++ b/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs
@@ -6,7 +6,7 @@ public interface IMatcherRepository
{
Task InitializeAsync(CancellationToken ct);
Task GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct);
- Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct);
+ Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct);
Task GetChatCompletionAsync(string cacheKey, CancellationToken ct);
Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct);
}
diff --git a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs
index 651dcea..33b8efa 100644
--- a/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs
+++ b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs
@@ -40,7 +40,7 @@ public sealed class EfMatcherRepository : IMatcherRepository
return result;
}
- public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct)
+ public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct)
{
var exists = await _db.CvMatchResults.AnyAsync(
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language,
@@ -58,6 +58,8 @@ public sealed class EfMatcherRepository : IMatcherRepository
Language = language,
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
Score = response.Score,
+ Email = email,
+ ClientIpAddress = clientIpAddress,
CreatedAt = DateTime.UtcNow
});