19 Commits

Author SHA1 Message Date
claude da1f90449e Merge observability/compact-json: standardize Serilog to Compact JSON (Application/Environment/AppVersion enrichment) 2026-06-18 10:59:13 +03:00
claude 2192c3f4c5 Logs: Compact JSON + aligned enrichment in shared StartupExtensions
CompactJsonFormatter in both ConfigureJsonSerilog overloads; rename Service->Application,
EnvironmentName->Environment (keep AppVersion). Applies to all myAi services.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 11:03:55 +03:00
claude 492859f17f ci: prune images + build cache after build (prevent runner disk exhaustion)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:51:31 +03:00
claude a3567ce8e9 Merge pull request 'Fix CV keyword extraction — derive from candidate CV, not matched job' (#55) from feature/fix-keyword-extraction-prompt into main
Build and Push Docker Images Staging / build (push) Successful in 2m23s
Merge PR #55: Fix CV keyword extraction — derive from candidate CV, not matched job
2026-06-09 13:40:10 +00:00
claude b52ef8ddff Fix CV keyword extraction to reflect candidate identity, not matched job
The AI prompt now instructs the LLM to derive keywords entirely from the
candidate's CV (seniority level, primary role title, core technologies they
emphasize) rather than from the job description being matched. This ensures
the job-board search keywords used by cv-search-job represent who the
candidate actually is, not a mirror of the job they happened to match against.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 16:38:02 +03:00
claude d2b12e39ec Merge PR #54: Fix hardcoded user-facing strings (Issue #53)
Build and Push Docker Images Staging / build (push) Successful in 46s
2026-06-08 22:34:28 +03:00
claude 1e8758796e Fix hardcoded user-facing strings — localize email fallbacks, API errors, AI parse messages
- Frontend: update extractApiError to check body.code first via i18n 'error.<code>' keys;
  add en/ro translations for cv_file_missing, captcha_verification_failed, request_cancelled
- email-data migration: seed 6 fallback template keys (match N/A, subject label, unknown IP,
  job search results empty states for keywords/providers/location)
- EmailApiEmailSender: replace "N/A", "Job", "Unknown" literals with template lookups
- CvSearchEmailSender: replace "none detected", "none", "-" literals with template lookups
- cv-matcher-data migration: seed parse-error.summary and parse-error.recommendation in AiPrompts
- CvMatcherService: look up localized parse-error messages from AiPrompts before calling ParseResult

Closes #53

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:32:03 +03:00
claude ef2793448a Fix: declare language before jobLabel in CvMatcherController
Build and Push Docker Images Staging / build (push) Successful in 8m20s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:10:22 +03:00
claude cbf06031e8 Merge pull request 'Fix language consistency in job search and match emails' (#52) from feature/language-consistency into main
Build and Push Docker Images Staging / build (push) Failing after 32s
Merge PR #52: Fix language consistency in job search and match emails
2026-06-08 19:08:28 +00:00
claude 90f540139a Fix language consistency in job search and match emails
1. Pass session.Language to MatchJobRequest in cv-search-job so the LLM
   uses the correct language-specific prompt for job titles and summaries.

2. Replace hardcoded "Manual job description" with a template-driven label
   (email.match.manual-job-label) seeded in English and Romanian, so the
   match email subject and Job row reflect the user's language.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 22:07:15 +03:00
claude 71d5ac8e06 Remove environment name prefix from email subjects
Build and Push Docker Images Staging / build (push) Successful in 1m26s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:47:21 +03:00
claude c2082d6729 Suppress environment prefix in email subjects on Production
[ENV_NAME] prefix is now only prepended in non-production environments
(Development, Staging, etc.). Production emails get a clean subject line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 21:45:46 +03:00
claude 6f1d8992ab Merge pull request 'Link pageFetcher.PageFetches to cvSearch.JobSearchSessions' (#50) from feature/page-fetch-session-link into main
Build and Push Docker Images Staging / build (push) Successful in 17m39s
Merge PR #50: Link pageFetcher.PageFetches to cvSearch.JobSearchSessions
2026-06-08 16:57:07 +00:00
claude 2d9ffc9c2b Link pageFetcher.PageFetches to cvSearch.JobSearchSessions
Adds nullable JobSearchSessionId to PageFetchEntity and FetchPageRequest.
cv-search-job passes session.Id on every fetch so all Playwright page
loads for a job search session can be traced back to their session.
Includes index on JobSearchSessionId for efficient lookup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 19:56:13 +03:00
claude 9fbad722fc Merge pull request 'Add Email and ClientIpAddress audit fields to cvSearch.JobSearchSessions and JobSearchResults' (#48) from feature/job-search-audit-fields into main
Build and Push Docker Images Staging / build (push) Successful in 39s
Merge PR #48: Add Email and ClientIpAddress audit fields to cvSearch
2026-06-08 16:21:46 +00:00
claude 473c36d65f 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 <noreply@anthropic.com>
2026-06-08 19:20:02 +03:00
claude 292d19d5ed 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 <noreply@anthropic.com>
2026-06-08 19:13:10 +03:00
claude d56729de42 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 <noreply@anthropic.com>
2026-06-08 19:11:50 +03:00
claude 79a3dec679 Merge pull request 'Add Email and ClientIpAddress audit fields to cvMatcher.Results' (#46) from feature/result-email-and-ip into main
Build and Push Docker Images Staging / build (push) Successful in 16m43s
Merge PR #46: Add Email and ClientIpAddress audit fields to cvMatcher.Results
2026-06-08 15:58:18 +00:00
43 changed files with 1655 additions and 41 deletions
+7 -1
View File
@@ -97,4 +97,10 @@ jobs:
- name: Push Page Fetcher API image
run: |
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
- name: Reclaim disk space
if: always()
run: |
docker image prune -af
docker builder prune -af
@@ -10,5 +10,5 @@ public interface IJobSearchApi
Task<CreateJobSearchTokenResponse> CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct);
[Post("/api/cv/job-search/token/{tokenId}/start")]
Task<StartJobSearchResponse> StartSearchAsync(string tokenId, CancellationToken ct);
Task<StartJobSearchResponse> StartSearchAsync(string tokenId, [Body] StartJobSearchRequest request, CancellationToken ct);
}
+5 -5
View File
@@ -170,11 +170,10 @@ public sealed class CvMatcherController : ControllerBase
!string.IsNullOrWhiteSpace(request.JobDescription));
var res = await _cvApi.MatchJob(request, ct);
var attachmentPath = TryGetCachedCvPath(request.CvDocumentId);
var language = NormalizeLanguage(request.Language);
var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl)
? request.JobUrl
: "Manual job description";
var language = NormalizeLanguage(request.Language);
: _emailSender.GetManualJobLabel(language);
string? jobSearchLink = null;
if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId))
@@ -182,7 +181,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))
{
@@ -245,7 +244,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
{
@@ -61,5 +61,11 @@ namespace Api.Services.Contracts
/// <param name="expiryDays">Number of days until the job-search link expires (shown in the footer copy).</param>
/// <returns>Rendered HTML body string.</returns>
string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7);
/// <summary>
/// Returns the localised label for a manually-entered job description (no URL provided).
/// </summary>
/// <param name="language">Two-letter language code.</param>
string GetManualJobLabel(string language);
}
}
+7 -4
View File
@@ -138,7 +138,7 @@ public sealed class EmailApiEmailSender : IEmailSender
</tr>
<tr style="background:#f8f9fa">
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td>
<td style="border:1px solid #dee2e6">{userIp ?? "Unknown"}</td>
<td style="border:1px solid #dee2e6">{userIp ?? _emailTemplates.Get("email.notification.unknown-ip", "en")}</td>
</tr>
</table>
""";
@@ -215,8 +215,8 @@ public sealed class EmailApiEmailSender : IEmailSender
// email.match.body is now stored as HTML in the database
var body = _emailTemplates.Render("email.match.body", language,
("cvDocumentId", cvDocumentId),
("jobLabel", jobLabel ?? "N/A"),
("jobUrl", result.JobUrl ?? "N/A"),
("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.fallback-na", language)),
("jobUrl", result.JobUrl ?? _emailTemplates.Get("email.match.fallback-na", language)),
("score", result.Score.ToString()),
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
("strengths", strengths),
@@ -238,5 +238,8 @@ public sealed class EmailApiEmailSender : IEmailSender
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_emailTemplates.Render("email.match.subject", language,
("score", score.ToString()),
("jobLabel", jobLabel ?? "Job"));
("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.subject-fallback-label", language)));
public string GetManualJobLabel(string language) =>
_emailTemplates.Get("email.match.manual-job-label", language);
}
@@ -7,4 +7,6 @@ public sealed class CreateJobSearchTokenRequest
public string Language { get; set; } = "en";
public List<string> Keywords { get; set; } = [];
public string? Location { get; set; }
/// <summary>Client IP address forwarded by the api layer at CV match time. Null when not available.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -0,0 +1,11 @@
namespace CvMatcher.Models.Requests;
/// <summary>
/// Request body sent by <c>api</c> when activating a one-time job-search link.
/// Carries the caller's IP address so it can be persisted on the session for auditing.
/// </summary>
public sealed class StartJobSearchRequest
{
/// <summary>Client IP address forwarded by the api layer. Null when not available.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -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)
@@ -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<ActionResult<StartJobSearchResponse>> Start(string tokenId, CancellationToken ct)
public async Task<ActionResult<StartJobSearchResponse>> 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)
@@ -19,16 +19,17 @@ public interface IJobTokenService
/// The generated token ID to embed in the one-click job search link,
/// or <c>null</c> when no job providers are currently enabled (link should be suppressed).
/// </returns>
Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, CancellationToken ct);
Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, string? clientIpAddress, CancellationToken ct);
/// <summary>
/// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session.
/// </summary>
/// <param name="tokenId">The token ID from the one-click link.</param>
/// <param name="clientIpAddress">Client IP address forwarded by the api layer. Null when not available.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// One of the <c>StartJobSearchStatus</c> string constants:
/// <c>Started</c>, <c>AlreadyUsed</c>, <c>Expired</c>, or <c>NotFound</c>.
/// </returns>
Task<string> TriggerStartAsync(string tokenId, CancellationToken ct);
Task<string> TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct);
}
@@ -141,7 +141,9 @@ public sealed class CvMatcherService : ICvMatcherService
""";
var json = await _ai.CreateChatCompletionAsync(systemPrompt, userPrompt, 0.2m, ct);
var result = ParseResult(json);
var errorSummary = await _aiPrompts.GetAsync("parse-error.summary", language, ct);
var errorRec = await _aiPrompts.GetAsync("parse-error.recommendation", language, ct);
var result = ParseResult(json, errorSummary, errorRec);
result.JobDocumentId = job.Id;
result.JobUrl = job.SourceUrl;
result.Cached = false;
@@ -153,7 +155,10 @@ public sealed class CvMatcherService : ICvMatcherService
/// Deserialises the LLM's JSON output into a <see cref="JobMatchResponse"/>.
/// Returns a safe fallback response instead of throwing when the JSON cannot be parsed.
/// </summary>
private static JobMatchResponse ParseResult(string json)
private static JobMatchResponse ParseResult(
string json,
string? errorSummary = null,
string? errorRec = null)
{
try
{
@@ -168,8 +173,8 @@ public sealed class CvMatcherService : ICvMatcherService
return new JobMatchResponse
{
Score = 0,
Summary = "The AI response could not be parsed as structured JSON.",
Recommendations = ["Inspect the raw model output and tune the scoring prompt."]
Summary = errorSummary ?? "The AI response could not be parsed as structured JSON.",
Recommendations = [errorRec ?? "Inspect the raw model output and tune the scoring prompt."]
};
}
@@ -34,7 +34,7 @@ public sealed class JobTokenService : IJobTokenService
}
/// <inheritdoc />
public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, CancellationToken ct)
public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> 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
@@ -63,7 +64,7 @@ public sealed class JobTokenService : IJobTokenService
}
/// <inheritdoc />
public async Task<string> TriggerStartAsync(string tokenId, CancellationToken ct)
public async Task<string> 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 +95,7 @@ public sealed class JobTokenService : IJobTokenService
Status = JobSearchStatus.Pending,
Keywords = keywords,
Location = token.Location,
ClientIpAddress = clientIpAddress,
ProviderConfigJson = providerConfigJson,
CreatedAt = DateTime.UtcNow
};
@@ -0,0 +1,138 @@
// <auto-generated />
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("20260608193046_AddParseErrorPrompts")]
partial class AddParseErrorPrompts
{
/// <inheritdoc />
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<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("AiPrompts", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("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<string>("CacheKey")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("ResponseText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Temperature")
.HasColumnType("decimal(4,2)");
b.HasKey("CacheKey");
b.ToTable("ChatCache", "cvMatcher");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,67 @@
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class AddParseErrorPrompts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
columns: ["Key", "Language", "Value", "Description"],
values: ["parse-error.summary", "en", "The AI response could not be parsed. Please try again.", "Summary shown in match email when the AI returns an unparseable response"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
columns: ["Key", "Language", "Value", "Description"],
values: ["parse-error.summary", "ro", "Răspunsul AI nu a putut fi interpretat. Vă rugăm să încercați din nou.", "Sumar afișat în emailul de potrivire când AI returnează un răspuns care nu poate fi interpretat"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
columns: ["Key", "Language", "Value", "Description"],
values: ["parse-error.recommendation", "en", "If the problem persists, try a different job link or description.", "Recommendation shown in match email when the AI returns an unparseable response"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
columns: ["Key", "Language", "Value", "Description"],
values: ["parse-error.recommendation", "ro", "Dacă problema persistă, încercați un alt link sau descriere de job.", "Recomandare afișată în emailul de potrivire când AI returnează un răspuns care nu poate fi interpretat"]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["parse-error.summary", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["parse-error.summary", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["parse-error.recommendation", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["parse-error.recommendation", "ro"]);
}
}
}
@@ -0,0 +1,138 @@
// <auto-generated />
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("20260609133623_FixKeywordExtractionPrompt")]
partial class FixKeywordExtractionPrompt
{
/// <inheritdoc />
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<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("AiPrompts", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("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<string>("CacheKey")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("ResponseText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Temperature")
.HasColumnType("decimal(4,2)");
b.HasKey("CacheKey");
b.ToTable("ChatCache", "cvMatcher");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,93 @@
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class FixKeywordExtractionPrompt : Migration
{
// Full prompt values — only the 'keywords' instruction changes vs. the previous migration.
// Stored in full so Down() can restore the previous version exactly.
private const string EnNew =
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\n" +
"JSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\n" +
"For 'keywords': extract 2-4 job-board search terms that represent the candidate's professional identity as shown in their CV — their seniority level and primary role title (e.g. 'Software Architect', 'Engineering Manager', 'Senior .NET Developer') plus 1-2 core technologies they genuinely emphasize throughout the CV. Derive these entirely from the CV — do not use the job title or job technologies unless they independently match the candidate's actual positioning. Avoid generic terms like 'developer', 'engineer', 'cloud', or 'leadership'.\n" +
"For 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.";
private const string EnPrev =
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\n" +
"JSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\n" +
"For 'keywords': extract 2-4 short, concrete terms a recruiter would search for on a job board — the candidate's primary role title and key technologies (e.g. 'Senior .NET Developer', 'C#', 'Azure'). Avoid abstract concepts like 'leadership', 'cloud', or 'microservices'.\n" +
"For 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.";
private const string RoNew =
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\n" +
"JSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\n" +
"Pentru 'keywords': extrage 2-4 termeni de căutare pe site-uri de joburi care reprezintă identitatea profesională a candidatului conform CV-ului — nivelul de senioritate și titlul principal de rol (ex. 'Software Architect', 'Engineering Manager', 'Senior .NET Developer') și 1-2 tehnologii de bază pe care candidatul le evidențiază cu adevărat în CV. Derivă aceștia exclusiv din CV — nu folosi titlul jobului sau tehnologiile din job dacă nu corespund poziționării reale a candidatului. Evită termeni generici precum 'developer', 'engineer', 'cloud' sau 'leadership'.\n" +
"Pentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.";
private const string RoPrev =
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\n" +
"JSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\n" +
"Pentru 'keywords': extrage 2-4 termeni scurți și concreți pe care un recrutor i-ar căuta pe un site de joburi — titlul principal al rolului și tehnologiile cheie (ex. 'Senior .NET Developer', 'C#', 'Azure'). Evită concepte abstracte precum 'leadership', 'cloud' sau 'microservicii'.\n" +
"Pentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.";
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Update English prompt: keywords must now be derived from the CV only,
// not influenced by the job description being matched against.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
EnNew,
"System prompt for CV-to-job matching in English. Keywords represent the candidate's CV identity (seniority + role + core tech), not the job being matched."
]);
// Update Romanian prompt: same improvement.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
RoNew,
"System prompt pentru potrivire CV-job în română. Cuvintele cheie reprezintă identitatea CV-ului candidatului (senioritate + rol + tehnologii cheie), nu jobul cu care se face potrivirea."
]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
EnPrev,
"System prompt for CV-to-job matching in English. Extracts job-board-friendly keywords (role title + key tech) and candidate location."
]);
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
RoPrev,
"System prompt pentru potrivire CV-job în limba română. Extrage cuvinte cheie prietenoase pentru site-uri de joburi (titlu rol + tehnologii cheie) și locația candidatului."
]);
}
}
}
@@ -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()");
});
@@ -51,6 +52,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 +66,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);
});
@@ -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;
/// <summary>Email address of the user who triggered the search. Copied from the parent session.</summary>
public string? Email { get; set; }
/// <summary>Client IP address at link-click time. Copied from the parent session.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -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; }
/// <summary>Client IP address captured when the user clicked the one-time job-search link. Null for sessions created before this field was added.</summary>
public string? ClientIpAddress { get; set; }
public string? ProviderConfigJson { get; set; }
public string Language { get; set; } = "en";
}
@@ -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; }
/// <summary>Client IP address captured when the user submitted the CV match request. Null for tokens created before this field was added.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -0,0 +1,250 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,58 @@
using CvSearch.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddEmailIpToSessionAndResults : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions",
type: "nvarchar(45)",
maxLength: 45,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchResults",
type: "nvarchar(45)",
maxLength: 45,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Email",
schema: MigrationConstants.SchemaName,
table: "JobSearchResults",
type: "nvarchar(256)",
maxLength: 256,
nullable: true);
}
/// <inheritdoc />
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");
}
}
}
@@ -0,0 +1,254 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("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<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,32 @@
using CvSearch.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddClientIpToJobSearchTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens",
type: "nvarchar(45)",
maxLength: 45,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens");
}
}
}
@@ -80,11 +80,19 @@ namespace CvSearch.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -129,6 +137,10 @@ namespace CvSearch.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
@@ -185,6 +197,10 @@ namespace CvSearch.Data.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
@@ -58,7 +58,7 @@ public sealed class SmtpEmailDispatcher
if (!string.IsNullOrWhiteSpace(req.ReplyTo))
msg.ReplyTo.Add(MailboxAddress.Parse(req.ReplyTo));
msg.Subject = $"[{_environmentName}] {req.Subject}".Trim();
msg.Subject = req.Subject.Trim();
var shellStart = _templates.Get("email.html-shell.start", "*");
var shellEnd = _templates.Get("email.html-shell.end", "*");
@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using Email.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Email.Data.Migrations
{
[DbContext(typeof(EmailDbContext))]
[Migration("20260608190611_AddManualJobLabelTemplate")]
partial class AddManualJobLabelTemplate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("email")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("OperatorCopy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("Templates", "email");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,43 @@
using Email.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Email.Data.Migrations
{
/// <inheritdoc />
public partial class AddManualJobLabelTemplate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.manual-job-label", "en", "Manual job description", "Label used in the match email subject and body when no job URL was provided"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.manual-job-label", "ro", "Descriere manuală a jobului", "Etichetă folosită în subiectul și corpul emailului când nu a fost furnizat un URL de job"]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.manual-job-label", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.manual-job-label", "ro"]);
}
}
}
@@ -0,0 +1,69 @@
// <auto-generated />
using System;
using Email.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Email.Data.Migrations
{
[DbContext(typeof(EmailDbContext))]
[Migration("20260608192938_AddFallbackStringTemplates")]
partial class AddFallbackStringTemplates
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("email")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<string>("OperatorCopy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("Templates", "email");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,163 @@
using Email.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Email.Data.Migrations
{
/// <inheritdoc />
public partial class AddFallbackStringTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.fallback-na", "en", "N/A", "Fallback when a match email field (job label or URL) has no value"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.fallback-na", "ro", "N/A", "Fallback când un câmp al emailului de potrivire (etichetă job sau URL) nu are valoare"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.subject-fallback-label", "en", "Job", "Fallback job label used in match email subject when no specific label is available"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.match.subject-fallback-label", "ro", "Job", "Etichetă fallback pentru subiectul emailului de potrivire când nu există o etichetă specifică"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.notification.unknown-ip", "en", "Unknown", "Fallback IP address label in operator notification emails"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.notification.unknown-ip", "ro", "Necunoscut", "Etichetă fallback pentru adresa IP în emailurile de notificare operator"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.keywords-empty", "en", "none detected", "Text shown in job search results email when no CV keywords were extracted"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.keywords-empty", "ro", "niciunul detectat", "Text afișat în emailul cu rezultatele căutării când nu au fost extrase cuvinte cheie din CV"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.providers-empty", "en", "none", "Text shown in job search results email when no providers were searched"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.providers-empty", "ro", "niciunul", "Text afișat în emailul cu rezultatele căutării când nu au fost căutați furnizori"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.location-empty", "en", "-", "Fallback location display in job search results email scan summary"]);
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "Templates",
columns: ["Key", "Language", "Value", "Description"],
values: ["email.search-results.location-empty", "ro", "-", "Afișaj fallback pentru locație în sumarului de scanare al emailului cu rezultatele căutării"]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.fallback-na", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.fallback-na", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.subject-fallback-label", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.match.subject-fallback-label", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.notification.unknown-ip", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.notification.unknown-ip", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.keywords-empty", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.keywords-empty", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.providers-empty", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.providers-empty", "ro"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.location-empty", "en"]);
migrationBuilder.DeleteData(
schema: MigrationConstants.SchemaName,
table: "Templates",
keyColumns: ["Key", "Language"],
keyValues: ["email.search-results.location-empty", "ro"]);
}
}
}
@@ -17,4 +17,10 @@ public sealed class FetchPageRequest
/// Identifies the calling service for audit purposes (e.g. <c>cv-matcher-api</c>, <c>cv-search-job</c>).
/// </summary>
public string CallerService { get; set; } = string.Empty;
/// <summary>
/// Optional reference to the job search session that triggered this fetch.
/// Stored on <c>pageFetcher.PageFetches</c> for cross-schema audit queries.
/// </summary>
public string? JobSearchSessionId { get; set; }
}
@@ -101,6 +101,7 @@ public sealed class PageFetcherService
Id = Guid.NewGuid().ToString("N"),
Url = request.Url,
CallerService = request.CallerService ?? string.Empty,
JobSearchSessionId = request.JobSearchSessionId,
HttpStatusCode = statusCode,
Html = html,
Text = text,
@@ -31,4 +31,10 @@ public sealed class PageFetchEntity : BaseEntity
/// <summary>Exception message when <see cref="Success"/> is <c>false</c>.</summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Optional reference to the <c>cvSearch.JobSearchSessions</c> row that triggered this fetch.
/// Null for fetches not originating from a job search session (e.g. direct CV-to-job matches).
/// </summary>
public string? JobSearchSessionId { get; set; }
}
@@ -0,0 +1,88 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using PageFetcher.Data;
#nullable disable
namespace PageFetcher.Data.Migrations
{
[DbContext(typeof(PageFetchDbContext))]
[Migration("20260608165542_AddJobSearchSessionId")]
partial class AddJobSearchSessionId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("pageFetcher")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("PageFetcher.Data.Entities.PageFetchEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("CallerService")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<long>("DurationMs")
.HasColumnType("bigint");
b.Property<string>("ErrorMessage")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<string>("Html")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("HttpStatusCode")
.HasColumnType("int");
b.Property<string>("JobSearchSessionId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Success")
.HasColumnType("bit");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("JobSearchSessionId");
b.HasIndex("Url");
b.ToTable("PageFetches", "pageFetcher");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,43 @@
using PageFetcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PageFetcher.Data.Migrations
{
/// <inheritdoc />
public partial class AddJobSearchSessionId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "JobSearchSessionId",
schema: MigrationConstants.SchemaName,
table: "PageFetches",
type: "nvarchar(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_PageFetches_JobSearchSessionId",
schema: MigrationConstants.SchemaName,
table: "PageFetches",
column: "JobSearchSessionId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_PageFetches_JobSearchSessionId",
schema: MigrationConstants.SchemaName,
table: "PageFetches");
migrationBuilder.DropColumn(
name: "JobSearchSessionId",
schema: MigrationConstants.SchemaName,
table: "PageFetches");
}
}
}
@@ -53,6 +53,10 @@ namespace PageFetcher.Data.Migrations
b.Property<int?>("HttpStatusCode")
.HasColumnType("int");
b.Property<string>("JobSearchSessionId")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("Success")
.HasColumnType("bit");
@@ -69,6 +73,8 @@ namespace PageFetcher.Data.Migrations
b.HasIndex("CreatedAt");
b.HasIndex("JobSearchSessionId");
b.HasIndex("Url");
b.ToTable("PageFetches", "pageFetcher");
@@ -36,8 +36,11 @@ public sealed class PageFetchDbContext : DbContext
entity.Property(x => x.Html).IsRequired();
entity.Property(x => x.Text).IsRequired();
entity.Property(x => x.ErrorMessage).HasMaxLength(2000);
entity.Property(x => x.JobSearchSessionId).HasMaxLength(64);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.JobSearchSessionId);
entity.HasIndex(x => x.Url);
entity.HasIndex(x => x.CreatedAt);
});
+1
View File
@@ -23,6 +23,7 @@
<PackageVersion Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageVersion Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageVersion Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<!-- Swagger -->
+6 -8
View File
@@ -39,11 +39,10 @@ public static class StartupExtensions
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Service", serviceName)
.Enrich.WithProperty("Application", serviceName)
.Enrich.WithProperty("Environment", context.HostingEnvironment.EnvironmentName)
.Enrich.WithProperty("AppVersion", appVersion)
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
.WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter());
AddEmailSinkIfConfigured(configuration, context.Configuration, serviceName);
});
@@ -57,11 +56,10 @@ public static class StartupExtensions
.ReadFrom.Configuration(builder.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProperty("Service", serviceName)
.Enrich.WithProperty("Application", serviceName)
.Enrich.WithProperty("Environment", builder.Environment.EnvironmentName)
.Enrich.WithProperty("AppVersion", appVersion)
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
.WriteTo.Console(new Serilog.Formatting.Compact.CompactJsonFormatter());
AddEmailSinkIfConfigured(configuration, builder.Configuration, serviceName);
});
@@ -17,6 +17,7 @@
<PackageReference Include="DotNetEnv" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" />
<PackageReference Include="Serilog.Formatting.Compact" />
<PackageReference Include="Serilog.Sinks.Email" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Swashbuckle.AspNetCore" />
@@ -127,13 +127,15 @@ public sealed class CvSearchEmailSender
var keywordsHtml = keywords.Count > 0
? string.Join(" ", keywords.Select(k =>
$"<span style=\"display:inline-block;background:#e9ecef;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px;\">{k}</span>"))
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic;\">none detected</span>";
: $"<span style=\"color:#6c757d;font-size:12px;font-style:italic;\">{_emailTemplates.Get("email.search-results.keywords-empty", language)}</span>";
var providers = providerNames.Count > 0
? string.Join(", ", providerNames)
: "none";
: _emailTemplates.Get("email.search-results.providers-empty", language);
var locationDisplay = string.IsNullOrWhiteSpace(location) ? "-" : location;
var locationDisplay = string.IsNullOrWhiteSpace(location)
? _emailTemplates.Get("email.search-results.location-empty", language)
: location;
return _emailTemplates.Render("email.search-results.scan-summary", language,
("keywordsHtml", keywordsHtml),
+8 -3
View File
@@ -169,7 +169,8 @@ public sealed class CvSearchJobTask : IJobTask
{
Url = url,
WaitFor = "domcontentloaded",
CallerService = "cv-search-job"
CallerService = "cv-search-job",
JobSearchSessionId = session.Id
}, ct);
if (!fetchResponse.Success || string.IsNullOrWhiteSpace(fetchResponse.Text))
@@ -197,7 +198,9 @@ public sealed class CvSearchJobTask : IJobTask
// Pre-fetched text passed directly so cv-matcher-api skips re-fetching the page
JobDescription = jobText,
// User already gave GDPR consent when they clicked the one-time job search link
GdprConsent = true
GdprConsent = true,
// Propagate language so the LLM uses the correct language-specific prompt
Language = session.Language
};
var matchResult = await _matcherApi.MatchJobAsync(matchRequest, ct);
@@ -217,9 +220,11 @@ 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,
ClientIpAddress = session.ClientIpAddress,
CreatedAt = DateTime.UtcNow
};
+8 -2
View File
@@ -115,7 +115,10 @@
"cv.noItems": "No items yet.",
"cv.strengths": "Strengths",
"cv.gaps": "Gaps",
"cv.evidence": "Supporting CV excerpts"
"cv.evidence": "Supporting CV excerpts",
"error.cv_file_missing": "Missing CV PDF.",
"error.captcha_verification_failed": "Captcha verification failed.",
"error.request_cancelled": "Request was cancelled."
},
ro: {
"brand.subtitle": "prezentare inginerie AI",
@@ -224,7 +227,10 @@
"cv.noItems": "Niciun element.",
"cv.strengths": "Puncte forte",
"cv.gaps": "Lipsuri",
"cv.evidence": "Fragmente relevante din CV"
"cv.evidence": "Fragmente relevante din CV",
"error.cv_file_missing": "Fișierul CV PDF lipsește.",
"error.captcha_verification_failed": "Verificarea captcha a eșuat.",
"error.request_cancelled": "Cererea a fost anulată."
}
};
})();
+12 -2
View File
@@ -52,6 +52,7 @@ function isValidEmail(value) {
*
* Rules:
* - 429 (rate limit) → return rateLimitKey translation
* - 4xx with known error code → look up 'error.<code>' in i18n dictionary first
* - 4xx with error body → return server's error message (intentional feedback)
* - 5xx or no body → return fallbackKey translation
*
@@ -65,8 +66,17 @@ function extractApiError(body, status, fallbackKey, rateLimitKey) {
if (status === 429) {
return window.MyAi.t(rateLimitKey || 'form.rateLimited');
}
var msg = body && (body.error || body.Error || body.title);
return (status >= 400 && status < 500 && msg) ? msg : window.MyAi.t(fallbackKey);
if (status >= 400 && status < 500) {
// Prefer i18n translation keyed on the machine-readable error code
if (body && body.code) {
var codeKey = 'error.' + body.code;
var translated = window.MyAi.t(codeKey);
if (translated !== codeKey) return translated;
}
var msg = body && (body.error || body.Error || body.title);
if (msg) return msg;
}
return window.MyAi.t(fallbackKey);
}
// Expose helpers on window.MyAi for use by other scripts