3 Commits

Author SHA1 Message Date
gelu 873576e2bf Merge pull request 'fix: footer vertical misalignment on all pages' (#14) from main into staging
Build and Push Docker Images Staging / build (push) Successful in 25s
Merge PR #14: fix footer vertical misalignment on all pages
2026-05-24 14:28:46 +00:00
gelu eee3215302 Merge pull request 'feat: language-aware match results + full controller documentation' (#13) from main into staging
Build and Push Docker Images Staging / build (push) Successful in 3m17s
Merge PR #13: feat: language-aware match results + full controller documentation
2026-05-24 14:08:41 +00:00
gelu c553757db0 Merge pull request 'feat: version display in web UI footer' (#11) from main into staging
Build and Push Docker Images Staging / build (push) Successful in 2m14s
Reviewed-on: #11
2026-05-22 17:50:15 +00:00
254 changed files with 3436 additions and 11908 deletions
-18
View File
@@ -11,11 +11,9 @@ env:
API_IMAGE: apps/myai-api API_IMAGE: apps/myai-api
CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api
RAG_API_IMAGE: apps/myai-rag-api RAG_API_IMAGE: apps/myai-rag-api
EMAIL_API_IMAGE: apps/myai-email-api
WEB_IMAGE: apps/myai-web WEB_IMAGE: apps/myai-web
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
IMAGE_TAG: staging IMAGE_TAG: staging
jobs: jobs:
@@ -47,10 +45,6 @@ jobs:
run: | run: |
docker build -f Apis/rag-api/Dockerfile -t "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}" . docker build -f Apis/rag-api/Dockerfile -t "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}" .
- name: Build Email API image
run: |
docker build -f Apis/email-api/Dockerfile -t "${REGISTRY_HOST}/${EMAIL_API_IMAGE}:${IMAGE_TAG}" .
- name: Build Web image - name: Build Web image
run: | run: |
docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" .
@@ -63,10 +57,6 @@ jobs:
run: | run: |
docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" . docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" .
- name: Build Page Fetcher API image
run: |
docker build -f Apis/page-fetcher-api/Dockerfile -t "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}" .
- name: Push API image - name: Push API image
run: | run: |
docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}" docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}"
@@ -79,10 +69,6 @@ jobs:
run: | run: |
docker push "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}" docker push "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}"
- name: Push Email API image
run: |
docker push "${REGISTRY_HOST}/${EMAIL_API_IMAGE}:${IMAGE_TAG}"
- name: Push Web image - name: Push Web image
run: | run: |
docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}"
@@ -94,7 +80,3 @@ jobs:
- name: Push CV search job image - name: Push CV search job image
run: | run: |
docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}"
- name: Push Page Fetcher API image
run: |
docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}"
@@ -10,6 +10,4 @@ public sealed class JobMatchRequest
public string? CaptchaToken { get; set; } public string? CaptchaToken { get; set; }
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary> /// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
public string? Language { get; set; } public string? Language { get; set; }
/// <summary>Client IP address — set by the api layer from the HTTP context before forwarding. Not supplied by the browser.</summary>
public string? ClientIpAddress { get; set; }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
using Common.Requests; using Shared.Models.Requests;
namespace Models.Requests namespace Models.Requests
{ {
+11
View File
@@ -0,0 +1,11 @@
namespace Models.Settings
{
public class SmtpSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool UseStartTls { get; set; } = true;
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
@@ -8,11 +8,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\common\common.csproj" /> <ProjectReference Include="..\shared-models\shared-models.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -10,5 +10,5 @@ public interface IJobSearchApi
Task<CreateJobSearchTokenResponse> CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct); Task<CreateJobSearchTokenResponse> CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct);
[Post("/api/cv/job-search/token/{tokenId}/start")] [Post("/api/cv/job-search/token/{tokenId}/start")]
Task<StartJobSearchResponse> StartSearchAsync(string tokenId, [Body] StartJobSearchRequest request, CancellationToken ct); Task<StartJobSearchResponse> StartSearchAsync(string tokenId, CancellationToken ct);
} }
+2 -2
View File
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
using Models.Settings; using Models.Settings;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Models.Requests; using Models.Requests;
using Common.Responses; using Shared.Models.Responses;
namespace Api.Controllers namespace Api.Controllers
{ {
@@ -70,7 +70,7 @@ namespace Api.Controllers
{ {
Error = "Captcha verification failed.", Error = "Captcha verification failed.",
Code = "captcha_verification_failed", Code = "captcha_verification_failed",
Detail = verdict.Score.HasValue ? $"Score: {verdict.Score:0.00}" : null Score = verdict.Score
}); });
} }
+2 -2
View File
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
using Models.Settings; using Models.Settings;
using Models.Requests; using Models.Requests;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Common.Responses; using Shared.Models.Responses;
namespace Api.Controllers namespace Api.Controllers
{ {
@@ -115,7 +115,7 @@ namespace Api.Controllers
catch (Exception ex) catch (Exception ex)
{ {
_log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email); _log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email);
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Could not process subscription.", Code = "subscription_failed" }); return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed.", Code = "subscription_failed" });
} }
} }
+30 -50
View File
@@ -4,12 +4,12 @@ using CvMatcher.Models.Responses;
using Models.Requests; using Models.Requests;
using Models.Settings; using Models.Settings;
using Api.Services.Contracts; using Api.Services.Contracts;
using Api.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Common.Responses; using Shared.Models.Responses;
using MyAi.Data.Services;
namespace Api.Controllers; namespace Api.Controllers;
@@ -27,7 +27,6 @@ public sealed class CvMatcherController : ControllerBase
private readonly FileStorageSettings _fileStorageSettings; private readonly FileStorageSettings _fileStorageSettings;
private readonly JobSearchLinkSettings _jobSearchLinkSettings; private readonly JobSearchLinkSettings _jobSearchLinkSettings;
private readonly IEmailSender _emailSender; private readonly IEmailSender _emailSender;
private readonly ITemplateService _templates;
private readonly ILogger<CvMatcherController> _logger; private readonly ILogger<CvMatcherController> _logger;
public CvMatcherController( public CvMatcherController(
@@ -37,7 +36,6 @@ public sealed class CvMatcherController : ControllerBase
IOptions<FileStorageSettings> fileStorageSettings, IOptions<FileStorageSettings> fileStorageSettings,
IOptions<JobSearchLinkSettings> jobSearchLinkSettings, IOptions<JobSearchLinkSettings> jobSearchLinkSettings,
IEmailSender emailSender, IEmailSender emailSender,
ITemplateService templates,
ILogger<CvMatcherController> logger) ILogger<CvMatcherController> logger)
{ {
_cvApi = cvApi; _cvApi = cvApi;
@@ -46,7 +44,6 @@ public sealed class CvMatcherController : ControllerBase
_fileStorageSettings = fileStorageSettings.Value; _fileStorageSettings = fileStorageSettings.Value;
_jobSearchLinkSettings = jobSearchLinkSettings.Value; _jobSearchLinkSettings = jobSearchLinkSettings.Value;
_emailSender = emailSender; _emailSender = emailSender;
_templates = templates;
_logger = logger; _logger = logger;
} }
@@ -112,16 +109,6 @@ public sealed class CvMatcherController : ControllerBase
_logger.LogWarning("CV upload proxy request was cancelled by the client."); _logger.LogWarning("CV upload proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" });
} }
catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500)
{
// Forward upstream 4xx errors (e.g. "File is too large", "Only PDF files supported")
// so the browser can display the actionable message rather than a generic 502.
var body = await apiEx.GetContentAsAsync<ErrorResponse>();
_logger.LogWarning("Upstream cv-matcher-api returned {Status} during CV upload: {Error}",
(int)apiEx.StatusCode, body?.Error);
return StatusCode((int)apiEx.StatusCode,
body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" });
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "CV upload proxy request failed."); _logger.LogError(ex, "CV upload proxy request failed.");
@@ -163,7 +150,6 @@ public sealed class CvMatcherController : ControllerBase
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" }); 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}", _logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}",
request.CvDocumentId, request.CvDocumentId,
!string.IsNullOrWhiteSpace(request.JobUrl), !string.IsNullOrWhiteSpace(request.JobUrl),
@@ -172,9 +158,7 @@ public sealed class CvMatcherController : ControllerBase
var attachmentPath = TryGetCachedCvPath(request.CvDocumentId); var attachmentPath = TryGetCachedCvPath(request.CvDocumentId);
var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl) var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl)
? request.JobUrl ? request.JobUrl
: _emailSender.GetManualJobLabel(language); : "Manual job description";
var language = NormalizeLanguage(request.Language);
string? jobSearchLink = null; string? jobSearchLink = null;
if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId)) if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId))
@@ -182,14 +166,11 @@ public sealed class CvMatcherController : ControllerBase
try try
{ {
var tokenResp = await _jobSearchApi.CreateTokenAsync( var tokenResp = await _jobSearchApi.CreateTokenAsync(
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location, ClientIpAddress = userIp }, new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email },
ct); ct);
if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
{
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
} }
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Could not create job search token. Email link will be omitted."); _logger.LogWarning(ex, "Could not create job search token. Email link will be omitted.");
@@ -198,8 +179,8 @@ public sealed class CvMatcherController : ControllerBase
await _emailSender.SendMatchAsync( await _emailSender.SendMatchAsync(
request.Email, request.Email,
_emailSender.BuildMatchEmailSubject(res.Score, jobLabel, language), SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel),
_emailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, language, jobSearchLink), SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, jobSearchLink),
attachmentPath, attachmentPath,
ct); ct);
@@ -210,16 +191,6 @@ public sealed class CvMatcherController : ControllerBase
_logger.LogWarning("Job match proxy request was cancelled by the client."); _logger.LogWarning("Job match proxy request was cancelled by the client.");
return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" });
} }
catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500)
{
// Forward upstream 4xx errors (e.g. "Could not extract enough job text",
// "Invalid job URL") so the browser can display the actionable message.
var body = await apiEx.GetContentAsAsync<ErrorResponse>();
_logger.LogWarning("Upstream cv-matcher-api returned {Status} during job match: {Error}",
(int)apiEx.StatusCode, body?.Error);
return StatusCode((int)apiEx.StatusCode,
body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" });
}
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Job match proxy request failed."); _logger.LogError(ex, "Job match proxy request failed.");
@@ -245,27 +216,39 @@ public sealed class CvMatcherController : ControllerBase
{ {
try try
{ {
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); var result = await _jobSearchApi.StartSearchAsync(t, ct);
var result = await _jobSearchApi.StartSearchAsync(t, new StartJobSearchRequest { ClientIpAddress = userIp }, ct); var html = result.Status switch
var lang = "en";
var (title, message) = result.Status switch
{ {
StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), StartJobSearchStatus.Started =>
StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), HtmlPage("Job search started", "Your job search has started. Results will be sent to your email shortly."),
StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)), StartJobSearchStatus.AlreadyUsed =>
_ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang)) HtmlPage("Link already used", "This job search link has already been used."),
StartJobSearchStatus.Expired =>
HtmlPage("Link expired", "This job search link has expired. Please request a new CV match to get a fresh link."),
_ =>
HtmlPage("Invalid link", "This job search link is not valid.")
}; };
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html"); return Content(html, "text/html");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Job search start failed for token {Token}.", t); _logger.LogError(ex, "Job search start failed for token {Token}.", t);
var title = _templates.Get("html.job-search.error.title", "en"); return Content(HtmlPage("Error", "An error occurred. Please try again later."), "text/html");
var message = _templates.Get("html.job-search.error.message", "en");
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html");
} }
} }
private static string HtmlPage(string title, string message) => $$"""
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>{{title}} - MyAi.ro</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}
.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}
h1{font-size:1.4rem;margin-bottom:.5rem}p{color:#555}</style>
</head>
<body><div class="card"><h1>{{title}}</h1><p>{{message}}</p></div></body>
</html>
""";
private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct) private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
{ {
try try
@@ -305,9 +288,6 @@ public sealed class CvMatcherController : ControllerBase
return Path.Combine(GetFileStoragePath(), $"{safeId}.pdf"); return Path.Combine(GetFileStoragePath(), $"{safeId}.pdf");
} }
private static string NormalizeLanguage(string? language) =>
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
private string GetFileStoragePath() private string GetFileStoragePath()
{ {
var fileStoragePath = _fileStorageSettings.Path; var fileStoragePath = _fileStorageSettings.Path;
+12 -34
View File
@@ -6,8 +6,7 @@ using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Common.Responses; using Shared.Models.Responses;
using Microsoft.AspNetCore.RateLimiting;
namespace Api.Controllers namespace Api.Controllers
{ {
@@ -18,44 +17,42 @@ namespace Api.Controllers
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
[EnableCors("FrontendOnly")] [EnableCors("FrontendOnly")]
[EnableRateLimiting("download")]
public sealed class FileDownloadController : ControllerBase public sealed class FileDownloadController : ControllerBase
{ {
private readonly ILogger<FileDownloadController> _logger; private readonly ILogger<FileDownloadController> _logger;
private readonly FileStorageSettings _fileStorageSettings; private readonly FileStorageSettings _fileStorageSettings;
private readonly IContentTypeProvider _contentTypeProvider; private readonly IContentTypeProvider _contentTypeProvider;
private readonly IEmailSender _emailSender; private readonly IEmailSender _emailSender;
private readonly ICaptchaVerifier _captcha; private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance
private const int BufferSize = 81920;
public FileDownloadController( public FileDownloadController(
ILogger<FileDownloadController> logger, ILogger<FileDownloadController> logger,
IOptions<FileStorageSettings> fileStorageSettings, IOptions<FileStorageSettings> fileStorageSettings,
IContentTypeProvider contentTypeProvider, IContentTypeProvider contentTypeProvider,
IEmailSender emailSender, IEmailSender emailSender)
ICaptchaVerifier captcha)
{ {
_logger = logger; _logger = logger;
_fileStorageSettings = fileStorageSettings.Value; _fileStorageSettings = fileStorageSettings.Value;
_contentTypeProvider = contentTypeProvider; _contentTypeProvider = contentTypeProvider;
_emailSender = emailSender; _emailSender = emailSender;
_captcha = captcha;
} }
/// <summary> /// <summary>
/// Downloads a file with support for resume (range requests) and chunked transfer. /// Downloads a file with support for resume (range requests) and chunked transfer.
/// Supports HTTP 206 Partial Content for efficient downloads and resume capability. /// Supports HTTP 206 Partial Content for efficient downloads and resume capability.
/// Requires a valid reCAPTCHA v3 token on the initial (non-range) request.
/// Sends email notification when download starts. /// Sends email notification when download starts.
/// </summary> /// </summary>
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided).</param> /// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided)</param>
/// <param name="captchaToken">reCAPTCHA v3 token — required on the initial download request; omit on subsequent range requests.</param>
/// <returns>File stream with appropriate headers for resumable downloads</returns> /// <returns>File stream with appropriate headers for resumable downloads</returns>
/// <response code="200">Full file content</response>
/// <response code="206">Partial file content (range request)</response>
/// <response code="404">File not found</response>
/// <response code="416">Requested range not satisfiable</response>
[HttpGet("{fileName?}")] [HttpGet("{fileName?}")]
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file. Requires a reCAPTCHA v3 token on the initial request. Range requests for resume do not require a token.")] [SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")]
[SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")] [SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")]
[SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")] [SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Missing/invalid captcha token, no file name, or no default configured")] [SwaggerResponse(StatusCodes.Status400BadRequest, "No file name provided and no default configured")]
[SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")] [SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")]
[SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")] [SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")]
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")] [SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")]
@@ -65,30 +62,10 @@ namespace Api.Controllers
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)] [ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> DownloadFile(string? fileName = null, [FromQuery] string? captchaToken = null) public async Task<IActionResult> DownloadFile(string? fileName = null)
{ {
try try
{ {
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
// Captcha required on the initial (full) download only — range requests are resume continuations.
var isRangeRequest = !string.IsNullOrEmpty(Request.Headers[HeaderNames.Range].ToString());
if (!isRangeRequest)
{
if (string.IsNullOrWhiteSpace(captchaToken))
{
_logger.LogWarning("Download attempt without captcha token from IP={IP}", HttpContext.Connection.RemoteIpAddress);
return BadRequest(new ErrorResponse { Error = "Captcha token is required.", Code = "captcha_token_missing" });
}
var verdict = await _captcha.VerifyAsync(captchaToken, userIp, "file_download", CancellationToken.None);
if (!verdict.Success)
{
_logger.LogWarning("Download blocked by captcha. IP={IP} Score={Score}", userIp, verdict.Score);
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
}
}
if (string.IsNullOrWhiteSpace(fileName)) if (string.IsNullOrWhiteSpace(fileName))
{ {
fileName = _fileStorageSettings.DefaultFileName; fileName = _fileStorageSettings.DefaultFileName;
@@ -126,6 +103,7 @@ namespace Api.Controllers
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType)) if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
contentType = "application/octet-stream"; contentType = "application/octet-stream";
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try try
+2 -11
View File
@@ -2,27 +2,18 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY Directory.Packages.props ./
COPY Apis/api/api.csproj Apis/api/ COPY Apis/api/api.csproj Apis/api/
COPY Apis/shared-models/shared-models.csproj Apis/shared-models/
COPY Apis/api-models/api-models.csproj Apis/api-models/ COPY Apis/api-models/api-models.csproj Apis/api-models/
COPY Apis/email-data/email-data.csproj Apis/email-data/
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
COPY Apis/common/common.csproj Apis/common/
COPY Apis/myai-data/myai-data.csproj Apis/myai-data/
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
RUN dotnet restore Apis/api/api.csproj RUN dotnet restore Apis/api/api.csproj
COPY Apis/api/ Apis/api/ COPY Apis/api/ Apis/api/
COPY Apis/shared-models/ Apis/shared-models/
COPY Apis/api-models/ Apis/api-models/ COPY Apis/api-models/ Apis/api-models/
COPY Apis/email-data/ Apis/email-data/
COPY Apis/email-api-models/ Apis/email-api-models/
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
COPY Apis/common/ Apis/common/
COPY Apis/myai-data/ Apis/myai-data/
COPY Apis/shared-data/ Apis/shared-data/
COPY Helpers/startup-helpers/ Helpers/startup-helpers/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/
RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+2 -57
View File
@@ -1,19 +1,9 @@
using System.Reflection; using System.Reflection;
using Api.Services; using Api.Services;
using Api.Services.Contracts; using Api.Services.Contracts;
using Email.Data;
using Email.Data.Repositories;
using Email.Data.Repositories.Contracts;
using Email.Data.Services;
using Email.Models.Clients;
using Email.Models.Settings;
using Microsoft.EntityFrameworkCore;
using Models.Settings; using Models.Settings;
using MyAi.Data;
using MyAi.Data.Services;
using Refit; using Refit;
using Serilog; using Serilog;
using Common.Settings;
using StartupHelpers; using StartupHelpers;
StartupExtensions.LoadDotEnvFile(); StartupExtensions.LoadDotEnvFile();
@@ -35,50 +25,15 @@ try
builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google")); builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google"));
builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact")); builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact"));
builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe")); builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe"));
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha")); builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage")); builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch")); builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
builder.Services.Configure<EmailApiSettings>(builder.Configuration.GetSection("EmailApi"));
builder.Services.AddDbContext<MyAiDbContext>(options =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsAssembly("myai-data");
sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName);
});
});
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
builder.Services.AddDbContext<EmailDbContext>(options =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName);
sql.MigrationsAssembly("email-data");
});
});
builder.Services.AddScoped<IEmailTemplateRepository, EfEmailTemplateRepository>();
builder.Services.AddSingleton<IEmailTemplateService, EmailTemplateService>();
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>(); builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
builder.Services.AddSingleton<IEmailSender, EmailApiEmailSender>(); builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>(); builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
static void ConfigureEmailApiClient(IServiceProvider sp, HttpClient client)
{
var config = sp.GetRequiredService<IConfiguration>();
var baseUrl = config["EmailApi:BaseUrl"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(baseUrl))
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
var key = config["EmailApi:InternalApiKey"];
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
}
static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client) static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client)
{ {
var config = sp.GetRequiredService<IConfiguration>(); var config = sp.GetRequiredService<IConfiguration>();
@@ -90,9 +45,6 @@ try
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
} }
builder.Services.AddRefitClient<IEmailApiClient>()
.ConfigureHttpClient(ConfigureEmailApiClient);
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>() builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
.ConfigureHttpClient(ConfigureCvMatcherApiClient); .ConfigureHttpClient(ConfigureCvMatcherApiClient);
@@ -119,13 +71,6 @@ try
app.UseRateLimiter(); app.UseRateLimiter();
app.MapControllers(); app.MapControllers();
Log.Information("Running EF Core migrations if any");
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
db.Database.Migrate();
}
Log.Information("{Service} startup complete. Listening for requests...", ServiceName); Log.Information("{Service} startup complete. Listening for requests...", ServiceName);
app.Run(); app.Run();
} }
@@ -1,21 +1,9 @@
using Api.Services.Contracts.Models; using Api.Services.Contracts.Models;
namespace Api.Services.Contracts namespace Api.Services.Contracts
{ {
/// <summary>
/// Verifies a reCAPTCHA token against the Google verification API.
/// </summary>
public interface ICaptchaVerifier public interface ICaptchaVerifier
{ {
/// <summary>
/// Sends the token to the Google reCAPTCHA verification endpoint and
/// returns a verdict indicating success, score, and any failure reason.
/// </summary>
/// <param name="token">The reCAPTCHA token provided by the client.</param>
/// <param name="userIp">Optional remote IP address passed to Google for additional risk analysis.</param>
/// <param name="expectedAction">Optional action name to validate against the token's embedded action (v3 only).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A <see cref="CaptchaVerdictModel"/> with the verification outcome.</returns>
Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct); Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct);
} }
} }
+1 -60
View File
@@ -1,71 +1,12 @@
using CvMatcher.Models.Responses; using Models.Requests;
using Models.Requests;
namespace Api.Services.Contracts namespace Api.Services.Contracts
{ {
/// <summary>
/// Abstraction for sending transactional emails from the public API.
/// </summary>
public interface IEmailSender public interface IEmailSender
{ {
/// <summary>
/// Sends a contact-form message to the configured operator address.
/// </summary>
/// <param name="req">Contact request containing name, email, subject, and message.</param>
/// <param name="ct">Cancellation token.</param>
Task SendContactAsync(ContactRequest req, CancellationToken ct); Task SendContactAsync(ContactRequest req, CancellationToken ct);
/// <summary>
/// Notifies the configured operator address that a new email subscription was received.
/// </summary>
/// <param name="req">Subscription request containing the subscriber's email address.</param>
/// <param name="ct">Cancellation token.</param>
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct); Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
/// <summary>
/// Sends a background notification when a file download is initiated.
/// Does nothing when no notification address is configured.
/// </summary>
/// <param name="fileName">Name of the downloaded file.</param>
/// <param name="userIp">Remote IP address of the downloader, or <c>null</c> if unavailable.</param>
/// <param name="ct">Cancellation token.</param>
Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct); Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct);
/// <summary>
/// Sends a CV match results email to the user and the operator copy address.
/// </summary>
/// <param name="explicitTo">Primary recipient email address, or <c>null</c> to send only the operator copy.</param>
/// <param name="subject">Email subject line.</param>
/// <param name="body">Pre-built HTML body fragment.</param>
/// <param name="attachmentPath">Full path to a CV PDF to attach, or <c>null</c> for no attachment.</param>
/// <param name="ct">Cancellation token.</param>
Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct); Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct);
/// <summary>
/// Builds the localised subject line for a CV match email.
/// </summary>
/// <param name="score">Match score percentage (0100).</param>
/// <param name="jobLabel">Human-readable job title or label.</param>
/// <param name="language">Two-letter language code (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
/// <returns>Rendered subject string.</returns>
string BuildMatchEmailSubject(int score, string? jobLabel, string language);
/// <summary>
/// Builds the full HTML body for a CV match email, including an optional job-search footer link.
/// </summary>
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
/// <param name="result">Structured match response from the CV matcher engine.</param>
/// <param name="jobLabel">Human-readable job title or label.</param>
/// <param name="language">Two-letter language code.</param>
/// <param name="jobSearchLink">Optional one-click job-search URL to append as a footer CTA.</param>
/// <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);
} }
} }
-245
View File
@@ -1,245 +0,0 @@
using Api.Services.Contracts;
using CvMatcher.Models.Responses;
using Email.Data.Services;
using Email.Models.Clients;
using Email.Models.Requests;
using Microsoft.Extensions.Options;
using Models.Requests;
using Models.Settings;
using System.Net;
namespace Api.Services;
/// <summary>
/// Implements <see cref="IEmailSender"/> by delegating all email dispatch to the internal email-api service via Refit.
/// </summary>
public sealed class EmailApiEmailSender : IEmailSender
{
private readonly IEmailApiClient _emailApi;
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly IEmailTemplateService _emailTemplates;
private readonly ILogger<EmailApiEmailSender> _log;
public EmailApiEmailSender(
IEmailApiClient emailApi,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
IEmailTemplateService emailTemplates,
ILogger<EmailApiEmailSender> log)
{
_emailApi = emailApi;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_emailTemplates = emailTemplates;
_log = log;
}
/// <inheritdoc />
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
{
_log.LogDebug("Contact email skipped - ToEmail not configured");
throw new InvalidOperationException("Contact email recipient is not configured.");
}
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
req.Email, _contact.ToEmail);
var htmlBody = $"""
<h2 style="color:#2c5282;margin:0 0 20px">New Contact Message</h2>
<table cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:24px">
<tr style="background:#f8f9fa">
<td style="font-weight:600;width:100px;border:1px solid #dee2e6;color:#495057">Name</td>
<td style="border:1px solid #dee2e6">{req.Name}</td>
</tr>
<tr>
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Email</td>
<td style="border:1px solid #dee2e6">{req.Email}</td>
</tr>
<tr style="background:#f8f9fa">
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Subject</td>
<td style="border:1px solid #dee2e6">{req.Subject}</td>
</tr>
</table>
<h3 style="color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px">Message</h3>
<p style="color:#495057;line-height:1.7;white-space:pre-wrap">{req.Message}</p>
""";
await _emailApi.SendAsync(new SendEmailRequest
{
To = [_contact.ToEmail],
ReplyTo = req.Email,
Subject = $"{_contact.SubjectPrefix} {req.Subject}".Trim(),
HtmlBody = htmlBody
}, ct);
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
}
/// <inheritdoc />
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
{
_log.LogDebug("Subscription email skipped - ToEmail not configured");
throw new InvalidOperationException("Subscription email recipient is not configured.");
}
_log.LogInformation("Processing subscription request for {Email}", req.Email);
var htmlBody = $"""
<h2 style="color:#2c5282;margin:0 0 20px">New Subscription Request</h2>
<p style="color:#495057">A new user has subscribed:</p>
<table cellpadding="10" cellspacing="0" style="border-collapse:collapse;margin-top:12px">
<tr style="background:#f8f9fa">
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057;padding:10px 16px">Email</td>
<td style="border:1px solid #dee2e6;padding:10px 16px">{req.Email}</td>
</tr>
</table>
""";
await _emailApi.SendAsync(new SendEmailRequest
{
To = [_subscribe.ToEmail],
ReplyTo = req.Email,
Subject = _subscribe.SubjectPrefix.Trim(),
HtmlBody = htmlBody
}, ct);
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
}
/// <inheritdoc />
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
{
_log.LogDebug("File download notification skipped - ToEmail not configured");
return;
}
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
var htmlBody = $"""
<h2 style="color:#2c5282;margin:0 0 20px">File Download Notification</h2>
<table cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse">
<tr style="background:#f8f9fa">
<td style="font-weight:600;width:120px;border:1px solid #dee2e6;color:#495057">File</td>
<td style="border:1px solid #dee2e6">{fileName}</td>
</tr>
<tr>
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Downloaded at</td>
<td style="border:1px solid #dee2e6">{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</td>
</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>
</tr>
</table>
""";
await _emailApi.SendAsync(new SendEmailRequest
{
To = [_fileStorage.ToEmail],
Subject = $"{_fileStorage.SubjectPrefix} {fileName}".Trim(),
HtmlBody = htmlBody
}, ct);
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
}
/// <inheritdoc />
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
{
var operatorCopy = _emailTemplates.GetOperatorCopy("email.match.subject", "en");
var recipients = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitTo))
recipients.Add(explicitTo);
if (!string.IsNullOrWhiteSpace(operatorCopy) &&
!recipients.Any(x => string.Equals(x, operatorCopy, StringComparison.OrdinalIgnoreCase)))
recipients.Add(operatorCopy);
if (recipients.Count == 0)
{
_log.LogDebug("Match email skipped - no recipients configured");
return;
}
string? relativeAttachment = null;
if (!string.IsNullOrWhiteSpace(attachmentPath))
relativeAttachment = Path.GetFileName(attachmentPath);
foreach (var recipient in recipients)
{
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
await _emailApi.SendAsync(new SendEmailRequest
{
To = [recipient],
Subject = subject,
HtmlBody = body,
AttachmentPath = relativeAttachment
}, ct);
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
}
}
/// <inheritdoc />
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
{
// Build HTML lists for strengths, gaps, and recommendations
var strengths = result.Strengths?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
: "<p style=\"color:#6c757d;margin:0\">—</p>";
var gaps = result.Gaps?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
: "<p style=\"color:#6c757d;margin:0\">—</p>";
var recommendations = result.Recommendations?.Count > 0
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
: "<p style=\"color:#6c757d;margin:0\">—</p>";
// Render the HTML template with substituted values
// 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"),
("score", result.Score.ToString()),
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
("strengths", strengths),
("gaps", gaps),
("recommendations", recommendations));
// Append the job search footer if link is provided
if (!string.IsNullOrWhiteSpace(jobSearchLink))
{
body += _emailTemplates.Render("email.match.job-search-footer", language,
("jobSearchLink", jobSearchLink),
("expiryDays", expiryDays.ToString()));
}
return body;
}
/// <inheritdoc />
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
_emailTemplates.Render("email.match.subject", language,
("score", score.ToString()),
("jobLabel", jobLabel ?? "Job"));
public string GetManualJobLabel(string language) =>
_emailTemplates.Get("email.match.manual-job-label", language);
}
-4
View File
@@ -5,9 +5,6 @@ using Models.Settings;
namespace Api.Services namespace Api.Services
{ {
/// <summary>
/// Verifies reCAPTCHA v2/v3 tokens by calling the Google site-verify API.
/// </summary>
public sealed class RecaptchaVerifier : ICaptchaVerifier public sealed class RecaptchaVerifier : ICaptchaVerifier
{ {
private readonly HttpClient _http; private readonly HttpClient _http;
@@ -21,7 +18,6 @@ namespace Api.Services
_log = log; _log = log;
} }
/// <inheritdoc />
public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct) public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct)
{ {
_log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown"); _log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown");
+254
View File
@@ -0,0 +1,254 @@
using Api.Services.Contracts;
using Microsoft.Extensions.Options;
using MailKit.Net.Smtp;
using MailKit.Security;
using MimeKit;
using Models.Settings;
using Models.Requests;
using CvMatcher.Models.Responses;
namespace Api.Services
{
public sealed class SmtpEmailSender : IEmailSender
{
private readonly SmtpSettings _smtp;
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly ILogger<SmtpEmailSender> _log;
private readonly string _environmentName;
public SmtpEmailSender(IOptions<SmtpSettings> smtp,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
ILogger<SmtpEmailSender> log)
{
_smtp = smtp.Value;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_log = log;
// Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development"
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
}
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since contact requests are important to process.
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
{
_log.LogDebug("Contact email skipped - ToEmail not configured");
throw new InvalidOperationException("Contact email recipient is not configured.");
}
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
req.Email, _contact.ToEmail);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_contact.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_contact.SubjectPrefix} [{_environmentName}] {req.Subject}".Trim();
var body =
$@"New contact form submission:
Name: {req.Name}
Email: {req.Email}
Subject: {req.Subject}
Message:
{req.Message}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "contact email", ct);
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
}
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since subscription requests are important to process.
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
{
_log.LogDebug("Subscription email skipped - ToEmail not configured");
throw new InvalidOperationException("Subscription email recipient is not configured.");
}
_log.LogInformation("Processing subscription request for {Email}", req.Email);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_subscribe.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_subscribe.SubjectPrefix} [{_environmentName}]".Trim();
var body =
$@"New subscription request:
Email: {req.Email}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "subscription email", ct);
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
}
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
{
// Skip sending if ToEmail is not configured
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
{
_log.LogDebug("File download notification skipped - ToEmail not configured");
return;
}
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_fileStorage.ToEmail));
msg.Subject = $"{_fileStorage.SubjectPrefix} [{_environmentName}] {fileName}".Trim();
var body =
$@"File download notification:
File: {fileName}
Downloaded at: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
IP Address: {userIp ?? "Unknown"}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "file download notification email", ct);
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
}
/// <summary>
/// Connects to the SMTP server and authenticates if credentials are configured.
/// </summary>
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
{
// If you're in enterprise environments, you may need to tweak certificate validation.
// Don't disable it casually.
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
_log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}",
_smtp.Host, _smtp.Port, tls);
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
if (!string.IsNullOrWhiteSpace(_smtp.Username))
{
_log.LogDebug("Authenticating with SMTP server as {Username}", _smtp.Username);
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
}
}
/// <summary>
/// Sends an email message using SMTP.
/// </summary>
/// <param name="message">The email message to send.</param>
/// <param name="messageType">Description of the message type for logging purposes.</param>
/// <param name="ct">Cancellation token.</param>
private async Task SendEmailAsync(MimeMessage message, string messageType, CancellationToken ct)
{
using var client = new SmtpClient();
await ConnectAndAuthenticateAsync(client, ct);
_log.LogDebug("Sending {MessageType} message", messageType);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
{
var recipients = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitTo))
{
recipients.Add(explicitTo);
}
if (!string.IsNullOrWhiteSpace(_contact.ToEmail) &&
!recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase)))
{
recipients.Add(_contact.ToEmail);
}
if (recipients.Count == 0)
{
_log.LogDebug("Match email skipped - no recipients configured (user email and Contact:ToEmail missing)");
return;
}
foreach (var recipient in recipients)
{
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(recipient));
msg.Subject = $"[{_environmentName}] {subject}".Trim();
var builder = new BodyBuilder
{
TextBody = body
};
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
{
builder.Attachments.Add(attachmentPath);
}
msg.Body = builder.ToMessageBody();
await SendEmailAsync(msg, "cv match email", ct);
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
}
}
public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string? jobSearchLink = null)
{
var body = $@"CV Matcher result
CV Document ID: {cvDocumentId}
Job: {jobLabel ?? "N/A"}
Job URL: {result.JobUrl ?? "N/A"}
Score: {result.Score}%
Summary:
{result.Summary}
Strengths:
- {string.Join("\n- ", result.Strengths)}
Gaps:
- {string.Join("\n- ", result.Gaps)}
Recommendations:
- {string.Join("\n- ", result.Recommendations)}";
if (!string.IsNullOrWhiteSpace(jobSearchLink))
{
body += $@"
---
Vrei sa gasesti mai multe joburi potrivite CV-ului tau?
Click: {jobSearchLink}
(link valabil 7 zile)";
}
return body;
}
public static string BuildMatchEmailSubject(int score, string? jobLabel)
=> $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}";
}
}
+13 -16
View File
@@ -16,18 +16,18 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" /> <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
<PackageReference Include="Azure.Identity" /> <PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="DotNetEnv" /> <PackageReference Include="DotNetEnv" Version="3.2.0" />
<!-- MailKit removed — email sending delegated to email-api service --> <PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" /> <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Serilog.AspNetCore" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" /> <PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Email" /> <PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageReference Include="Serilog.Sinks.File" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" /> <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" />
<PackageReference Include="Refit.HttpClientFactory" /> <PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -36,12 +36,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\api-models\api-models.csproj" /> <ProjectReference Include="..\api-models\api-models.csproj" />
<ProjectReference Include="..\email-data\email-data.csproj" />
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" /> <ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
<ProjectReference Include="..\common\common.csproj" /> <ProjectReference Include="..\shared-models\shared-models.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" /> <ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
<ProjectReference Include="..\myai-data\myai-data.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+21 -5
View File
@@ -2,7 +2,8 @@
"Serilog": { "Serilog": {
"Using": [ "Using": [
"Serilog.Sinks.Console", "Serilog.Sinks.Console",
"Serilog.Sinks.File" "Serilog.Sinks.File",
"Serilog.Sinks.Email"
], ],
"MinimumLevel": { "MinimumLevel": {
"Default": "Information", "Default": "Information",
@@ -29,6 +30,25 @@
"retainedFileCountLimit": 30, "retainedFileCountLimit": 30,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
} }
},
{
"Name": "Email",
"Args": {
"restrictedToMinimumLevel": "Error",
"fromEmail": "",
"toEmail": "",
"mailServer": "",
"networkCredential": {
"userName": "",
"password": ""
},
"port": 587,
"enableSsl": true,
"emailSubject": "[mihes.ro API] Error Alert",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
"batchPostingLimit": 10,
"period": "0.00:05:00"
}
} }
], ],
"Enrich": [ "Enrich": [
@@ -90,10 +110,6 @@
"BaseUrl": "", "BaseUrl": "",
"InternalApiKey": "" "InternalApiKey": ""
}, },
"EmailApi": {
"BaseUrl": "",
"InternalApiKey": ""
},
"RateLimiting": { "RateLimiting": {
"Global": { "Global": {
"PermitLimit": 120, "PermitLimit": 120,
-16
View File
@@ -1,16 +0,0 @@
namespace Common.Responses;
/// <summary>
/// Standard error body returned by all API endpoints on 4xx and 5xx responses.
/// </summary>
public sealed class ErrorResponse
{
/// <summary>Human-readable error message, safe to display directly to the end user for 4xx responses.</summary>
public string Error { get; init; } = string.Empty;
/// <summary>Machine-readable error code for programmatic handling (e.g. <c>"captcha_verification_failed"</c>).</summary>
public string? Code { get; init; }
/// <summary>Optional additional detail for debugging (not shown in UI).</summary>
public string? Detail { get; init; }
}
@@ -1,11 +0,0 @@
namespace Common.Settings;
/// <summary>
/// Connection settings for the internal page-fetcher-api service.
/// Bound from the <c>PageFetcherApi</c> configuration section.
/// </summary>
public sealed class PageFetcherApiSettings
{
public string BaseUrl { get; set; } = string.Empty;
public string InternalApiKey { get; set; } = string.Empty;
}
@@ -4,9 +4,4 @@ public sealed class CreateJobSearchTokenRequest
{ {
public string CvDocumentId { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
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; }
} }
@@ -9,7 +9,5 @@
public string? Email { get; set; } public string? Email { get; set; }
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary> /// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
public string? Language { get; set; } public string? Language { get; set; }
/// <summary>Client IP address forwarded by the api layer. Null when called from a background job.</summary>
public string? ClientIpAddress { get; set; }
} }
} }
@@ -1,11 +0,0 @@
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; }
}
@@ -2,9 +2,5 @@ namespace CvMatcher.Models.Responses;
public sealed class CreateJobSearchTokenResponse public sealed class CreateJobSearchTokenResponse
{ {
/// <summary> public string TokenId { get; set; } = string.Empty;
/// The generated token ID, or <c>null</c> when no job providers are currently enabled.
/// Callers must check for null before building the job-search link.
/// </summary>
public string? TokenId { get; set; }
} }
@@ -8,8 +8,6 @@
public List<string> Gaps { get; set; } = []; public List<string> Gaps { get; set; } = [];
public List<string> Recommendations { get; set; } = []; public List<string> Recommendations { get; set; } = [];
public List<string> Evidence { get; set; } = []; public List<string> Evidence { get; set; } = [];
public List<string> Keywords { get; set; } = [];
public string? Location { get; set; }
public bool Cached { get; set; } public bool Cached { get; set; }
public string? JobDocumentId { get; set; } public string? JobDocumentId { get; set; }
public string? JobUrl { get; set; } public string? JobUrl { get; set; }
@@ -1,8 +1,8 @@
using Common.Settings; using Shared.Models.Settings;
namespace CvMatcher.Models.Settings; namespace CvMatcher.Models.Settings;
public sealed class AiSettings : Common.Settings.AiSettings public sealed class AiSettings : Shared.Models.Settings.AiSettings
{ {
public OpenAiSettings OpenAI { get; set; } = new(); public OpenAiSettings OpenAI { get; set; } = new();
public OllamaSettings Ollama { get; set; } = new(); public OllamaSettings Ollama { get; set; } = new();
@@ -8,7 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\common\common.csproj" /> <ProjectReference Include="..\shared-models\shared-models.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+2 -2
View File
@@ -36,8 +36,8 @@ Default model: `gpt-4o-mini`. Timeout: 90 s.
Both contexts use the same SQL Server connection string (from `Database:*` settings). Both contexts use the same SQL Server connection string (from `Database:*` settings).
- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-data` assembly (`Apis/cv-matcher-data/`) - `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-api` assembly
- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-data` assembly (`Apis/cv-search-data/`) - `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-models` assembly (MigrationsAssembly = "cv-search-models")
## Keyword extraction (JobTokenService.ExtractKeywords) ## Keyword extraction (JobTokenService.ExtractKeywords)
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
using CvMatcher.Data.Repositories.Contracts; using Api.Data.Repositories.Contracts;
using Api.Clients.Ai.Contracts; using Api.Clients.Ai.Contracts;
using CommonHelpers; using CommonHelpers;
@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Api.Clients.Ai.Contracts; using Api.Clients.Ai.Contracts;
using CvMatcher.Data.Repositories.Contracts; using Api.Data.Repositories.Contracts;
using CommonHelpers; using CommonHelpers;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -2,9 +2,9 @@ using CvMatcher.Models.Requests;
using Api.Services.Contracts; using Api.Services.Contracts;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using Common.Requests; using Shared.Models.Requests;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
using Common.Responses; using Shared.Models.Responses;
namespace Api.Controllers; namespace Api.Controllers;
@@ -2,7 +2,7 @@ using Api.Services.Contracts;
using CvMatcher.Models.Requests; using CvMatcher.Models.Requests;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Common.Responses; using Shared.Models.Responses;
using Swashbuckle.AspNetCore.Annotations; using Swashbuckle.AspNetCore.Annotations;
namespace Api.Controllers; namespace Api.Controllers;
@@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); 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, request.ClientIpAddress, ct); var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, ct);
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
} }
catch (Exception ex) catch (Exception ex)
@@ -81,11 +81,11 @@ public sealed class JobSearchController : ControllerBase
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Session creation failed", typeof(ErrorResponse))] [SwaggerResponse(StatusCodes.Status500InternalServerError, "Session creation failed", typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, [FromBody] StartJobSearchRequest? request, CancellationToken ct) public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, CancellationToken ct)
{ {
try try
{ {
var status = await _tokenService.TriggerStartAsync(tokenId, request?.ClientIpAddress, ct); var status = await _tokenService.TriggerStartAsync(tokenId, ct);
return Ok(new StartJobSearchResponse { Status = status }); return Ok(new StartJobSearchResponse { Status = status });
} }
catch (Exception ex) catch (Exception ex)
@@ -1,12 +1,13 @@
using CvMatcher.Data.Entities; using Api.Data.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace CvMatcher.Data; namespace Api.Data;
public sealed class CvMatcherDbContext : DbContext public sealed class CvMatcherDbContext : DbContext
{ {
public const string SchemaName = MigrationConstants.SchemaName; public const string SchemaName = "cvMatcher";
public const string MigrationTableName = MigrationConstants.MigrationTableName; public const string MigrationTableName = "_Migrations";
public CvMatcherDbContext(DbContextOptions<CvMatcherDbContext> options) : base(options) public CvMatcherDbContext(DbContextOptions<CvMatcherDbContext> options) : base(options)
{ {
@@ -14,14 +15,6 @@ public sealed class CvMatcherDbContext : DbContext
public DbSet<CvMatchResultEntity> CvMatchResults => Set<CvMatchResultEntity>(); public DbSet<CvMatchResultEntity> CvMatchResults => Set<CvMatchResultEntity>();
public DbSet<CvMatcherChatCacheEntity> CvMatcherChatCache => Set<CvMatcherChatCacheEntity>(); public DbSet<CvMatcherChatCacheEntity> CvMatcherChatCache => Set<CvMatcherChatCacheEntity>();
public DbSet<AiPromptEntity> AiPrompts => Set<AiPromptEntity>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
// Configure migration history table to use schema-qualified name: [cvMatcher].[_Migrations]
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
}
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -36,9 +29,7 @@ public sealed class CvMatcherDbContext : DbContext
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.ResultJson).IsRequired(); entity.Property(x => x.ResultJson).IsRequired();
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.Property(x => x.Email).HasMaxLength(256); entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique();
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique();
}); });
modelBuilder.Entity<CvMatcherChatCacheEntity>(entity => modelBuilder.Entity<CvMatcherChatCacheEntity>(entity =>
@@ -51,16 +42,5 @@ public sealed class CvMatcherDbContext : DbContext
entity.Property(x => x.ResponseText).IsRequired(); entity.Property(x => x.ResponseText).IsRequired();
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
}); });
modelBuilder.Entity<AiPromptEntity>(entity =>
{
entity.ToTable("AiPrompts");
entity.HasKey(x => new { x.Key, x.Language });
entity.Property(x => x.Key).HasMaxLength(128);
entity.Property(x => x.Language).HasMaxLength(8);
entity.Property(x => x.Value).IsRequired();
entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty);
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
});
} }
} }
@@ -1,14 +1,12 @@
using Shared.Data.Entities; namespace Api.Data.Entities;
namespace CvMatcher.Data.Entities; public sealed class CvMatchResultEntity
public sealed class CvMatchResultEntity : BaseEntity
{ {
public string Id { get; set; } = string.Empty;
public string CvDocumentId { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty;
public string JobDocumentId { get; set; } = string.Empty; public string JobDocumentId { get; set; } = string.Empty;
public string Language { get; set; } = "en"; public string Language { get; set; } = "en";
public string ResultJson { get; set; } = string.Empty; public string ResultJson { get; set; } = string.Empty;
public int Score { get; set; } public int Score { get; set; }
public string? Email { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public string? ClientIpAddress { get; set; }
} }
@@ -1,6 +1,5 @@
namespace CvMatcher.Data.Entities; namespace Api.Data.Entities;
// CacheKey PK — BaseEntity not applicable
public sealed class CvMatcherChatCacheEntity public sealed class CvMatcherChatCacheEntity
{ {
public string CacheKey { get; set; } = string.Empty; public string CacheKey { get; set; } = string.Empty;
@@ -1,12 +1,12 @@
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
namespace CvMatcher.Data.Repositories.Contracts; namespace Api.Data.Repositories.Contracts;
public interface IMatcherRepository public interface IMatcherRepository
{ {
Task InitializeAsync(CancellationToken ct); Task InitializeAsync(CancellationToken ct);
Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct); Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct);
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct); Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct);
Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct); Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct);
Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct); Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct);
} }
@@ -1,12 +1,11 @@
using System.Text.Json; using System.Text.Json;
using CvMatcher.Data; using Api.Data;
using CvMatcher.Data.Entities; using Api.Data.Entities;
using CvMatcher.Data.Repositories.Contracts; using Api.Data.Repositories.Contracts;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace CvMatcher.Data.Repositories; namespace Api.Data.Repositories;
public sealed class EfMatcherRepository : IMatcherRepository public sealed class EfMatcherRepository : IMatcherRepository
{ {
@@ -40,7 +39,7 @@ public sealed class EfMatcherRepository : IMatcherRepository
return result; return result;
} }
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct) public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct)
{ {
var exists = await _db.CvMatchResults.AnyAsync( var exists = await _db.CvMatchResults.AnyAsync(
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language, x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language,
@@ -48,8 +47,6 @@ public sealed class EfMatcherRepository : IMatcherRepository
if (exists) return; if (exists) return;
try
{
_db.CvMatchResults.Add(new CvMatchResultEntity _db.CvMatchResults.Add(new CvMatchResultEntity
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"),
@@ -58,24 +55,11 @@ public sealed class EfMatcherRepository : IMatcherRepository
Language = language, Language = language,
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
Score = response.Score, Score = response.Score,
Email = email,
ClientIpAddress = clientIpAddress,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}); });
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
} }
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_Results_CvDocumentId_JobDocumentId_Language") == true
|| ex.InnerException?.Message.Contains("unique") == true)
{
// Duplicate key violation: record was inserted between the AnyAsync check and SaveChangesAsync.
// This is safe to ignore — the match result already exists in the database.
_logger.LogWarning(
"Duplicate match result ignored: CV={CvDocumentId} Job={JobDocumentId} Language={Language}. " +
"Record was likely inserted concurrently. This is expected behavior in high-concurrency scenarios.",
cvDocumentId, jobDocumentId, language);
}
}
public async Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct) public async Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct)
{ {
+4 -13
View File
@@ -2,28 +2,19 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY Directory.Packages.props ./
COPY Apis/cv-matcher-api/cv-matcher-api.csproj Apis/cv-matcher-api/ COPY Apis/cv-matcher-api/cv-matcher-api.csproj Apis/cv-matcher-api/
COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/ COPY Apis/cv-search-models/cv-search-models.csproj Apis/cv-search-models/
COPY Apis/cv-matcher-data/cv-matcher-data.csproj Apis/cv-matcher-data/ COPY Apis/shared-models/shared-models.csproj Apis/shared-models/
COPY Apis/common/common.csproj Apis/common/
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/
COPY Apis/myai-data/myai-data.csproj Apis/myai-data/
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj
COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/ COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/
COPY Apis/cv-search-data/ Apis/cv-search-data/ COPY Apis/cv-search-models/ Apis/cv-search-models/
COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/ COPY Apis/shared-models/ Apis/shared-models/
COPY Apis/common/ Apis/common/
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/
COPY Apis/myai-data/ Apis/myai-data/
COPY Apis/shared-data/ Apis/shared-data/
COPY Helpers/common-helpers/ Helpers/common-helpers/ COPY Helpers/common-helpers/ Helpers/common-helpers/
COPY Helpers/startup-helpers/ Helpers/startup-helpers/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvMatcher.Data; using Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace CvMatcher.Data.Migrations namespace Api.Migrations
{ {
[DbContext(typeof(CvMatcherDbContext))] [DbContext(typeof(CvMatcherDbContext))]
[Migration("20260608124331_ImproveKeywordsAndAddLocation")] [Migration("20260507140442_InitialCvMatcherSchema")]
partial class ImproveKeywordsAndAddLocation partial class InitialCvMatcherSchema
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,38 +26,7 @@ namespace CvMatcher.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", 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") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -78,10 +47,6 @@ namespace CvMatcher.Data.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("nvarchar(64)"); .HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson") b.Property<string>("ResultJson")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -91,13 +56,13 @@ namespace CvMatcher.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId", "Language") b.HasIndex("CvDocumentId", "JobDocumentId")
.IsUnique(); .IsUnique();
b.ToTable("Results", "cvMatcher"); b.ToTable("Results", "cvMatcher");
}); });
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
{ {
b.Property<string>("CacheKey") b.Property<string>("CacheKey")
.HasMaxLength(64) .HasMaxLength(64)
@@ -0,0 +1,70 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Api.Migrations
{
/// <inheritdoc />
public partial class InitialCvMatcherSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "cvMatcher");
migrationBuilder.CreateTable(
name: "ChatCache",
schema: "cvMatcher",
columns: table => new
{
CacheKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Model = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
Temperature = table.Column<decimal>(type: "decimal(4,2)", nullable: false),
ResponseText = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ChatCache", x => x.CacheKey);
});
migrationBuilder.CreateTable(
name: "Results",
schema: "cvMatcher",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
JobDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Score = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_Results", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Results_CvDocumentId_JobDocumentId",
schema: "cvMatcher",
table: "Results",
columns: new[] { "CvDocumentId", "JobDocumentId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChatCache",
schema: "cvMatcher");
migrationBuilder.DropTable(
name: "Results",
schema: "cvMatcher");
}
}
}
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvMatcher.Data; using Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace CvMatcher.Data.Migrations namespace Api.Migrations
{ {
[DbContext(typeof(CvMatcherDbContext))] [DbContext(typeof(CvMatcherDbContext))]
[Migration("20260601133028_InitialSchema")] [Migration("20260524140335_AddLanguageToCvMatchResult")]
partial class InitialSchema partial class AddLanguageToCvMatchResult
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,38 +26,7 @@ namespace CvMatcher.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", 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") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -80,7 +49,7 @@ namespace CvMatcher.Data.Migrations
b.Property<string>("Language") b.Property<string>("Language")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(max)");
b.Property<string>("ResultJson") b.Property<string>("ResultJson")
.IsRequired() .IsRequired()
@@ -91,13 +60,13 @@ namespace CvMatcher.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId", "Language") b.HasIndex("CvDocumentId", "JobDocumentId")
.IsUnique(); .IsUnique();
b.ToTable("Results", "cvMatcher"); b.ToTable("Results", "cvMatcher");
}); });
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
{ {
b.Property<string>("CacheKey") b.Property<string>("CacheKey")
.HasMaxLength(64) .HasMaxLength(64)
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Api.Migrations
{
/// <inheritdoc />
public partial class AddLanguageToCvMatchResult : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
schema: "cvMatcher",
table: "Results",
type: "nvarchar(max)",
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
schema: "cvMatcher",
table: "Results");
}
}
}
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvMatcher.Data; using Api.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace CvMatcher.Data.Migrations namespace Api.Migrations
{ {
[DbContext(typeof(CvMatcherDbContext))] [DbContext(typeof(CvMatcherDbContext))]
partial class CvMatcherDbContextModelSnapshot : ModelSnapshot partial class CvMatcherDbContextModelSnapshot : ModelSnapshot
@@ -23,47 +23,12 @@ namespace CvMatcher.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", 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") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("nvarchar(64)"); .HasColumnType("nvarchar(64)");
b.Property<string>("ClientIpAddress")
.HasMaxLength(45)
.HasColumnType("nvarchar(45)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("datetime2") .HasColumnType("datetime2")
@@ -74,10 +39,6 @@ namespace CvMatcher.Data.Migrations
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("nvarchar(64)"); .HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("JobDocumentId") b.Property<string>("JobDocumentId")
.IsRequired() .IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
@@ -85,7 +46,7 @@ namespace CvMatcher.Data.Migrations
b.Property<string>("Language") b.Property<string>("Language")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(max)");
b.Property<string>("ResultJson") b.Property<string>("ResultJson")
.IsRequired() .IsRequired()
@@ -96,13 +57,13 @@ namespace CvMatcher.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId", "Language") b.HasIndex("CvDocumentId", "JobDocumentId")
.IsUnique(); .IsUnique();
b.ToTable("Results", "cvMatcher"); b.ToTable("Results", "cvMatcher");
}); });
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
{ {
b.Property<string>("CacheKey") b.Property<string>("CacheKey")
.HasMaxLength(64) .HasMaxLength(64)
+8 -20
View File
@@ -2,18 +2,18 @@ using Api.Clients.Ai;
using Api.Clients.Ai.Contracts; using Api.Clients.Ai.Contracts;
using Api.Clients.Api; using Api.Clients.Api;
using Api.Clients.Api.Contracts; using Api.Clients.Api.Contracts;
using CvMatcher.Data; using Api.Data;
using CvMatcher.Data.Repositories; using Api.Data.Repositories;
using CvMatcher.Data.Repositories.Contracts; using Api.Data.Repositories.Contracts;
using Api.Services; using Api.Services;
using Api.Services.Contracts; using Api.Services.Contracts;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
using CvSearch.Data; using CvSearch.Models.Data;
using CvSearch.Models.Settings;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Refit; using Refit;
using Serilog; using Serilog;
using Common.Settings; using Shared.Models.Settings;
using PageFetcher.Models;
using StartupHelpers; using StartupHelpers;
using System.Reflection; using System.Reflection;
@@ -37,16 +37,6 @@ try
builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai")); builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher")); builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
builder.Services.Configure<JobSearchSettings>(builder.Configuration.GetSection("JobSearch")); builder.Services.Configure<JobSearchSettings>(builder.Configuration.GetSection("JobSearch"));
builder.Services.Configure<PageFetcherApiSettings>(builder.Configuration.GetSection("PageFetcherApi"));
builder.Services.AddRefitClient<IPageFetcherApiClient>()
.ConfigureHttpClient((sp, c) =>
{
var settings = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<PageFetcherApiSettings>>().Value;
c.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/");
if (!string.IsNullOrWhiteSpace(settings.InternalApiKey))
c.DefaultRequestHeaders.Add("X-Internal-Api-Key", settings.InternalApiKey);
});
builder.Services.AddRefitClient<IRefitRagApi>() builder.Services.AddRefitClient<IRefitRagApi>()
.ConfigureHttpClient((sp, c) => .ConfigureHttpClient((sp, c) =>
@@ -61,7 +51,7 @@ try
builder.Services.AddScoped<IRagApiClient, RagApiClient>(); builder.Services.AddScoped<IRagApiClient, RagApiClient>();
builder.Services.AddHttpClient<IMatcherAiClient, MatcherAiClient>(); builder.Services.AddHttpClient<IMatcherAiClient, MatcherAiClient>();
builder.Services.AddScoped<IJobTextExtractor, JobTextExtractor>(); builder.Services.AddHttpClient<IJobTextExtractor, JobTextExtractor>();
builder.Services.AddDbContext<CvMatcherDbContext>(options => builder.Services.AddDbContext<CvMatcherDbContext>(options =>
{ {
@@ -71,7 +61,6 @@ try
options.UseSqlServer(connectionString, sql => options.UseSqlServer(connectionString, sql =>
{ {
sql.MigrationsHistoryTable(CvMatcherDbContext.MigrationTableName, CvMatcherDbContext.SchemaName); sql.MigrationsHistoryTable(CvMatcherDbContext.MigrationTableName, CvMatcherDbContext.SchemaName);
sql.MigrationsAssembly("cv-matcher-data");
}); });
}); });
@@ -80,13 +69,12 @@ try
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql => options.UseSqlServer(connectionString, sql =>
{ {
sql.MigrationsAssembly("cv-search-data"); sql.MigrationsAssembly("cv-search-models");
sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName); sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName);
}); });
}); });
builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>(); builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>();
builder.Services.AddScoped<IAiPromptsRepository, EfAiPromptsRepository>();
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>(); builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
builder.Services.AddScoped<IJobTokenService, JobTokenService>(); builder.Services.AddScoped<IJobTokenService, JobTokenService>();
@@ -3,34 +3,9 @@ using CvMatcher.Models.Responses;
namespace Api.Services.Contracts; namespace Api.Services.Contracts;
/// <summary>
/// Orchestrates CV indexing, job matching, and job discovery operations.
/// </summary>
public interface ICvMatcherService public interface ICvMatcherService
{ {
/// <summary>
/// Indexes a CV PDF into the RAG system and returns document metadata.
/// Returns cached metadata without re-indexing when the same text hash already exists.
/// </summary>
/// <param name="file">Uploaded CV PDF file.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Upload response with document ID, hash, and indexing statistics.</returns>
Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct); Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct);
/// <summary>
/// Scores a CV against a specific job posting URL or pasted description using the LLM.
/// Caches the result so repeat requests for the same (CV, job, language) triple are served instantly.
/// </summary>
/// <param name="request">Match request containing CV document ID, job URL or description, and language preference.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Structured match response with score, summary, strengths, gaps, and recommendations.</returns>
Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct); Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct);
/// <summary>
/// Searches the RAG index for job documents most similar to the given CV and scores the top candidates.
/// </summary>
/// <param name="request">Request containing the CV document ID and optional result count limit.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Response with the CV document ID and a list of ranked match results.</returns>
Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct); Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct);
} }
@@ -1,17 +1,6 @@
namespace Api.Services.Contracts; namespace Api.Services.Contracts;
/// <summary>
/// Extracts plain text from a job posting, either from a pasted description or by fetching and parsing a URL.
/// </summary>
public interface IJobTextExtractor public interface IJobTextExtractor
{ {
/// <summary>
/// Returns normalised plain text for the job posting.
/// Prefers <paramref name="jobDescription"/> when provided; otherwise fetches and strips HTML from <paramref name="jobUrl"/>.
/// </summary>
/// <param name="jobUrl">URL of the job posting page, used when no description is pasted.</param>
/// <param name="jobDescription">Pasted job description text; takes priority over URL fetching.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Normalised plain text, truncated to the configured maximum character limit.</returns>
Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct); Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct);
} }
@@ -1,35 +1,7 @@
namespace Api.Services.Contracts; namespace Api.Services.Contracts;
/// <summary>
/// Manages one-time job search tokens and the sessions they trigger.
/// </summary>
public interface IJobTokenService public interface IJobTokenService
{ {
/// <summary> Task<string> CreateTokenAsync(string cvDocumentId, string email, CancellationToken ct);
/// Creates a new single-use job search token linked to the given CV document and user. Task<string> TriggerStartAsync(string tokenId, CancellationToken ct);
/// The token expires after the number of days configured in <c>JobSearch:TokenExpiryDays</c>.
/// </summary>
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
/// <param name="email">Email address of the user who will receive the results.</param>
/// <param name="language">Preferred language for result emails (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
/// <param name="keywords">Job search keywords extracted by the LLM during the match call.</param>
/// <param name="location">Candidate location extracted from the CV (e.g. "Cluj-Napoca, Romania"). Null if not available.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 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, 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, string? clientIpAddress, CancellationToken ct);
} }
@@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Api.Clients.Ai.Contracts; using Api.Clients.Ai.Contracts;
using Api.Clients.Api.Contracts; using Api.Clients.Api.Contracts;
using CvMatcher.Data.Repositories.Contracts; using Api.Data.Repositories.Contracts;
using CvMatcher.Models.Requests; using CvMatcher.Models.Requests;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
@@ -10,16 +10,12 @@ using Microsoft.Extensions.Options;
namespace Api.Services; namespace Api.Services;
/// <summary>
/// Orchestrates CV upload, RAG indexing, job text extraction, LLM scoring, and result caching.
/// </summary>
public sealed class CvMatcherService : ICvMatcherService public sealed class CvMatcherService : ICvMatcherService
{ {
private readonly IRagApiClient _rag; private readonly IRagApiClient _rag;
private readonly IJobTextExtractor _jobTextExtractor; private readonly IJobTextExtractor _jobTextExtractor;
private readonly IMatcherAiClient _ai; private readonly IMatcherAiClient _ai;
private readonly IMatcherRepository _repository; private readonly IMatcherRepository _repository;
private readonly IAiPromptsRepository _aiPrompts;
private readonly MatcherSettings _settings; private readonly MatcherSettings _settings;
public CvMatcherService( public CvMatcherService(
@@ -27,18 +23,15 @@ public sealed class CvMatcherService : ICvMatcherService
IJobTextExtractor jobTextExtractor, IJobTextExtractor jobTextExtractor,
IMatcherAiClient ai, IMatcherAiClient ai,
IMatcherRepository repository, IMatcherRepository repository,
IAiPromptsRepository aiPrompts,
IOptions<MatcherSettings> options) IOptions<MatcherSettings> options)
{ {
_rag = rag; _rag = rag;
_jobTextExtractor = jobTextExtractor; _jobTextExtractor = jobTextExtractor;
_ai = ai; _ai = ai;
_repository = repository; _repository = repository;
_aiPrompts = aiPrompts;
_settings = options.Value; _settings = options.Value;
} }
/// <inheritdoc />
public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct) public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct)
{ {
var response = await _rag.IndexCvPdfAsync(file, ct); var response = await _rag.IndexCvPdfAsync(file, ct);
@@ -55,7 +48,6 @@ public sealed class CvMatcherService : ICvMatcherService
}; };
} }
/// <inheritdoc />
public async Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct) public async Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct)
{ {
var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found."); var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found.");
@@ -77,13 +69,12 @@ public sealed class CvMatcherService : ICvMatcherService
{ {
var job = await _rag.GetDocumentAsync(result.DocumentId, ct); var job = await _rag.GetDocumentAsync(result.DocumentId, ct);
if (job is null) continue; if (job is null) continue;
jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, null, NormalizeLanguage(null), ct)); jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, NormalizeLanguage(null), ct));
} }
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs }; return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
} }
/// <inheritdoc />
public async Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct) public async Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct)
{ {
if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required."); if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required.");
@@ -107,15 +98,10 @@ public sealed class CvMatcherService : ICvMatcherService
.FirstOrDefault(x => x.DocumentId == job.DocumentId)? .FirstOrDefault(x => x.DocumentId == job.DocumentId)?
.MatchedChunks.Select(x => x.Text).ToArray() ?? []; .MatchedChunks.Select(x => x.Text).ToArray() ?? [];
return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, request.ClientIpAddress, NormalizeLanguage(request.Language), ct); return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), ct);
} }
/// <summary> private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, string language, CancellationToken ct)
/// Scores a (CV, job) pair with the LLM.
/// 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.
/// </summary>
private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, string? clientIpAddress, string language, CancellationToken ct)
{ {
var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct); var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct);
if (cached is not null) return cached; if (cached is not null) return cached;
@@ -123,11 +109,14 @@ public sealed class CvMatcherService : ICvMatcherService
var cvText = Limit(cv.Text, 18000); var cvText = Limit(cv.Text, 18000);
var jobText = Limit(job.Text, 14000); var jobText = Limit(job.Text, 14000);
var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000);
var languageName = LanguageName(language);
var systemPrompt = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", language, ct) var systemPrompt = $$"""
?? throw new InvalidOperationException( You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.
$"AI prompt not found: key='ai.cv-match.system-prompt', language='{language}'. " + Penalize missing required skills. Do not invent experience. Use concise business language.
$"This is a configuration error. Ensure the cvMatcher.AiPrompts table is properly seeded with language-specific prompts."); Respond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.
JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]}
""";
var userPrompt = $""" var userPrompt = $"""
CV: CV:
@@ -145,14 +134,17 @@ public sealed class CvMatcherService : ICvMatcherService
result.JobDocumentId = job.Id; result.JobDocumentId = job.Id;
result.JobUrl = job.SourceUrl; result.JobUrl = job.SourceUrl;
result.Cached = false; result.Cached = false;
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, email, clientIpAddress, ct); await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct);
//await _email.SendMatchAsync(
// email,
// $"MyAi.ro CV Match: {result.Score}% - {job.Title}",
// BuildEmailBody(cv, job, result),
// ct);
return result; return result;
} }
/// <summary>
/// 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)
{ {
try try
@@ -173,28 +165,48 @@ public sealed class CvMatcherService : ICvMatcherService
}; };
} }
/// <summary>
/// Builds a descriptive search query from the CV text for use in vector similarity search.
/// </summary>
private static string BuildCvSearchProfile(string cvText) private static string BuildCvSearchProfile(string cvText)
{ {
var text = Limit(cvText, 10000); var text = Limit(cvText, 10000);
return $"Candidate profile, skills, technologies, seniority, industry experience, project experience: {text}"; return $"Candidate profile, skills, technologies, seniority, industry experience, project experience: {text}";
} }
/// <summary>
/// Extracts a short job title from the first sentence-like fragment of the job text.
/// </summary>
private static string ExtractJobTitle(string jobText) private static string ExtractJobTitle(string jobText)
{ {
var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140); var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140);
return first ?? "Job description"; return first ?? "Job description";
} }
/// <summary>Returns the base language code, lower-cased, defaulting to <c>"en"</c>.</summary>
private static string NormalizeLanguage(string? language) => private static string NormalizeLanguage(string? language) =>
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
/// <summary>Truncates <paramref name="value"/> to at most <paramref name="max"/> characters.</summary> private static string LanguageName(string language) => language switch
{
"ro" => "Romanian",
"en" => "English",
_ => "English"
};
private static string Limit(string value, int max) => value.Length <= max ? value : value[..max]; private static string Limit(string value, int max) => value.Length <= max ? value : value[..max];
//private static string BuildEmailBody(RagDocumentDetails cv, RagDocumentDetails job, JobMatchResponse result) => $"""
// CV Matcher result
// CV: {cv.Title}
// Job: {job.Title}
// Job URL: {job.SourceUrl ?? "N/A"}
// Score: {result.Score}%
// Summary:
// {result.Summary}
// Strengths:
// - {string.Join("\n- ", result.Strengths)}
// Gaps:
// - {string.Join("\n- ", result.Gaps)}
// Recommendations:
// - {string.Join("\n- ", result.Recommendations)}
// """;
} }
@@ -1,26 +1,24 @@
using System.Net;
using System.Text.RegularExpressions;
using CvMatcher.Models.Settings; using CvMatcher.Models.Settings;
using Api.Services.Contracts; using Api.Services.Contracts;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PageFetcher.Models;
namespace Api.Services; namespace Api.Services;
/// <summary>
/// Extracts normalised plain text from a job posting, either from a pasted description or by
/// fetching the job page text via <c>page-fetcher-api</c> (headless Chromium rendering).
/// </summary>
public sealed class JobTextExtractor : IJobTextExtractor public sealed class JobTextExtractor : IJobTextExtractor
{ {
private readonly IPageFetcherApiClient _pageFetcher; private readonly HttpClient _http;
private readonly MatcherSettings _settings; private readonly MatcherSettings _settings;
public JobTextExtractor(IPageFetcherApiClient pageFetcher, IOptions<MatcherSettings> options) public JobTextExtractor(HttpClient http, IOptions<MatcherSettings> options)
{ {
_pageFetcher = pageFetcher; _http = http;
_settings = options.Value; _settings = options.Value;
_http.Timeout = TimeSpan.FromSeconds(25);
_http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0");
} }
/// <inheritdoc />
public async Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct) public async Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct)
{ {
var pasted = Normalize(jobDescription ?? string.Empty); var pasted = Normalize(jobDescription ?? string.Empty);
@@ -28,28 +26,23 @@ public sealed class JobTextExtractor : IJobTextExtractor
if (string.IsNullOrWhiteSpace(jobUrl)) return string.Empty; if (string.IsNullOrWhiteSpace(jobUrl)) return string.Empty;
if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
throw new InvalidOperationException("Invalid job URL.");
var response = await _pageFetcher.FetchAsync(new FetchPageRequest
{ {
Url = jobUrl, throw new InvalidOperationException("Invalid job URL.");
CallerService = "cv-matcher-api" }
}, ct);
var html = await _http.GetStringAsync(uri, ct);
if (!response.Success) html = Regex.Replace(html, "<script[\\s\\S]*?</script>", " ", RegexOptions.IgnoreCase);
throw new InvalidOperationException($"Failed to fetch job page: {response.Error}"); html = Regex.Replace(html, "<style[\\s\\S]*?</style>", " ", RegexOptions.IgnoreCase);
html = Regex.Replace(html, "<[^>]+>", " ");
return Limit(Normalize(response.Text)); return Limit(Normalize(WebUtility.HtmlDecode(html)));
} }
/// <summary>Truncates text to the configured maximum character count.</summary>
private string Limit(string value) private string Limit(string value)
{ {
var max = Math.Max(4000, _settings.MaxJobTextChars); var max = Math.Max(4000, _settings.MaxJobTextChars);
return value.Length <= max ? value : value[..max]; return value.Length <= max ? value : value[..max];
} }
/// <summary>Collapses all whitespace runs to single spaces and trims the result.</summary>
private static string Normalize(string value) private static string Normalize(string value)
{ {
if (string.IsNullOrWhiteSpace(value)) return string.Empty; if (string.IsNullOrWhiteSpace(value)) return string.Empty;
+32 -62
View File
@@ -1,57 +1,42 @@
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using Api.Clients.Api.Contracts;
using Api.Services.Contracts; using Api.Services.Contracts;
using CvMatcher.Models.Responses; using CvMatcher.Models.Responses;
using CvSearch.Data; using CvSearch.Models.Data;
using CvSearch.Data.Entities; using CvSearch.Models.Data.Entities;
using CvMatcher.Models.Settings; using CvSearch.Models.Settings;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Api.Services; namespace Api.Services;
/// <summary>
/// Creates and validates one-time job search tokens, and creates the corresponding search sessions.
/// Provider configuration is read from <c>cvSearch.JobProviders</c> at session-creation time and
/// snapshotted into <c>JobSearchSessionEntity.ProviderConfigJson</c> so subsequent config changes
/// do not affect already-queued sessions.
/// Keywords are extracted by the LLM during the CV-to-job match call and stored on the token,
/// then copied to the session when the user clicks the link — no extra RAG call needed.
/// </summary>
public sealed class JobTokenService : IJobTokenService public sealed class JobTokenService : IJobTokenService
{ {
private readonly CvSearchDbContext _db; private readonly CvSearchDbContext _db;
private readonly IRagApiClient _rag;
private readonly JobSearchSettings _settings; private readonly JobSearchSettings _settings;
private readonly ILogger<JobTokenService> _logger; private readonly ILogger<JobTokenService> _logger;
public JobTokenService( public JobTokenService(
CvSearchDbContext db, CvSearchDbContext db,
IRagApiClient rag,
IOptions<JobSearchSettings> settings, IOptions<JobSearchSettings> settings,
ILogger<JobTokenService> logger) ILogger<JobTokenService> logger)
{ {
_db = db; _db = db;
_rag = rag;
_settings = settings.Value; _settings = settings.Value;
_logger = logger; _logger = logger;
} }
/// <inheritdoc /> public async Task<string> CreateTokenAsync(string cvDocumentId, string email, 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)
{
_logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders");
return null;
}
var token = new JobSearchTokenEntity var token = new JobSearchTokenEntity
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"),
CvDocumentId = cvDocumentId, CvDocumentId = cvDocumentId,
Email = email, Email = email,
Language = language,
Keywords = string.Join(",", keywords),
Location = location,
ClientIpAddress = clientIpAddress,
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
Used = false, Used = false,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
@@ -59,12 +44,11 @@ public sealed class JobTokenService : IJobTokenService
_db.JobSearchTokens.Add(token); _db.JobSearchTokens.Add(token);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}, Location={Location}", token.Id, cvDocumentId, token.Keywords, token.Location); _logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}", token.Id, cvDocumentId);
return token.Id; return token.Id;
} }
/// <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); var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct);
if (token is null) return StartJobSearchStatus.NotFound; if (token is null) return StartJobSearchStatus.NotFound;
@@ -74,15 +58,11 @@ public sealed class JobTokenService : IJobTokenService
token.Used = true; token.Used = true;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
var keywords = token.Keywords; var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct);
var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty;
var enabledProviders = await _db.JobProviders
.Where(p => p.Enabled)
.OrderBy(p => p.DisplayOrder)
.ToListAsync(ct);
var providerConfigJson = JsonSerializer.Serialize( var providerConfigJson = JsonSerializer.Serialize(
enabledProviders.Select(ToConfig).ToList(), _settings.Providers.Where(p => p.Enabled).ToList(),
new JsonSerializerOptions(JsonSerializerDefaults.Web)); new JsonSerializerOptions(JsonSerializerDefaults.Web));
var session = new JobSearchSessionEntity var session = new JobSearchSessionEntity
@@ -91,47 +71,37 @@ public sealed class JobTokenService : IJobTokenService
TokenId = token.Id, TokenId = token.Id,
CvDocumentId = token.CvDocumentId, CvDocumentId = token.CvDocumentId,
Email = token.Email, Email = token.Email,
Language = token.Language,
Status = JobSearchStatus.Pending, Status = JobSearchStatus.Pending,
Keywords = keywords, Keywords = keywords,
Location = token.Location,
ClientIpAddress = clientIpAddress,
ProviderConfigJson = providerConfigJson, ProviderConfigJson = providerConfigJson,
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}; };
_db.JobSearchSessions.Add(session); _db.JobSearchSessions.Add(session);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
_logger.LogInformation( _logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords);
"Job search session created. SessionId={SessionId}, Keywords={Keywords}, Providers={Providers}",
session.Id, keywords, string.Join(", ", enabledProviders.Select(p => p.Name)));
return StartJobSearchStatus.Started; return StartJobSearchStatus.Started;
} }
private static JobProviderConfig ToConfig(JobProviderEntity entity) private static string ExtractKeywords(string cvText)
{ {
List<string> keywords; var lines = cvText
try .Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
{ .Select(l => l.Trim())
keywords = JsonSerializer.Deserialize<List<string>>(entity.InitialKeywordsJson, .Where(l => l.Length > 5 && l.Length < 200)
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? []; .Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
} .Take(5)
catch .ToList();
{
keywords = [];
}
return new JobProviderConfig var words = lines
{ .SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries))
Name = entity.Name, .Select(w => Regex.Replace(w, @"[^\w\-]", ""))
Enabled = entity.Enabled, .Where(w => w.Length > 2)
SearchUrlTemplate = entity.SearchUrlTemplate, .Distinct(StringComparer.OrdinalIgnoreCase)
JobLinkContains = entity.JobLinkContains, .Take(10)
InitialKeywords = keywords, .ToList();
MaxResults = entity.MaxResults,
RequireKeywordInAnchor = entity.RequireKeywordInAnchor
};
}
return string.Join(",", words);
}
} }
+48 -2
View File
@@ -2,7 +2,8 @@
"Serilog": { "Serilog": {
"Using": [ "Using": [
"Serilog.Sinks.Console", "Serilog.Sinks.Console",
"Serilog.Sinks.File" "Serilog.Sinks.File",
"Serilog.Sinks.Email"
], ],
"MinimumLevel": { "MinimumLevel": {
"Default": "Information", "Default": "Information",
@@ -29,6 +30,25 @@
"retainedFileCountLimit": 30, "retainedFileCountLimit": 30,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
} }
},
{
"Name": "Email",
"Args": {
"restrictedToMinimumLevel": "Error",
"fromEmail": "",
"toEmail": "",
"mailServer": "",
"networkCredential": {
"userName": "",
"password": ""
},
"port": 587,
"enableSsl": true,
"emailSubject": "[mihes.ro API] Error Alert",
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
"batchPostingLimit": 10,
"period": "0.00:05:00"
}
} }
], ],
"Enrich": [ "Enrich": [
@@ -92,6 +112,32 @@
"JobSearchLinkBaseUrl": "https://myai.ro", "JobSearchLinkBaseUrl": "https://myai.ro",
"TokenExpiryDays": 7, "TokenExpiryDays": 7,
"MinMatchScore": 15, "MinMatchScore": 15,
"MaxJobsToMatch": 15 "MaxJobsToMatch": 15,
"Providers": [
{
"Name": "ejobs.ro",
"Enabled": false,
"SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/",
"JobLinkContains": "/user/locuri-de-munca/job/",
"InitialKeywords": [],
"MaxResults": 20
},
{
"Name": "bestjobs.eu",
"Enabled": false,
"SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}",
"JobLinkContains": "/ro/locuri-de-munca/",
"InitialKeywords": [],
"MaxResults": 20
},
{
"Name": "linkedin.com",
"Enabled": false,
"SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania",
"JobLinkContains": "/jobs/view/",
"InitialKeywords": [],
"MaxResults": 20
}
]
} }
} }
+16 -18
View File
@@ -58,31 +58,29 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" /> <PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
<PackageReference Include="Azure.Identity" /> <PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="DotNetEnv" /> <PackageReference Include="DotNetEnv" Version="3.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MailKit" /> <PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Serilog.AspNetCore" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" /> <PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Console" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.Email" /> <PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" /> <PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" />
<PackageReference Include="Refit.HttpClientFactory" /> <PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" /> <ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" /> <ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
<ProjectReference Include="..\cv-search-data\cv-search-data.csproj" /> <ProjectReference Include="..\cv-search-models\cv-search-models.csproj" />
<ProjectReference Include="..\cv-matcher-data\cv-matcher-data.csproj" /> <ProjectReference Include="..\shared-models\shared-models.csproj" />
<ProjectReference Include="..\common\common.csproj" />
<ProjectReference Include="..\page-fetcher-api-models\page-fetcher-api-models.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" /> <ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,10 +0,0 @@
namespace CvMatcher.Data.Entities;
public sealed class AiPromptEntity
{
public string Key { get; set; } = string.Empty;
public string Language { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; }
}
@@ -1,11 +0,0 @@
namespace CvMatcher.Data;
/// <summary>
/// Schema constants used by CvMatcherDbContext and migrations.
/// Centralized to avoid hardcoded strings and ensure consistency.
/// </summary>
public static class MigrationConstants
{
public const string SchemaName = "cvMatcher";
public const string MigrationTableName = "_Migrations";
}
@@ -1,110 +0,0 @@
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class InitialSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: MigrationConstants.SchemaName);
migrationBuilder.CreateTable(
name: "AiPrompts",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language });
});
migrationBuilder.CreateTable(
name: "ChatCache",
schema: MigrationConstants.SchemaName,
columns: table => new
{
CacheKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Model = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
Temperature = table.Column<decimal>(type: "decimal(4,2)", nullable: false),
ResponseText = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ChatCache", x => x.CacheKey);
});
migrationBuilder.CreateTable(
name: "Results",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
JobDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Language = table.Column<string>(type: "nvarchar(450)", nullable: false),
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Score = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_Results", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Results_CvDocumentId_JobDocumentId_Language",
schema: MigrationConstants.SchemaName,
table: "Results",
columns: new[] { "CvDocumentId", "JobDocumentId", "Language" },
unique: true);
Seed(migrationBuilder);
}
private static void Seed(MigrationBuilder m)
{
void Row(string key, string lang, string value, string description = "")
=> m.InsertData("AiPrompts", ["Key", "Language", "Value", "Description"], [key, lang, value, description], MigrationConstants.SchemaName);
// AI system prompt for CV matching — English
Row("ai.cv-match.system-prompt", "en",
"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.\nJSON 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\":[\"keyword1\",\"keyword2\",\"keyword3\"]}",
"System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job.");
// AI system prompt for CV matching — Romanian
Row("ai.cv-match.system-prompt", "ro",
"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ă.\nJSON 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\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}",
"System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AiPrompts",
schema: MigrationConstants.SchemaName);
migrationBuilder.DropTable(
name: "ChatCache",
schema: MigrationConstants.SchemaName);
migrationBuilder.DropTable(
name: "Results",
schema: MigrationConstants.SchemaName);
}
}
}
@@ -1,65 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class ImproveKeywordsAndAddLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Update English prompt: tighter keywords instruction (job-board search terms, not abstract
// concepts) and add location field so the LLM extracts the candidate's city/country.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
"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.\nJSON 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\"}.\nFor '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'.\nFor 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.",
"System prompt for CV-to-job matching in English. Extracts job-board-friendly keywords (role title + key tech) and candidate location."
]);
// Update Romanian prompt: same improvements.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
"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ă.\nJSON 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ă\"}.\nPentru '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'.\nPentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.",
"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."
]);
}
/// <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: [
"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.\nJSON 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\":[\"keyword1\",\"keyword2\",\"keyword3\"]}",
"System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job."
]);
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
"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ă.\nJSON 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\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}",
"System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job."
]);
}
}
}
@@ -1,138 +0,0 @@
// <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("20260608155310_AddEmailAndIpToResults")]
partial class AddEmailAndIpToResults
{
/// <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
}
}
}
@@ -1,45 +0,0 @@
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class AddEmailAndIpToResults : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "Results",
type: "nvarchar(45)",
maxLength: 45,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Email",
schema: MigrationConstants.SchemaName,
table: "Results",
type: "nvarchar(256)",
maxLength: 256,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ClientIpAddress",
schema: MigrationConstants.SchemaName,
table: "Results");
migrationBuilder.DropColumn(
name: "Email",
schema: MigrationConstants.SchemaName,
table: "Results");
}
}
}
@@ -1,6 +0,0 @@
namespace CvMatcher.Data.Repositories.Contracts;
public interface IAiPromptsRepository
{
Task<string?> GetAsync(string key, string language, CancellationToken ct);
}
@@ -1,24 +0,0 @@
using CvMatcher.Data;
using CvMatcher.Data.Repositories.Contracts;
using Microsoft.EntityFrameworkCore;
namespace CvMatcher.Data.Repositories;
public sealed class EfAiPromptsRepository : IAiPromptsRepository
{
private readonly CvMatcherDbContext _db;
public EfAiPromptsRepository(CvMatcherDbContext db)
{
_db = db;
}
public async Task<string?> GetAsync(string key, string language, CancellationToken ct)
{
return await _db.AiPrompts
.AsNoTracking()
.Where(x => x.Key == key && x.Language == language)
.Select(x => x.Value)
.FirstOrDefaultAsync(ct);
}
}
@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>cv-matcher-data</AssemblyName>
<RootNamespace>CvMatcher.Data</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\shared-data\shared-data.csproj" />
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
</ItemGroup>
</Project>
@@ -1,39 +0,0 @@
namespace CvSearch.Data.Entities;
/// <summary>
/// Persisted job-board provider configuration. Stored in <c>cvSearch.JobProviders</c>.
/// Providers are loaded from here at session-creation time and snapshotted into
/// <c>JobSearchSessionEntity.ProviderConfigJson</c> so runtime config changes do not
/// affect already-queued sessions.
/// </summary>
public sealed class JobProviderEntity
{
public int Id { get; set; }
/// <summary>Display name (e.g. "ejobs.ro").</summary>
public string Name { get; set; } = string.Empty;
/// <summary>When false the provider is skipped at session-creation and the job-search link is hidden.</summary>
public bool Enabled { get; set; }
/// <summary>URL template with <c>{keywords}</c> placeholder (URL-encoded keywords are substituted at runtime).</summary>
public string SearchUrlTemplate { get; set; } = string.Empty;
/// <summary>Substring that must appear in an anchor href to pass the stage-1 link filter.</summary>
public string JobLinkContains { get; set; } = string.Empty;
/// <summary>JSON array of baseline keywords merged with CV keywords before building the search URL.</summary>
public string InitialKeywordsJson { get; set; } = "[]";
/// <summary>Maximum number of job URLs to collect from this provider per session.</summary>
public int MaxResults { get; set; } = 20;
/// <summary>Controls display ordering in future admin UIs.</summary>
public int DisplayOrder { get; set; }
/// <summary>
/// When false, the Stage 2 anchor-text keyword filter is skipped.
/// Set to false for providers whose search URL already filters by relevance server-side (ejobs.ro, bestjobs.eu).
/// </summary>
public bool RequireKeywordInAnchor { get; set; } = true;
}
@@ -1,18 +0,0 @@
using Shared.Data.Entities;
namespace CvSearch.Data.Entities;
public sealed class JobSearchResultEntity : BaseEntity
{
public string SessionId { get; set; } = string.Empty;
public string ProviderName { get; set; } = string.Empty;
public string JobUrl { get; set; } = string.Empty;
public string JobTitle { get; set; } = string.Empty;
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; }
}
@@ -1,16 +0,0 @@
using Shared.Data.Entities;
namespace CvSearch.Data.Entities;
public sealed class JobSearchTokenEntity : BaseEntity
{
public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Language { get; set; } = "en";
public DateTime ExpiresAt { get; set; }
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; }
}
@@ -1,11 +0,0 @@
namespace CvSearch.Data;
/// <summary>
/// Schema constants used by CvSearchDbContext and migrations.
/// Centralized to avoid hardcoded strings and ensure consistency.
/// </summary>
public static class MigrationConstants
{
public const string SchemaName = "cvSearch";
public const string MigrationTableName = "_Migrations";
}
@@ -1,47 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddLanguageToJobSearchEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
migrationBuilder.AddColumn<string>(
name: "Language",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens");
migrationBuilder.DropColumn(
name: "Language",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions");
}
}
}
@@ -1,222 +0,0 @@
// <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("20260529084440_AddJobProviders")]
partial class AddJobProviders
{
/// <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<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<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
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<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>("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>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,55 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddJobProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "JobProviders",
schema: MigrationConstants.SchemaName,
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
Enabled = table.Column<bool>(type: "bit", nullable: false),
SearchUrlTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
JobLinkContains = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
InitialKeywordsJson = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"),
MaxResults = table.Column<int>(type: "int", nullable: false, defaultValue: 20),
DisplayOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0)
},
constraints: table =>
{
table.PrimaryKey("PK_JobProviders", x => x.Id);
});
// Seed the three default providers — all disabled so the feature is opt-in per environment.
// Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI.
migrationBuilder.InsertData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"],
values: new object[,]
{
{ "ejobs.ro", false, "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", "[]", 20, 0 },
{ "bestjobs.eu", false, "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}", "/ro/locuri-de-munca/", "[]", 20, 1 },
{ "linkedin.com", false, "https://www.linkedin.com/jobs/search/?keywords={keywords}", "/jobs/view/", "[]", 20, 2 },
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JobProviders",
schema: MigrationConstants.SchemaName);
}
}
}
@@ -1,229 +0,0 @@
// <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("20260529130000_AddKeywordsToJobSearchTokens")]
partial class AddKeywordsToJobSearchTokens
{
/// <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<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<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
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<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>("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<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,33 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddKeywordsToJobSearchTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Keywords",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens",
type: "nvarchar(1000)",
maxLength: 1000,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Keywords",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens");
}
}
}
@@ -1,229 +0,0 @@
// <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("20260529160000_FixBestJobsLinkFilter")]
partial class FixBestJobsLinkFilter
{
/// <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<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<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
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<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>("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<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,37 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class FixBestJobsLinkFilter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// bestjobs.eu individual job listings use /loc-de-munca/{slug}.
// The original seed value /ro/locuri-de-munca/ matched only category nav links,
// so zero job URLs passed the stage-1 filter.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "JobLinkContains",
value: "/loc-de-munca/");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "JobLinkContains",
value: "/ro/locuri-de-munca/");
}
}
}
@@ -1,234 +0,0 @@
// <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("20260529170000_AddHeadlessBrowserToProviders")]
partial class AddHeadlessBrowserToProviders
{
/// <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<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<bool>("UseHeadlessBrowser")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
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<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
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<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>("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<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -1,50 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddHeadlessBrowserToProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders",
type: "bit",
nullable: false,
defaultValue: false);
// ejobs.ro (Id=1) is a Nuxt SPA — the old /user/ URL 404s and plain HTTP GET
// returns only the JS bundle, not actual job listings.
// Fix: use the correct search URL and headless Chromium to render job results.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"],
values: new object[] { "https://www.ejobs.ro/locuri-de-munca?q={keywords}", "/locuri-de-munca/", true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"],
values: new object[] { "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", false });
migrationBuilder.DropColumn(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders");
}
}
}
@@ -1,243 +0,0 @@
// <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("20260608124304_AddRequireKeywordInAnchorAndLocation")]
partial class AddRequireKeywordInAnchorAndLocation
{
/// <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.Property<bool>("UseHeadlessBrowser")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
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<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
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<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
}
}
}
@@ -1,74 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddRequireKeywordInAnchorAndLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Location",
schema: "cvSearch",
table: "JobSearchTokens",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Location",
schema: "cvSearch",
table: "JobSearchSessions",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "RequireKeywordInAnchor",
schema: "cvSearch",
table: "JobProviders",
type: "bit",
nullable: false,
defaultValue: true);
// ejobs.ro (Id=1) and bestjobs.eu (Id=2) do server-side keyword filtering via their
// search URL — the Stage 2 anchor-text filter rejects all Romanian job titles because
// they rarely contain abstract LLM keywords.
migrationBuilder.UpdateData(
schema: "cvSearch",
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
column: "RequireKeywordInAnchor",
value: false);
migrationBuilder.UpdateData(
schema: "cvSearch",
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "RequireKeywordInAnchor",
value: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Location",
schema: "cvSearch",
table: "JobSearchTokens");
migrationBuilder.DropColumn(
name: "Location",
schema: "cvSearch",
table: "JobSearchSessions");
migrationBuilder.DropColumn(
name: "RequireKeywordInAnchor",
schema: "cvSearch",
table: "JobProviders");
}
}
}
@@ -1,243 +0,0 @@
// <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("20260608124452_AddLocationToProviders")]
partial class AddLocationToProviders
{
/// <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.Property<bool>("UseHeadlessBrowser")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
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<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
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<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
}
}
}
@@ -1,71 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddLocationToProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ejobs.ro (Id=1): location in URL path as slug, keywords via q= param.
// Verified URL structure: /locuri-de-munca/{location-slug}?q={keywords}
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
column: "SearchUrlTemplate",
value: "https://www.ejobs.ro/locuri-de-munca/{location-slug}?q={keywords}");
// bestjobs.eu (Id=2): location in URL path as slug, keywords via query param.
// Verified URL structure: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords}
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "SearchUrlTemplate",
value: "https://bestjobs.eu/ro/locuri-de-munca-in-{location-slug}?keywords={keywords}");
// linkedin.com (Id=3): location as query parameter.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 3,
column: "SearchUrlTemplate",
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}&location={location}");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
column: "SearchUrlTemplate",
value: "https://www.ejobs.ro/locuri-de-munca?q={keywords}");
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "SearchUrlTemplate",
value: "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}");
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 3,
column: "SearchUrlTemplate",
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}");
}
}
}
@@ -1,238 +0,0 @@
// <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("20260608154221_RemoveUseHeadlessBrowser")]
partial class RemoveUseHeadlessBrowser
{
/// <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<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
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<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
}
}
}
@@ -1,32 +0,0 @@
using CvSearch.Data;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class RemoveUseHeadlessBrowser : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "UseHeadlessBrowser",
schema: MigrationConstants.SchemaName,
table: "JobProviders",
type: "bit",
nullable: false,
defaultValue: false);
}
}
}
@@ -1,250 +0,0 @@
// <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
}
}
}
@@ -1,58 +0,0 @@
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");
}
}
}
@@ -1,254 +0,0 @@
// <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
}
}
}
@@ -1,32 +0,0 @@
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");
}
}
}
@@ -1,251 +0,0 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
partial class CvSearchDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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
}
}
}
-23
View File
@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AssemblyName>cv-search-data</AssemblyName>
<RootNamespace>CvSearch.Data</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\shared-data\shared-data.csproj" />
</ItemGroup>
</Project>
@@ -1,26 +1,18 @@
using CvSearch.Data.Entities; using CvSearch.Models.Data.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace CvSearch.Data; namespace CvSearch.Models.Data;
public sealed class CvSearchDbContext : DbContext public sealed class CvSearchDbContext : DbContext
{ {
public const string SchemaName = MigrationConstants.SchemaName; public const string SchemaName = "cvSearch";
public const string MigrationTableName = MigrationConstants.MigrationTableName; public const string MigrationTableName = "_Migrations";
public CvSearchDbContext(DbContextOptions<CvSearchDbContext> options) : base(options) { } public CvSearchDbContext(DbContextOptions<CvSearchDbContext> options) : base(options) { }
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>(); public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>(); public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>(); public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
public DbSet<JobProviderEntity> JobProviders => Set<JobProviderEntity>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
// Configure migration history table to use schema-qualified name: [cvSearch].[_Migrations]
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
}
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -33,10 +25,7 @@ public sealed class CvSearchDbContext : DbContext
entity.Property(x => x.Id).HasMaxLength(64); entity.Property(x => x.Id).HasMaxLength(64);
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
entity.Property(x => x.Email).HasMaxLength(256).IsRequired(); entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
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.Used).HasDefaultValue(false);
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
}); });
@@ -51,8 +40,6 @@ public sealed class CvSearchDbContext : DbContext
entity.Property(x => x.Status).HasMaxLength(32).IsRequired(); entity.Property(x => x.Status).HasMaxLength(32).IsRequired();
entity.Property(x => x.Keywords).HasMaxLength(1000); entity.Property(x => x.Keywords).HasMaxLength(1000);
entity.Property(x => x.ProviderConfigJson).IsRequired(false); 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.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.Status); entity.HasIndex(x => x.Status);
}); });
@@ -66,23 +53,8 @@ public sealed class CvSearchDbContext : DbContext
entity.Property(x => x.ProviderName).HasMaxLength(128); entity.Property(x => x.ProviderName).HasMaxLength(128);
entity.Property(x => x.JobUrl).HasMaxLength(2048); entity.Property(x => x.JobUrl).HasMaxLength(2048);
entity.Property(x => x.JobTitle).HasMaxLength(512); 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.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
entity.HasIndex(x => x.SessionId); entity.HasIndex(x => x.SessionId);
}); });
modelBuilder.Entity<JobProviderEntity>(entity =>
{
entity.ToTable("JobProviders");
entity.HasKey(x => x.Id);
entity.Property(x => x.Id).UseIdentityColumn();
entity.Property(x => x.Name).HasMaxLength(128).IsRequired();
entity.Property(x => x.SearchUrlTemplate).HasMaxLength(1024).IsRequired();
entity.Property(x => x.JobLinkContains).HasMaxLength(256).IsRequired();
entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired();
entity.Property(x => x.MaxResults).HasDefaultValue(20);
entity.Property(x => x.DisplayOrder).HasDefaultValue(0);
});
} }
} }
@@ -0,0 +1,14 @@
namespace CvSearch.Models.Data.Entities;
public sealed class JobSearchResultEntity
{
public string Id { get; set; } = string.Empty;
public string SessionId { get; set; } = string.Empty;
public string ProviderName { get; set; } = string.Empty;
public string JobUrl { get; set; } = string.Empty;
public string JobTitle { get; set; } = string.Empty;
public string JobText { get; set; } = string.Empty;
public int Score { get; set; }
public string ResultJson { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -1,19 +1,15 @@
using Shared.Data.Entities; namespace CvSearch.Models.Data.Entities;
namespace CvSearch.Data.Entities; public sealed class JobSearchSessionEntity
public sealed class JobSearchSessionEntity : BaseEntity
{ {
public string Id { get; set; } = string.Empty;
public string TokenId { get; set; } = string.Empty; public string TokenId { get; set; } = string.Empty;
public string CvDocumentId { get; set; } = string.Empty; public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
public string Status { get; set; } = JobSearchStatus.Pending; public string Status { get; set; } = JobSearchStatus.Pending;
public string Keywords { get; set; } = string.Empty; 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? ProviderConfigJson { get; set; }
public string Language { get; set; } = "en"; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
public static class JobSearchStatus public static class JobSearchStatus
@@ -0,0 +1,11 @@
namespace CvSearch.Models.Data.Entities;
public sealed class JobSearchTokenEntity
{
public string Id { get; set; } = string.Empty;
public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public DateTime ExpiresAt { get; set; }
public bool Used { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -1,6 +1,6 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvSearch.Data; using CvSearch.Models.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace CvSearch.Data.Migrations namespace CvSearch.Models.Migrations
{ {
[DbContext(typeof(CvSearchDbContext))] [DbContext(typeof(CvSearchDbContext))]
[Migration("20260522093356_AddJobSearchTables")] [Migration("20260522093356_AddJobSearchTables")]
@@ -26,7 +26,7 @@ namespace CvSearch.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -75,7 +75,7 @@ namespace CvSearch.Data.Migrations
b.ToTable("JobSearchResults", "cvSearch"); b.ToTable("JobSearchResults", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -121,7 +121,7 @@ namespace CvSearch.Data.Migrations
b.ToTable("JobSearchSessions", "cvSearch"); b.ToTable("JobSearchSessions", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -1,10 +1,9 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable #nullable disable
namespace CvSearch.Data.Migrations namespace CvSearch.Models.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
public partial class AddJobSearchTables : Migration public partial class AddJobSearchTables : Migration
@@ -13,11 +12,11 @@ namespace CvSearch.Data.Migrations
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.EnsureSchema( migrationBuilder.EnsureSchema(
name: MigrationConstants.SchemaName); name: "cvSearch");
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "JobSearchResults", name: "JobSearchResults",
schema: MigrationConstants.SchemaName, schema: "cvSearch",
columns: table => new columns: table => new
{ {
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false), Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
@@ -37,7 +36,7 @@ namespace CvSearch.Data.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "JobSearchSessions", name: "JobSearchSessions",
schema: MigrationConstants.SchemaName, schema: "cvSearch",
columns: table => new columns: table => new
{ {
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false), Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
@@ -56,7 +55,7 @@ namespace CvSearch.Data.Migrations
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "JobSearchTokens", name: "JobSearchTokens",
schema: MigrationConstants.SchemaName, schema: "cvSearch",
columns: table => new columns: table => new
{ {
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false), Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
@@ -73,13 +72,13 @@ namespace CvSearch.Data.Migrations
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_JobSearchResults_SessionId", name: "IX_JobSearchResults_SessionId",
schema: MigrationConstants.SchemaName, schema: "cvSearch",
table: "JobSearchResults", table: "JobSearchResults",
column: "SessionId"); column: "SessionId");
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_JobSearchSessions_Status", name: "IX_JobSearchSessions_Status",
schema: MigrationConstants.SchemaName, schema: "cvSearch",
table: "JobSearchSessions", table: "JobSearchSessions",
column: "Status"); column: "Status");
} }
@@ -89,15 +88,15 @@ namespace CvSearch.Data.Migrations
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "JobSearchResults", name: "JobSearchResults",
schema: MigrationConstants.SchemaName); schema: "cvSearch");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "JobSearchSessions", name: "JobSearchSessions",
schema: MigrationConstants.SchemaName); schema: "cvSearch");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "JobSearchTokens", name: "JobSearchTokens",
schema: MigrationConstants.SchemaName); schema: "cvSearch");
} }
} }
} }
@@ -1,22 +1,19 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using CvSearch.Data; using CvSearch.Models.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable #nullable disable
namespace CvSearch.Data.Migrations namespace CvSearch.Models.Migrations
{ {
[DbContext(typeof(CvSearchDbContext))] [DbContext(typeof(CvSearchDbContext))]
[Migration("20260524145702_AddLanguageToJobSearchEntities")] partial class CvSearchDbContextModelSnapshot : ModelSnapshot
partial class AddLanguageToJobSearchEntities
{ {
/// <inheritdoc /> protected override void BuildModel(ModelBuilder modelBuilder)
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
@@ -26,7 +23,7 @@ namespace CvSearch.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -75,7 +72,7 @@ namespace CvSearch.Data.Migrations
b.ToTable("JobSearchResults", "cvSearch"); b.ToTable("JobSearchResults", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -101,13 +98,6 @@ namespace CvSearch.Data.Migrations
.HasMaxLength(1000) .HasMaxLength(1000)
.HasColumnType("nvarchar(1000)"); .HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("ProviderConfigJson") b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -128,7 +118,7 @@ namespace CvSearch.Data.Migrations
b.ToTable("JobSearchSessions", "cvSearch"); b.ToTable("JobSearchSessions", "cvSearch");
}); });
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
.HasMaxLength(64) .HasMaxLength(64)
@@ -152,13 +142,6 @@ namespace CvSearch.Data.Migrations
b.Property<DateTime>("ExpiresAt") b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<bool>("Used") b.Property<bool>("Used")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("bit") .HasColumnType("bit")
@@ -1,4 +1,4 @@
namespace CvMatcher.Models.Settings; namespace CvSearch.Models.Settings;
public sealed class JobSearchSettings public sealed class JobSearchSettings
{ {
@@ -7,12 +7,9 @@ public sealed class JobSearchSettings
public int TokenExpiryDays { get; set; } = 7; public int TokenExpiryDays { get; set; } = 7;
public int MinMatchScore { get; set; } = 15; public int MinMatchScore { get; set; } = 15;
public int MaxJobsToMatch { get; set; } = 15; public int MaxJobsToMatch { get; set; } = 15;
public List<JobProviderConfig> Providers { get; set; } = [];
} }
/// <summary>
/// Runtime DTO for a job provider. Populated from <c>cvSearch.JobProviders</c> at session-creation
/// time and snapshotted to <c>JobSearchSessionEntity.ProviderConfigJson</c>.
/// </summary>
public sealed class JobProviderConfig public sealed class JobProviderConfig
{ {
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;
@@ -21,9 +18,4 @@ public sealed class JobProviderConfig
public string JobLinkContains { get; set; } = string.Empty; public string JobLinkContains { get; set; } = string.Empty;
public List<string> InitialKeywords { get; set; } = []; public List<string> InitialKeywords { get; set; } = [];
public int MaxResults { get; set; } = 20; public int MaxResults { get; set; } = 20;
/// <summary>
/// When false, the Stage 2 anchor-text keyword filter is skipped.
/// Set to false for providers whose search URL already filters by relevance server-side.
/// </summary>
public bool RequireKeywordInAnchor { get; set; } = true;
} }
@@ -2,22 +2,17 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<AssemblyName>myai-data</AssemblyName> <RootNamespace>CvSearch.Models</RootNamespace>
<RootNamespace>MyAi.Data</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\shared-data\shared-data.csproj" />
</ItemGroup>
</Project> </Project>
@@ -1,10 +0,0 @@
using Email.Models.Requests;
using Refit;
namespace Email.Models.Clients;
public interface IEmailApiClient
{
[Post("/api/email/send")]
Task SendAsync(SendEmailRequest request, CancellationToken ct = default);
}
@@ -1,10 +0,0 @@
namespace Email.Models.Requests;
public sealed class SendEmailRequest
{
public required List<string> To { get; init; }
public string? ReplyTo { get; init; }
public required string Subject { get; init; }
public required string HtmlBody { get; init; }
public string? AttachmentPath { get; init; }
}
@@ -1,7 +0,0 @@
namespace Email.Models.Settings;
public sealed class EmailApiSettings
{
public string BaseUrl { get; set; } = "";
public string InternalApiKey { get; set; } = "";
}
@@ -1,10 +0,0 @@
namespace Email.Models.Settings;
public sealed class SmtpSettings
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public string Username { get; set; } = "";
public string Password { get; set; } = "";
public bool UseStartTls { get; set; } = true;
}

Some files were not shown because too many files have changed in this diff Show More