Compare commits
141 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e8758796e | |||
| ef2793448a | |||
| cbf06031e8 | |||
| 90f540139a | |||
| 71d5ac8e06 | |||
| c2082d6729 | |||
| 6f1d8992ab | |||
| 2d9ffc9c2b | |||
| 9fbad722fc | |||
| 473c36d65f | |||
| 292d19d5ed | |||
| d56729de42 | |||
| 79a3dec679 | |||
| 02d2b1e510 | |||
| 3c3451b198 | |||
| a83f6f705f | |||
| b68cf942a8 | |||
| 61805e2fb5 | |||
| dcfc50ff32 | |||
| b1ed1cb201 | |||
| e1f171168e | |||
| ae2bc9b902 | |||
| 30a8df431f | |||
| 95b0cfa0a9 | |||
| 20b13647de | |||
| df011f2a03 | |||
| 3414c61cea | |||
| 898dd09d50 | |||
| 4de6f1db45 | |||
| 1222a86eb7 | |||
| 2e9069cbdb | |||
| c89df975bd | |||
| 709c0ac4c3 | |||
| 99e5cfb76b | |||
| 91b2baa445 | |||
| 0f64cb8d99 | |||
| b67e926c5f | |||
| f7d856147e | |||
| 8679bd1efd | |||
| 1bcf95d8d4 | |||
| 73f67d1342 | |||
| 650505c08d | |||
| 4066ab5f3f | |||
| 7a316b4a45 | |||
| 808a4901d9 | |||
| b5b654532c | |||
| 2838885e22 | |||
| 8f90a4cfda | |||
| 978dd3a069 | |||
| f9530b168f | |||
| 9cb38e5bc8 | |||
| d4c05d7d44 | |||
| e3e088a365 | |||
| b114156e9c | |||
| 64e003a639 | |||
| 7ea59d0940 | |||
| 823cbecb84 | |||
| bf9b35eda2 | |||
| dc3051f447 | |||
| bd1d4cf792 | |||
| 0bc860b1a7 | |||
| 070aa329fe | |||
| 87de7d3f77 | |||
| 8b143dcb12 | |||
| 6bb00163ae | |||
| a04e35bd82 | |||
| 06bec9b0ae | |||
| e38f40732f | |||
| 209325ace5 | |||
| 5ae65642c4 | |||
| 9cf3db089d | |||
| e5b6f19c1a | |||
| 9bedf57f39 | |||
| b78ede23cf | |||
| a467fac35d | |||
| 25731868ee | |||
| c675954f8a | |||
| c8d1a21736 | |||
| d0d45bd2d3 | |||
| 7c09f5a871 | |||
| af3a14c7ed | |||
| e14a6a0f69 | |||
| 181a0b23b5 | |||
| 2b43ec5984 | |||
| ea9bc87981 | |||
| 33d92551da | |||
| 43017036fd | |||
| 707f547014 | |||
| 487924e345 | |||
| 36759d8fee | |||
| d0bba19a17 | |||
| 0742694900 | |||
| ce05426452 | |||
| 98979b58f8 | |||
| 57e8cb3f4b | |||
| e7bb803ae2 | |||
| e8633ec52c | |||
| fc9e46d4dc | |||
| 9d2c7af8eb | |||
| 4d9f51fb73 | |||
| 0dd329d5b8 | |||
| cf78c31e05 | |||
| 5b5b471a4b | |||
| f6a27bd15b | |||
| 73673229a4 | |||
| 39708cf340 | |||
| b99260e227 | |||
| 7271484c7f | |||
| cb45c8a312 | |||
| 37997bb356 | |||
| 9955ae191a | |||
| 0c5b85e63c | |||
| 98a7eb73e4 | |||
| ee00bafd31 | |||
| 441cb24b8d | |||
| c9c629767e | |||
| 7afacb3fc0 | |||
| bf29478207 | |||
| af5d9fd7ad | |||
| 513d925be1 | |||
| 9227c92a88 | |||
| 6b460fded4 | |||
| 4e086f3eca | |||
| 04ce55bfc3 | |||
| 3a39b03ff1 | |||
| 9b0d7fb907 | |||
| 323e41e024 | |||
| 7908dad181 | |||
| 2e1efc598b | |||
| 16bb195cb5 | |||
| 4ee4a59b5e | |||
| 7d92f2f8d9 | |||
| de7a3a3a2d | |||
| a1c145e861 | |||
| e17f17b566 | |||
| e7ca6043b7 | |||
| c415ab3957 | |||
| 19e73aca17 | |||
| 270deaaef6 | |||
| 2d9f05cf33 | |||
| 0829eba2b1 |
@@ -11,9 +11,11 @@ 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:
|
||||||
@@ -45,6 +47,10 @@ 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}" .
|
||||||
@@ -57,6 +63,10 @@ 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}"
|
||||||
@@ -69,6 +79,10 @@ 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}"
|
||||||
@@ -79,4 +93,8 @@ 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,4 +10,6 @@ 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, CancellationToken ct);
|
Task<StartJobSearchResponse> StartSearchAsync(string tokenId, [Body] StartJobSearchRequest request, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ namespace Api.Controllers
|
|||||||
{
|
{
|
||||||
Error = "Captcha verification failed.",
|
Error = "Captcha verification failed.",
|
||||||
Code = "captcha_verification_failed",
|
Code = "captcha_verification_failed",
|
||||||
Score = verdict.Score
|
Detail = verdict.Score.HasValue ? $"Score: {verdict.Score:0.00}" : null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "Failed.", Code = "subscription_failed" });
|
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Could not process subscription.", Code = "subscription_failed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ 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.");
|
||||||
@@ -153,17 +163,17 @@ 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),
|
||||||
!string.IsNullOrWhiteSpace(request.JobDescription));
|
!string.IsNullOrWhiteSpace(request.JobDescription));
|
||||||
var res = await _cvApi.MatchJob(request, ct);
|
var res = await _cvApi.MatchJob(request, ct);
|
||||||
var attachmentPath = TryGetCachedCvPath(request.CvDocumentId);
|
var attachmentPath = TryGetCachedCvPath(request.CvDocumentId);
|
||||||
|
var language = NormalizeLanguage(request.Language);
|
||||||
var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl)
|
var jobLabel = !string.IsNullOrWhiteSpace(request.JobUrl)
|
||||||
? request.JobUrl
|
? request.JobUrl
|
||||||
: "Manual job description";
|
: _emailSender.GetManualJobLabel(language);
|
||||||
|
|
||||||
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))
|
||||||
@@ -171,10 +181,13 @@ 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 },
|
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location, ClientIpAddress = userIp },
|
||||||
ct);
|
ct);
|
||||||
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
|
if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
|
||||||
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
|
{
|
||||||
|
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
|
||||||
|
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -196,6 +209,16 @@ 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.");
|
||||||
@@ -221,40 +244,27 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _jobSearchApi.StartSearchAsync(t, ct);
|
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
var result = await _jobSearchApi.StartSearchAsync(t, new StartJobSearchRequest { ClientIpAddress = userIp }, ct);
|
||||||
var lang = "en";
|
var lang = "en";
|
||||||
var html = result.Status switch
|
var (title, message) = result.Status switch
|
||||||
{
|
{
|
||||||
StartJobSearchStatus.Started =>
|
StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
|
||||||
HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
|
StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
|
||||||
StartJobSearchStatus.AlreadyUsed =>
|
StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
|
||||||
HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
|
_ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
|
||||||
StartJobSearchStatus.Expired =>
|
|
||||||
HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
|
|
||||||
_ =>
|
|
||||||
HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
|
|
||||||
};
|
};
|
||||||
return Content(html, "text/html");
|
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "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);
|
||||||
return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html");
|
var title = _templates.Get("html.job-search.error.title", "en");
|
||||||
|
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
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 Common.Responses;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace Api.Controllers
|
namespace Api.Controllers
|
||||||
{
|
{
|
||||||
@@ -17,42 +18,44 @@ 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 const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance
|
private readonly ICaptchaVerifier _captcha;
|
||||||
|
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 with support for full and ranged (resumable) transfers.")]
|
[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.")]
|
||||||
[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, "No file name provided and no default configured")]
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Missing/invalid captcha token, no file name, or 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")]
|
||||||
@@ -62,10 +65,30 @@ 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)
|
public async Task<IActionResult> DownloadFile(string? fileName = null, [FromQuery] string? captchaToken = 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;
|
||||||
@@ -103,7 +126,6 @@ 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
|
||||||
|
|||||||
+7
-2
@@ -2,10 +2,13 @@ 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/common/common.csproj Apis/common/
|
|
||||||
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/myai-data/myai-data.csproj Apis/myai-data/
|
||||||
COPY Apis/shared-data/shared-data.csproj Apis/shared-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/
|
||||||
@@ -13,9 +16,11 @@ 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/common/ Apis/common/
|
|
||||||
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/myai-data/ Apis/myai-data/
|
||||||
COPY Apis/shared-data/ Apis/shared-data/
|
COPY Apis/shared-data/ Apis/shared-data/
|
||||||
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
||||||
|
|||||||
+19
-2
@@ -1,8 +1,12 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Api.Services;
|
using Api.Services;
|
||||||
using Api.Services.Contracts;
|
using Api.Services.Contracts;
|
||||||
using EmailApi.Models.Clients;
|
using Email.Data;
|
||||||
using EmailApi.Models.Settings;
|
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 Microsoft.EntityFrameworkCore;
|
||||||
using Models.Settings;
|
using Models.Settings;
|
||||||
using MyAi.Data;
|
using MyAi.Data;
|
||||||
@@ -47,6 +51,19 @@ try
|
|||||||
});
|
});
|
||||||
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
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, EmailApiEmailSender>();
|
||||||
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
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,15 +1,71 @@
|
|||||||
using CvMatcher.Models.Responses;
|
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 (0–100).</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);
|
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);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
using Api.Services.Contracts;
|
using Api.Services.Contracts;
|
||||||
using CvMatcher.Models.Responses;
|
using CvMatcher.Models.Responses;
|
||||||
using EmailApi.Models.Clients;
|
using Email.Data.Services;
|
||||||
using EmailApi.Models.Requests;
|
using Email.Models.Clients;
|
||||||
|
using Email.Models.Requests;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Models.Requests;
|
using Models.Requests;
|
||||||
using Models.Settings;
|
using Models.Settings;
|
||||||
using MyAi.Data.Services;
|
using System.Net;
|
||||||
|
|
||||||
namespace Api.Services;
|
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
|
public sealed class EmailApiEmailSender : IEmailSender
|
||||||
{
|
{
|
||||||
private readonly IEmailApiClient _emailApi;
|
private readonly IEmailApiClient _emailApi;
|
||||||
private readonly ContactSettings _contact;
|
private readonly ContactSettings _contact;
|
||||||
private readonly SubscribeSettings _subscribe;
|
private readonly SubscribeSettings _subscribe;
|
||||||
private readonly FileStorageSettings _fileStorage;
|
private readonly FileStorageSettings _fileStorage;
|
||||||
private readonly ITemplateService _templates;
|
private readonly IEmailTemplateService _emailTemplates;
|
||||||
private readonly ILogger<EmailApiEmailSender> _log;
|
private readonly ILogger<EmailApiEmailSender> _log;
|
||||||
|
|
||||||
public EmailApiEmailSender(
|
public EmailApiEmailSender(
|
||||||
@@ -23,17 +27,18 @@ public sealed class EmailApiEmailSender : IEmailSender
|
|||||||
IOptions<ContactSettings> contact,
|
IOptions<ContactSettings> contact,
|
||||||
IOptions<SubscribeSettings> subscribe,
|
IOptions<SubscribeSettings> subscribe,
|
||||||
IOptions<FileStorageSettings> fileStorage,
|
IOptions<FileStorageSettings> fileStorage,
|
||||||
ITemplateService templates,
|
IEmailTemplateService emailTemplates,
|
||||||
ILogger<EmailApiEmailSender> log)
|
ILogger<EmailApiEmailSender> log)
|
||||||
{
|
{
|
||||||
_emailApi = emailApi;
|
_emailApi = emailApi;
|
||||||
_contact = contact.Value;
|
_contact = contact.Value;
|
||||||
_subscribe = subscribe.Value;
|
_subscribe = subscribe.Value;
|
||||||
_fileStorage = fileStorage.Value;
|
_fileStorage = fileStorage.Value;
|
||||||
_templates = templates;
|
_emailTemplates = emailTemplates;
|
||||||
_log = log;
|
_log = log;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
|
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
|
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
|
||||||
@@ -76,6 +81,7 @@ public sealed class EmailApiEmailSender : IEmailSender
|
|||||||
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
|
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
|
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
|
||||||
@@ -108,6 +114,7 @@ public sealed class EmailApiEmailSender : IEmailSender
|
|||||||
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
|
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
|
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
|
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
|
||||||
@@ -131,7 +138,7 @@ public sealed class EmailApiEmailSender : IEmailSender
|
|||||||
</tr>
|
</tr>
|
||||||
<tr style="background:#f8f9fa">
|
<tr style="background:#f8f9fa">
|
||||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td>
|
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td>
|
||||||
<td style="border:1px solid #dee2e6">{userIp ?? "Unknown"}</td>
|
<td style="border:1px solid #dee2e6">{userIp ?? _emailTemplates.Get("email.notification.unknown-ip", "en")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
""";
|
""";
|
||||||
@@ -146,15 +153,18 @@ public sealed class EmailApiEmailSender : IEmailSender
|
|||||||
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
|
_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)
|
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>();
|
var recipients = new List<string>();
|
||||||
if (!string.IsNullOrWhiteSpace(explicitTo))
|
if (!string.IsNullOrWhiteSpace(explicitTo))
|
||||||
recipients.Add(explicitTo);
|
recipients.Add(explicitTo);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(_contact.ToEmail) &&
|
if (!string.IsNullOrWhiteSpace(operatorCopy) &&
|
||||||
!recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase)))
|
!recipients.Any(x => string.Equals(x, operatorCopy, StringComparison.OrdinalIgnoreCase)))
|
||||||
recipients.Add(_contact.ToEmail);
|
recipients.Add(operatorCopy);
|
||||||
|
|
||||||
if (recipients.Count == 0)
|
if (recipients.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -182,36 +192,41 @@ public sealed class EmailApiEmailSender : IEmailSender
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
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
|
var strengths = result.Strengths?.Count > 0
|
||||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||||
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
|
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
|
||||||
: "<p style=\"color:#6c757d\">—</p>";
|
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||||
|
|
||||||
var gaps = result.Gaps?.Count > 0
|
var gaps = result.Gaps?.Count > 0
|
||||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||||
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
|
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
|
||||||
: "<p style=\"color:#6c757d\">—</p>";
|
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||||
|
|
||||||
var recommendations = result.Recommendations?.Count > 0
|
var recommendations = result.Recommendations?.Count > 0
|
||||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||||
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
|
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
|
||||||
: "<p style=\"color:#6c757d\">—</p>";
|
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||||
|
|
||||||
var body = _templates.Render("email.match.body", language,
|
// 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),
|
("cvDocumentId", cvDocumentId),
|
||||||
("jobLabel", jobLabel ?? "N/A"),
|
("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.fallback-na", language)),
|
||||||
("jobUrl", result.JobUrl ?? "N/A"),
|
("jobUrl", result.JobUrl ?? _emailTemplates.Get("email.match.fallback-na", language)),
|
||||||
("score", result.Score.ToString()),
|
("score", result.Score.ToString()),
|
||||||
("summary", result.Summary ?? string.Empty),
|
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
|
||||||
("strengths", strengths),
|
("strengths", strengths),
|
||||||
("gaps", gaps),
|
("gaps", gaps),
|
||||||
("recommendations", recommendations));
|
("recommendations", recommendations));
|
||||||
|
|
||||||
|
// Append the job search footer if link is provided
|
||||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||||
{
|
{
|
||||||
body += _templates.Render("email.match.job-search-footer", language,
|
body += _emailTemplates.Render("email.match.job-search-footer", language,
|
||||||
("jobSearchLink", jobSearchLink),
|
("jobSearchLink", jobSearchLink),
|
||||||
("expiryDays", expiryDays.ToString()));
|
("expiryDays", expiryDays.ToString()));
|
||||||
}
|
}
|
||||||
@@ -219,8 +234,12 @@ public sealed class EmailApiEmailSender : IEmailSender
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
|
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
|
||||||
_templates.Render("email.match.subject", language,
|
_emailTemplates.Render("email.match.subject", language,
|
||||||
("score", score.ToString()),
|
("score", score.ToString()),
|
||||||
("jobLabel", jobLabel ?? "Job"));
|
("jobLabel", jobLabel ?? _emailTemplates.Get("email.match.subject-fallback-label", language)));
|
||||||
|
|
||||||
|
public string GetManualJobLabel(string language) =>
|
||||||
|
_emailTemplates.Get("email.match.manual-job-label", language);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ 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;
|
||||||
@@ -18,6 +21,7 @@ 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");
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
|
|
||||||
<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="..\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="..\common\common.csproj" />
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"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",
|
||||||
@@ -30,25 +29,6 @@
|
|||||||
"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": [
|
||||||
@@ -110,6 +90,10 @@
|
|||||||
"BaseUrl": "",
|
"BaseUrl": "",
|
||||||
"InternalApiKey": ""
|
"InternalApiKey": ""
|
||||||
},
|
},
|
||||||
|
"EmailApi": {
|
||||||
|
"BaseUrl": "",
|
||||||
|
"InternalApiKey": ""
|
||||||
|
},
|
||||||
"RateLimiting": {
|
"RateLimiting": {
|
||||||
"Global": {
|
"Global": {
|
||||||
"PermitLimit": 120,
|
"PermitLimit": 120,
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
namespace Common.Responses;
|
namespace Common.Responses;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Standard error body returned by all API endpoints on 4xx and 5xx responses.
|
||||||
|
/// </summary>
|
||||||
public sealed class ErrorResponse
|
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;
|
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; }
|
public string? Code { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Optional additional detail for debugging (not shown in UI).</summary>
|
||||||
public string? Detail { get; init; }
|
public string? Detail { get; init; }
|
||||||
public double? Score { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -5,4 +5,8 @@ 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 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,5 +9,7 @@
|
|||||||
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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace CvMatcher.Models.Requests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request body sent by <c>api</c> when activating a one-time job-search link.
|
||||||
|
/// Carries the caller's IP address so it can be persisted on the session for auditing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StartJobSearchRequest
|
||||||
|
{
|
||||||
|
/// <summary>Client IP address forwarded by the api layer. Null when not available.</summary>
|
||||||
|
public string? ClientIpAddress { get; set; }
|
||||||
|
}
|
||||||
@@ -2,5 +2,9 @@ namespace CvMatcher.Models.Responses;
|
|||||||
|
|
||||||
public sealed class CreateJobSearchTokenResponse
|
public sealed class CreateJobSearchTokenResponse
|
||||||
{
|
{
|
||||||
public string TokenId { get; set; } = string.Empty;
|
/// <summary>
|
||||||
|
/// 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,6 +8,8 @@
|
|||||||
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; }
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ 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;
|
||||||
@@ -18,4 +21,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ct);
|
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, request.ClientIpAddress, ct);
|
||||||
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
|
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, CancellationToken ct)
|
public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, [FromBody] StartJobSearchRequest? request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var status = await _tokenService.TriggerStartAsync(tokenId, ct);
|
var status = await _tokenService.TriggerStartAsync(tokenId, request?.ClientIpAddress, ct);
|
||||||
return Ok(new StartJobSearchResponse { Status = status });
|
return Ok(new StartJobSearchResponse { Status = status });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ 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-data/cv-search-data.csproj Apis/cv-search-data/
|
||||||
COPY Apis/cv-matcher-data/cv-matcher-data.csproj Apis/cv-matcher-data/
|
COPY Apis/cv-matcher-data/cv-matcher-data.csproj Apis/cv-matcher-data/
|
||||||
COPY Apis/common/common.csproj Apis/common/
|
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/myai-data/myai-data.csproj Apis/myai-data/
|
||||||
COPY Apis/shared-data/shared-data.csproj Apis/shared-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/
|
||||||
@@ -19,6 +21,7 @@ COPY Apis/cv-search-data/ Apis/cv-search-data/
|
|||||||
COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/
|
COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/
|
||||||
COPY Apis/common/ Apis/common/
|
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/myai-data/ Apis/myai-data/
|
||||||
COPY Apis/shared-data/ Apis/shared-data/
|
COPY Apis/shared-data/ Apis/shared-data/
|
||||||
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ using Api.Services.Contracts;
|
|||||||
using CvMatcher.Models.Settings;
|
using CvMatcher.Models.Settings;
|
||||||
using CvSearch.Data;
|
using CvSearch.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using MyAi.Data;
|
|
||||||
using MyAi.Data.Services;
|
|
||||||
using Refit;
|
using Refit;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Common.Settings;
|
using Common.Settings;
|
||||||
|
using PageFetcher.Models;
|
||||||
using StartupHelpers;
|
using StartupHelpers;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
|
||||||
@@ -38,6 +37,16 @@ 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) =>
|
||||||
@@ -52,7 +61,7 @@ try
|
|||||||
|
|
||||||
builder.Services.AddScoped<IRagApiClient, RagApiClient>();
|
builder.Services.AddScoped<IRagApiClient, RagApiClient>();
|
||||||
builder.Services.AddHttpClient<IMatcherAiClient, MatcherAiClient>();
|
builder.Services.AddHttpClient<IMatcherAiClient, MatcherAiClient>();
|
||||||
builder.Services.AddHttpClient<IJobTextExtractor, JobTextExtractor>();
|
builder.Services.AddScoped<IJobTextExtractor, JobTextExtractor>();
|
||||||
|
|
||||||
builder.Services.AddDbContext<CvMatcherDbContext>(options =>
|
builder.Services.AddDbContext<CvMatcherDbContext>(options =>
|
||||||
{
|
{
|
||||||
@@ -76,18 +85,8 @@ try
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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.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>();
|
||||||
|
|
||||||
@@ -122,11 +121,6 @@ try
|
|||||||
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
}
|
}
|
||||||
using (var scope = app.Services.CreateScope())
|
|
||||||
{
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
|
|
||||||
db.Database.Migrate();
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.Information("{Service} startup complete", ServiceName);
|
Log.Information("{Service} startup complete", ServiceName);
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -3,9 +3,34 @@ 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,6 +1,17 @@
|
|||||||
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,7 +1,35 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct);
|
/// <summary>
|
||||||
Task<string> TriggerStartAsync(string tokenId, CancellationToken ct);
|
/// Creates a new single-use job search token linked to the given CV document and user.
|
||||||
|
/// 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,35 +7,38 @@ using CvMatcher.Models.Responses;
|
|||||||
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 MyAi.Data.Services;
|
|
||||||
|
|
||||||
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;
|
||||||
private readonly ITemplateService _templates;
|
|
||||||
|
|
||||||
public CvMatcherService(
|
public CvMatcherService(
|
||||||
IRagApiClient rag,
|
IRagApiClient rag,
|
||||||
IJobTextExtractor jobTextExtractor,
|
IJobTextExtractor jobTextExtractor,
|
||||||
IMatcherAiClient ai,
|
IMatcherAiClient ai,
|
||||||
IMatcherRepository repository,
|
IMatcherRepository repository,
|
||||||
IOptions<MatcherSettings> options,
|
IAiPromptsRepository aiPrompts,
|
||||||
ITemplateService templates)
|
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;
|
||||||
_templates = templates;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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);
|
||||||
@@ -52,6 +55,7 @@ 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.");
|
||||||
@@ -73,12 +77,13 @@ 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, NormalizeLanguage(null), ct));
|
jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, null, NormalizeLanguage(null), ct));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
|
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.");
|
||||||
@@ -102,10 +107,15 @@ 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, NormalizeLanguage(request.Language), ct);
|
return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, request.ClientIpAddress, NormalizeLanguage(request.Language), ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, string language, CancellationToken ct)
|
/// <summary>
|
||||||
|
/// 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;
|
||||||
@@ -113,10 +123,11 @@ 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 = _templates.Render("ai.cv-match.system-prompt", "*",
|
var systemPrompt = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", language, ct)
|
||||||
("languageName", languageName));
|
?? throw new InvalidOperationException(
|
||||||
|
$"AI prompt not found: key='ai.cv-match.system-prompt', language='{language}'. " +
|
||||||
|
$"This is a configuration error. Ensure the cvMatcher.AiPrompts table is properly seeded with language-specific prompts.");
|
||||||
|
|
||||||
var userPrompt = $"""
|
var userPrompt = $"""
|
||||||
CV:
|
CV:
|
||||||
@@ -130,22 +141,24 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
var json = await _ai.CreateChatCompletionAsync(systemPrompt, userPrompt, 0.2m, ct);
|
var json = await _ai.CreateChatCompletionAsync(systemPrompt, userPrompt, 0.2m, ct);
|
||||||
var result = ParseResult(json);
|
var errorSummary = await _aiPrompts.GetAsync("parse-error.summary", language, ct);
|
||||||
|
var errorRec = await _aiPrompts.GetAsync("parse-error.recommendation", language, ct);
|
||||||
|
var result = ParseResult(json, errorSummary, errorRec);
|
||||||
result.JobDocumentId = job.Id;
|
result.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, ct);
|
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, email, clientIpAddress, ct);
|
||||||
|
|
||||||
//await _email.SendMatchAsync(
|
|
||||||
// email,
|
|
||||||
// $"MyAi.ro CV Match: {result.Score}% - {job.Title}",
|
|
||||||
// BuildEmailBody(cv, job, result),
|
|
||||||
// ct);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static JobMatchResponse ParseResult(string json)
|
/// <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,
|
||||||
|
string? errorSummary = null,
|
||||||
|
string? errorRec = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -160,53 +173,33 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
return new JobMatchResponse
|
return new JobMatchResponse
|
||||||
{
|
{
|
||||||
Score = 0,
|
Score = 0,
|
||||||
Summary = "The AI response could not be parsed as structured JSON.",
|
Summary = errorSummary ?? "The AI response could not be parsed as structured JSON.",
|
||||||
Recommendations = ["Inspect the raw model output and tune the scoring prompt."]
|
Recommendations = [errorRec ?? "Inspect the raw model output and tune the scoring prompt."]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
|
||||||
private static string LanguageName(string language) => language switch
|
/// <summary>Truncates <paramref name="value"/> to at most <paramref name="max"/> characters.</summary>
|
||||||
{
|
|
||||||
"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,24 +1,26 @@
|
|||||||
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 HttpClient _http;
|
private readonly IPageFetcherApiClient _pageFetcher;
|
||||||
private readonly MatcherSettings _settings;
|
private readonly MatcherSettings _settings;
|
||||||
|
|
||||||
public JobTextExtractor(HttpClient http, IOptions<MatcherSettings> options)
|
public JobTextExtractor(IPageFetcherApiClient pageFetcher, IOptions<MatcherSettings> options)
|
||||||
{
|
{
|
||||||
_http = http;
|
_pageFetcher = pageFetcher;
|
||||||
_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);
|
||||||
@@ -26,23 +28,28 @@ 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.");
|
throw new InvalidOperationException("Invalid job URL.");
|
||||||
}
|
|
||||||
|
|
||||||
var html = await _http.GetStringAsync(uri, ct);
|
var response = await _pageFetcher.FetchAsync(new FetchPageRequest
|
||||||
html = Regex.Replace(html, "<script[\\s\\S]*?</script>", " ", RegexOptions.IgnoreCase);
|
{
|
||||||
html = Regex.Replace(html, "<style[\\s\\S]*?</style>", " ", RegexOptions.IgnoreCase);
|
Url = jobUrl,
|
||||||
html = Regex.Replace(html, "<[^>]+>", " ");
|
CallerService = "cv-matcher-api"
|
||||||
return Limit(Normalize(WebUtility.HtmlDecode(html)));
|
}, ct);
|
||||||
|
|
||||||
|
if (!response.Success)
|
||||||
|
throw new InvalidOperationException($"Failed to fetch job page: {response.Error}");
|
||||||
|
|
||||||
|
return Limit(Normalize(response.Text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
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.Data;
|
||||||
@@ -11,33 +9,49 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct)
|
/// <inheritdoc />
|
||||||
|
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,
|
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
|
||||||
@@ -45,11 +59,12 @@ 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}", token.Id, cvDocumentId);
|
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}, Location={Location}", token.Id, cvDocumentId, token.Keywords, token.Location);
|
||||||
return token.Id;
|
return token.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> TriggerStartAsync(string tokenId, CancellationToken ct)
|
/// <inheritdoc />
|
||||||
|
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;
|
||||||
@@ -59,11 +74,15 @@ public sealed class JobTokenService : IJobTokenService
|
|||||||
token.Used = true;
|
token.Used = true;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct);
|
var keywords = token.Keywords;
|
||||||
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(
|
||||||
_settings.Providers.Where(p => p.Enabled).ToList(),
|
enabledProviders.Select(ToConfig).ToList(),
|
||||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||||
|
|
||||||
var session = new JobSearchSessionEntity
|
var session = new JobSearchSessionEntity
|
||||||
@@ -75,35 +94,44 @@ public sealed class JobTokenService : IJobTokenService
|
|||||||
Language = token.Language,
|
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("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords);
|
_logger.LogInformation(
|
||||||
|
"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 string ExtractKeywords(string cvText)
|
private static JobProviderConfig ToConfig(JobProviderEntity entity)
|
||||||
{
|
{
|
||||||
var lines = cvText
|
List<string> keywords;
|
||||||
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
|
try
|
||||||
.Select(l => l.Trim())
|
{
|
||||||
.Where(l => l.Length > 5 && l.Length < 200)
|
keywords = JsonSerializer.Deserialize<List<string>>(entity.InitialKeywordsJson,
|
||||||
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
|
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
|
||||||
.Take(5)
|
}
|
||||||
.ToList();
|
catch
|
||||||
|
{
|
||||||
|
keywords = [];
|
||||||
|
}
|
||||||
|
|
||||||
var words = lines
|
return new JobProviderConfig
|
||||||
.SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
{
|
||||||
.Select(w => Regex.Replace(w, @"[^\w\-]", ""))
|
Name = entity.Name,
|
||||||
.Where(w => w.Length > 2)
|
Enabled = entity.Enabled,
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
SearchUrlTemplate = entity.SearchUrlTemplate,
|
||||||
.Take(10)
|
JobLinkContains = entity.JobLinkContains,
|
||||||
.ToList();
|
InitialKeywords = keywords,
|
||||||
|
MaxResults = entity.MaxResults,
|
||||||
return string.Join(",", words);
|
RequireKeywordInAnchor = entity.RequireKeywordInAnchor
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"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",
|
||||||
@@ -30,25 +29,6 @@
|
|||||||
"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": [
|
||||||
@@ -112,32 +92,6 @@
|
|||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
<ProjectReference Include="..\cv-search-data\cv-search-data.csproj" />
|
<ProjectReference Include="..\cv-search-data\cv-search-data.csproj" />
|
||||||
<ProjectReference Include="..\cv-matcher-data\cv-matcher-data.csproj" />
|
<ProjectReference Include="..\cv-matcher-data\cv-matcher-data.csproj" />
|
||||||
<ProjectReference Include="..\common\common.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" />
|
||||||
<ProjectReference Include="..\myai-data\myai-data.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ namespace CvMatcher.Data;
|
|||||||
|
|
||||||
public sealed class CvMatcherDbContext : DbContext
|
public sealed class CvMatcherDbContext : DbContext
|
||||||
{
|
{
|
||||||
public const string SchemaName = "cvMatcher";
|
public const string SchemaName = MigrationConstants.SchemaName;
|
||||||
public const string MigrationTableName = "_Migrations";
|
public const string MigrationTableName = MigrationConstants.MigrationTableName;
|
||||||
|
|
||||||
public CvMatcherDbContext(DbContextOptions<CvMatcherDbContext> options) : base(options)
|
public CvMatcherDbContext(DbContextOptions<CvMatcherDbContext> options) : base(options)
|
||||||
{
|
{
|
||||||
@@ -14,6 +14,14 @@ 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)
|
||||||
{
|
{
|
||||||
@@ -28,7 +36,9 @@ 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.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique();
|
entity.Property(x => x.Email).HasMaxLength(256);
|
||||||
|
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
|
||||||
|
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<CvMatcherChatCacheEntity>(entity =>
|
modelBuilder.Entity<CvMatcherChatCacheEntity>(entity =>
|
||||||
@@ -41,5 +51,16 @@ 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()");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
namespace MyAi.Models.Data.Entities;
|
namespace CvMatcher.Data.Entities;
|
||||||
|
|
||||||
public sealed class TemplateEntity
|
public sealed class AiPromptEntity
|
||||||
{
|
{
|
||||||
public string Key { get; set; } = string.Empty;
|
public string Key { get; set; } = string.Empty;
|
||||||
public string Language { get; set; } = string.Empty;
|
public string Language { get; set; } = string.Empty;
|
||||||
@@ -9,4 +9,6 @@ public sealed class CvMatchResultEntity : BaseEntity
|
|||||||
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 string? ClientIpAddress { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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,70 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace CvMatcher.Data.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,31 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace CvMatcher.Data.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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+36
-5
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvMatcher.Data;
|
using CvMatcher.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
namespace CvMatcher.Data.Migrations
|
namespace CvMatcher.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvMatcherDbContext))]
|
[DbContext(typeof(CvMatcherDbContext))]
|
||||||
[Migration("20260524140335_AddLanguageToCvMatchResult")]
|
[Migration("20260601133028_InitialSchema")]
|
||||||
partial class AddLanguageToCvMatchResult
|
partial class InitialSchema
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -26,6 +26,37 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
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 =>
|
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -49,7 +80,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
b.Property<string>("ResultJson")
|
b.Property<string>("ResultJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -60,7 +91,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Results", "cvMatcher");
|
b.ToTable("Results", "cvMatcher");
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
-4
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvMatcher.Data;
|
using CvMatcher.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
namespace CvMatcher.Data.Migrations
|
namespace CvMatcher.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvMatcherDbContext))]
|
[DbContext(typeof(CvMatcherDbContext))]
|
||||||
[Migration("20260507140442_InitialCvMatcherSchema")]
|
[Migration("20260608124331_ImproveKeywordsAndAddLocation")]
|
||||||
partial class InitialCvMatcherSchema
|
partial class ImproveKeywordsAndAddLocation
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -26,6 +26,37 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
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 =>
|
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -47,6 +78,10 @@ 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)");
|
||||||
@@ -56,7 +91,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Results", "cvMatcher");
|
b.ToTable("Results", "cvMatcher");
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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."
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CvMatcher.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvMatcher.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CvMatcherDbContext))]
|
||||||
|
[Migration("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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CvMatcher.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvMatcher.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CvMatcherDbContext))]
|
||||||
|
[Migration("20260608193046_AddParseErrorPrompts")]
|
||||||
|
partial class AddParseErrorPrompts
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("cvMatcher")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Key", "Language");
|
||||||
|
|
||||||
|
b.ToTable("AiPrompts", "cvMatcher");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("nvarchar(45)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("JobDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ResultJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Results", "cvMatcher");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("CacheKey")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(120)
|
||||||
|
.HasColumnType("nvarchar(120)");
|
||||||
|
|
||||||
|
b.Property<string>("ResponseText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<decimal>("Temperature")
|
||||||
|
.HasColumnType("decimal(4,2)");
|
||||||
|
|
||||||
|
b.HasKey("CacheKey");
|
||||||
|
|
||||||
|
b.ToTable("ChatCache", "cvMatcher");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using CvMatcher.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvMatcher.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddParseErrorPrompts : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "AiPrompts",
|
||||||
|
columns: ["Key", "Language", "Value", "Description"],
|
||||||
|
values: ["parse-error.summary", "en", "The AI response could not be parsed. Please try again.", "Summary shown in match email when the AI returns an unparseable response"]);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "AiPrompts",
|
||||||
|
columns: ["Key", "Language", "Value", "Description"],
|
||||||
|
values: ["parse-error.summary", "ro", "Răspunsul AI nu a putut fi interpretat. Vă rugăm să încercați din nou.", "Sumar afișat în emailul de potrivire când AI returnează un răspuns care nu poate fi interpretat"]);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "AiPrompts",
|
||||||
|
columns: ["Key", "Language", "Value", "Description"],
|
||||||
|
values: ["parse-error.recommendation", "en", "If the problem persists, try a different job link or description.", "Recommendation shown in match email when the AI returns an unparseable response"]);
|
||||||
|
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "AiPrompts",
|
||||||
|
columns: ["Key", "Language", "Value", "Description"],
|
||||||
|
values: ["parse-error.recommendation", "ro", "Dacă problema persistă, încercați un alt link sau descriere de job.", "Recomandare afișată în emailul de potrivire când AI returnează un răspuns care nu poate fi interpretat"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "AiPrompts",
|
||||||
|
keyColumns: ["Key", "Language"],
|
||||||
|
keyValues: ["parse-error.summary", "en"]);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "AiPrompts",
|
||||||
|
keyColumns: ["Key", "Language"],
|
||||||
|
keyValues: ["parse-error.summary", "ro"]);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "AiPrompts",
|
||||||
|
keyColumns: ["Key", "Language"],
|
||||||
|
keyValues: ["parse-error.recommendation", "en"]);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "AiPrompts",
|
||||||
|
keyColumns: ["Key", "Language"],
|
||||||
|
keyValues: ["parse-error.recommendation", "ro"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvMatcher.Data;
|
using CvMatcher.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -23,12 +23,47 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
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 =>
|
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")
|
||||||
@@ -39,6 +74,10 @@ 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)
|
||||||
@@ -46,7 +85,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
b.Property<string>("ResultJson")
|
b.Property<string>("ResultJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -57,7 +96,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Results", "cvMatcher");
|
b.ToTable("Results", "cvMatcher");
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace CvMatcher.Data.Repositories.Contracts;
|
||||||
|
|
||||||
|
public interface IAiPromptsRepository
|
||||||
|
{
|
||||||
|
Task<string?> GetAsync(string key, string language, CancellationToken ct);
|
||||||
|
}
|
||||||
+1
-1
@@ -6,7 +6,7 @@ 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, CancellationToken ct);
|
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, 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);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+27
-11
@@ -4,6 +4,7 @@ using CvMatcher.Data.Entities;
|
|||||||
using CvMatcher.Data.Repositories.Contracts;
|
using CvMatcher.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 CvMatcher.Data.Repositories;
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct)
|
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var exists = await _db.CvMatchResults.AnyAsync(
|
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,
|
||||||
@@ -47,18 +48,33 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
|||||||
|
|
||||||
if (exists) return;
|
if (exists) return;
|
||||||
|
|
||||||
_db.CvMatchResults.Add(new CvMatchResultEntity
|
try
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
_db.CvMatchResults.Add(new CvMatchResultEntity
|
||||||
CvDocumentId = cvDocumentId,
|
{
|
||||||
JobDocumentId = jobDocumentId,
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
Language = language,
|
CvDocumentId = cvDocumentId,
|
||||||
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
JobDocumentId = jobDocumentId,
|
||||||
Score = response.Score,
|
Language = language,
|
||||||
CreatedAt = DateTime.UtcNow
|
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
||||||
});
|
Score = response.Score,
|
||||||
|
Email = email,
|
||||||
|
ClientIpAddress = clientIpAddress,
|
||||||
|
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)
|
||||||
@@ -15,5 +15,6 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\shared-data\shared-data.csproj" />
|
<ProjectReference Include="..\shared-data\shared-data.csproj" />
|
||||||
|
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -5,14 +5,22 @@ namespace CvSearch.Data;
|
|||||||
|
|
||||||
public sealed class CvSearchDbContext : DbContext
|
public sealed class CvSearchDbContext : DbContext
|
||||||
{
|
{
|
||||||
public const string SchemaName = "cvSearch";
|
public const string SchemaName = MigrationConstants.SchemaName;
|
||||||
public const string MigrationTableName = "_Migrations";
|
public const string MigrationTableName = MigrationConstants.MigrationTableName;
|
||||||
|
|
||||||
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)
|
||||||
{
|
{
|
||||||
@@ -26,7 +34,9 @@ public sealed class CvSearchDbContext : DbContext
|
|||||||
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.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()");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,6 +52,7 @@ public sealed class CvSearchDbContext : DbContext
|
|||||||
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.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);
|
||||||
});
|
});
|
||||||
@@ -55,8 +66,23 @@ 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,39 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -11,4 +11,8 @@ public sealed class JobSearchResultEntity : BaseEntity
|
|||||||
public string JobText { get; set; } = string.Empty;
|
public string JobText { get; set; } = string.Empty;
|
||||||
public int Score { get; set; }
|
public int Score { get; set; }
|
||||||
public string ResultJson { get; set; } = string.Empty;
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ public sealed class JobSearchSessionEntity : BaseEntity
|
|||||||
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 string Language { get; set; } = "en";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,8 @@ public sealed class JobSearchTokenEntity : BaseEntity
|
|||||||
public string Language { get; set; } = "en";
|
public string Language { get; set; } = "en";
|
||||||
public DateTime ExpiresAt { get; set; }
|
public DateTime ExpiresAt { get; set; }
|
||||||
public bool Used { 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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
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,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using CvSearch.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -12,11 +13,11 @@ namespace CvSearch.Data.Migrations
|
|||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.EnsureSchema(
|
migrationBuilder.EnsureSchema(
|
||||||
name: "cvSearch");
|
name: MigrationConstants.SchemaName);
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "JobSearchResults",
|
name: "JobSearchResults",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
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),
|
||||||
@@ -36,7 +37,7 @@ namespace CvSearch.Data.Migrations
|
|||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "JobSearchSessions",
|
name: "JobSearchSessions",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
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),
|
||||||
@@ -55,7 +56,7 @@ namespace CvSearch.Data.Migrations
|
|||||||
|
|
||||||
migrationBuilder.CreateTable(
|
migrationBuilder.CreateTable(
|
||||||
name: "JobSearchTokens",
|
name: "JobSearchTokens",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
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),
|
||||||
@@ -72,13 +73,13 @@ namespace CvSearch.Data.Migrations
|
|||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_JobSearchResults_SessionId",
|
name: "IX_JobSearchResults_SessionId",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "JobSearchResults",
|
table: "JobSearchResults",
|
||||||
column: "SessionId");
|
column: "SessionId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_JobSearchSessions_Status",
|
name: "IX_JobSearchSessions_Status",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "JobSearchSessions",
|
table: "JobSearchSessions",
|
||||||
column: "Status");
|
column: "Status");
|
||||||
}
|
}
|
||||||
@@ -88,15 +89,15 @@ namespace CvSearch.Data.Migrations
|
|||||||
{
|
{
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "JobSearchResults",
|
name: "JobSearchResults",
|
||||||
schema: "cvSearch");
|
schema: MigrationConstants.SchemaName);
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "JobSearchSessions",
|
name: "JobSearchSessions",
|
||||||
schema: "cvSearch");
|
schema: MigrationConstants.SchemaName);
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
migrationBuilder.DropTable(
|
||||||
name: "JobSearchTokens",
|
name: "JobSearchTokens",
|
||||||
schema: "cvSearch");
|
schema: MigrationConstants.SchemaName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using CvSearch.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ namespace CvSearch.Data.Migrations
|
|||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Language",
|
name: "Language",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "JobSearchTokens",
|
table: "JobSearchTokens",
|
||||||
type: "nvarchar(8)",
|
type: "nvarchar(8)",
|
||||||
maxLength: 8,
|
maxLength: 8,
|
||||||
@@ -21,7 +22,7 @@ namespace CvSearch.Data.Migrations
|
|||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Language",
|
name: "Language",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "JobSearchSessions",
|
table: "JobSearchSessions",
|
||||||
type: "nvarchar(8)",
|
type: "nvarchar(8)",
|
||||||
maxLength: 8,
|
maxLength: 8,
|
||||||
@@ -34,12 +35,12 @@ namespace CvSearch.Data.Migrations
|
|||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Language",
|
name: "Language",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "JobSearchTokens");
|
table: "JobSearchTokens");
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Language",
|
name: "Language",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "JobSearchSessions");
|
table: "JobSearchSessions");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-7
@@ -1,6 +1,6 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvSearch.Models.Data;
|
using CvSearch.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 CvSearch.Models.Migrations
|
namespace CvSearch.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvSearchDbContext))]
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
[Migration("20260524145702_AddLanguageToJobSearchEntities")]
|
[Migration("20260529084440_AddJobProviders")]
|
||||||
partial class AddLanguageToJobSearchEntities
|
partial class AddJobProviders
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
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")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchResults", "cvSearch");
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -128,7 +176,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchSessions", "cvSearch");
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-8
@@ -1,6 +1,6 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvSearch.Models.Data;
|
using CvSearch.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 CvSearch.Models.Migrations
|
namespace CvSearch.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvSearchDbContext))]
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
[Migration("20260522093356_AddJobSearchTables")]
|
[Migration("20260529130000_AddKeywordsToJobSearchTokens")]
|
||||||
partial class AddJobSearchTables
|
partial class AddKeywordsToJobSearchTokens
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
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")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchResults", "cvSearch");
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -101,6 +149,13 @@ namespace CvSearch.Models.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)");
|
||||||
|
|
||||||
@@ -121,7 +176,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchSessions", "cvSearch");
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -145,6 +200,20 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.Property<DateTime>("ExpiresAt")
|
b.Property<DateTime>("ExpiresAt")
|
||||||
.HasColumnType("datetime2");
|
.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")
|
b.Property<bool>("Used")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bit")
|
.HasColumnType("bit")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
-8
@@ -1,19 +1,22 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvSearch.Models.Data;
|
using CvSearch.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.Models.Migrations
|
namespace CvSearch.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvSearchDbContext))]
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
partial class CvSearchDbContextModelSnapshot : ModelSnapshot
|
[Migration("20260529160000_FixBestJobsLinkFilter")]
|
||||||
|
partial class FixBestJobsLinkFilter
|
||||||
{
|
{
|
||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
@@ -23,7 +26,55 @@ namespace CvSearch.Models.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
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")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -72,7 +123,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchResults", "cvSearch");
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -125,7 +176,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchSessions", "cvSearch");
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -149,6 +200,13 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.Property<DateTime>("ExpiresAt")
|
b.Property<DateTime>("ExpiresAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
// <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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+243
@@ -0,0 +1,243 @@
|
|||||||
|
// <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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
// <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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
// <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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+250
@@ -0,0 +1,250 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CvSearch.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
|
[Migration("20260608161102_AddEmailIpToSessionAndResults")]
|
||||||
|
partial class AddEmailIpToSessionAndResults
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("cvSearch")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("InitialKeywordsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
|
b.Property<string>("JobLinkContains")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MaxResults")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(20);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<bool>("RequireKeywordInAnchor")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("SearchUrlTemplate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobProviders", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("nvarchar(45)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("JobText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("JobTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("nvarchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("JobUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ResultJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SessionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SessionId");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("nvarchar(45)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderConfigJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("Used")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchTokens", "cvSearch");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using CvSearch.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEmailIpToSessionAndResults : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClientIpAddress",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobSearchSessions",
|
||||||
|
type: "nvarchar(45)",
|
||||||
|
maxLength: 45,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClientIpAddress",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobSearchResults",
|
||||||
|
type: "nvarchar(45)",
|
||||||
|
maxLength: 45,
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Email",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobSearchResults",
|
||||||
|
type: "nvarchar(256)",
|
||||||
|
maxLength: 256,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClientIpAddress",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobSearchSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClientIpAddress",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobSearchResults");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Email",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobSearchResults");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CvSearch.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
|
[Migration("20260608161930_AddClientIpToJobSearchTokens")]
|
||||||
|
partial class AddClientIpToJobSearchTokens
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("cvSearch")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("InitialKeywordsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
|
b.Property<string>("JobLinkContains")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MaxResults")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(20);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<bool>("RequireKeywordInAnchor")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("SearchUrlTemplate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobProviders", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("nvarchar(45)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("JobText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("JobTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("nvarchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("JobUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ResultJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SessionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SessionId");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("nvarchar(45)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderConfigJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("ClientIpAddress")
|
||||||
|
.HasMaxLength(45)
|
||||||
|
.HasColumnType("nvarchar(45)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("Used")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchTokens", "cvSearch");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using CvSearch.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddClientIpToJobSearchTokens : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ClientIpAddress",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobSearchTokens",
|
||||||
|
type: "nvarchar(45)",
|
||||||
|
maxLength: 45,
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ClientIpAddress",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobSearchTokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvSearch.Data;
|
using CvSearch.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -23,17 +23,76 @@ namespace CvSearch.Data.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
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 =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", 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")
|
||||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
b.Property<string>("JobText")
|
b.Property<string>("JobText")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -78,6 +137,10 @@ namespace CvSearch.Data.Migrations
|
|||||||
.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")
|
||||||
@@ -105,6 +168,9 @@ namespace CvSearch.Data.Migrations
|
|||||||
.HasColumnType("nvarchar(8)")
|
.HasColumnType("nvarchar(8)")
|
||||||
.HasDefaultValue("en");
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ProviderConfigJson")
|
b.Property<string>("ProviderConfigJson")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -131,6 +197,10 @@ namespace CvSearch.Data.Migrations
|
|||||||
.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")
|
||||||
@@ -149,6 +219,13 @@ namespace CvSearch.Data.Migrations
|
|||||||
b.Property<DateTime>("ExpiresAt")
|
b.Property<DateTime>("ExpiresAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -156,6 +233,9 @@ namespace CvSearch.Data.Migrations
|
|||||||
.HasColumnType("nvarchar(8)")
|
.HasColumnType("nvarchar(8)")
|
||||||
.HasDefaultValue("en");
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("Used")
|
b.Property<bool>("Used")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bit")
|
.HasColumnType("bit")
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
using CvSearch.Models.Data.Entities;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace CvSearch.Models.Data;
|
|
||||||
|
|
||||||
public sealed class CvSearchDbContext : DbContext
|
|
||||||
{
|
|
||||||
public const string SchemaName = "cvSearch";
|
|
||||||
public const string MigrationTableName = "_Migrations";
|
|
||||||
|
|
||||||
public CvSearchDbContext(DbContextOptions<CvSearchDbContext> options) : base(options) { }
|
|
||||||
|
|
||||||
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
|
|
||||||
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
|
|
||||||
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
modelBuilder.HasDefaultSchema(SchemaName);
|
|
||||||
|
|
||||||
modelBuilder.Entity<JobSearchTokenEntity>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("JobSearchTokens");
|
|
||||||
entity.HasKey(x => x.Id);
|
|
||||||
entity.Property(x => x.Id).HasMaxLength(64);
|
|
||||||
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
|
|
||||||
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
|
||||||
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
|
|
||||||
entity.Property(x => x.Used).HasDefaultValue(false);
|
|
||||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<JobSearchSessionEntity>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("JobSearchSessions");
|
|
||||||
entity.HasKey(x => x.Id);
|
|
||||||
entity.Property(x => x.Id).HasMaxLength(64);
|
|
||||||
entity.Property(x => x.TokenId).HasMaxLength(64).IsRequired();
|
|
||||||
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
|
|
||||||
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
|
||||||
entity.Property(x => x.Status).HasMaxLength(32).IsRequired();
|
|
||||||
entity.Property(x => x.Keywords).HasMaxLength(1000);
|
|
||||||
entity.Property(x => x.ProviderConfigJson).IsRequired(false);
|
|
||||||
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
|
|
||||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
|
||||||
entity.HasIndex(x => x.Status);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity<JobSearchResultEntity>(entity =>
|
|
||||||
{
|
|
||||||
entity.ToTable("JobSearchResults");
|
|
||||||
entity.HasKey(x => x.Id);
|
|
||||||
entity.Property(x => x.Id).HasMaxLength(64);
|
|
||||||
entity.Property(x => x.SessionId).HasMaxLength(64).IsRequired();
|
|
||||||
entity.Property(x => x.ProviderName).HasMaxLength(128);
|
|
||||||
entity.Property(x => x.JobUrl).HasMaxLength(2048);
|
|
||||||
entity.Property(x => x.JobTitle).HasMaxLength(512);
|
|
||||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
|
||||||
entity.HasIndex(x => x.SessionId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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,22 +0,0 @@
|
|||||||
namespace CvSearch.Models.Data.Entities;
|
|
||||||
|
|
||||||
public sealed class JobSearchSessionEntity
|
|
||||||
{
|
|
||||||
public string Id { get; set; } = string.Empty;
|
|
||||||
public string TokenId { get; set; } = string.Empty;
|
|
||||||
public string CvDocumentId { get; set; } = string.Empty;
|
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
public string Status { get; set; } = JobSearchStatus.Pending;
|
|
||||||
public string Keywords { get; set; } = string.Empty;
|
|
||||||
public string? ProviderConfigJson { get; set; }
|
|
||||||
public string Language { get; set; } = "en";
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class JobSearchStatus
|
|
||||||
{
|
|
||||||
public const string Pending = "Pending";
|
|
||||||
public const string Processing = "Processing";
|
|
||||||
public const string Done = "Done";
|
|
||||||
public const string Failed = "Failed";
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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 string Language { get; set; } = "en";
|
|
||||||
public DateTime ExpiresAt { get; set; }
|
|
||||||
public bool Used { get; set; }
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace CvSearch.Models.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddJobSearchTables : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.EnsureSchema(
|
|
||||||
name: "cvSearch");
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "JobSearchResults",
|
|
||||||
schema: "cvSearch",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
SessionId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
ProviderName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
|
||||||
JobUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
|
|
||||||
JobTitle = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
|
|
||||||
JobText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
|
||||||
Score = table.Column<int>(type: "int", nullable: false),
|
|
||||||
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_JobSearchResults", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "JobSearchSessions",
|
|
||||||
schema: "cvSearch",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
TokenId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
|
||||||
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
|
||||||
Keywords = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
|
||||||
ProviderConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_JobSearchSessions", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "JobSearchTokens",
|
|
||||||
schema: "cvSearch",
|
|
||||||
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),
|
|
||||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
|
||||||
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
|
||||||
Used = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_JobSearchTokens", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_JobSearchResults_SessionId",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchResults",
|
|
||||||
column: "SessionId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_JobSearchSessions_Status",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchSessions",
|
|
||||||
column: "Status");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "JobSearchResults",
|
|
||||||
schema: "cvSearch");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "JobSearchSessions",
|
|
||||||
schema: "cvSearch");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "JobSearchTokens",
|
|
||||||
schema: "cvSearch");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace CvSearch.Models.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddLanguageToJobSearchEntities : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Language",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchTokens",
|
|
||||||
type: "nvarchar(8)",
|
|
||||||
maxLength: 8,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "en");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Language",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchSessions",
|
|
||||||
type: "nvarchar(8)",
|
|
||||||
maxLength: 8,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "en");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Language",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchTokens");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Language",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchSessions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
namespace CvSearch.Models.Settings;
|
|
||||||
|
|
||||||
public sealed class JobSearchSettings
|
|
||||||
{
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
public string JobSearchLinkBaseUrl { get; set; } = string.Empty;
|
|
||||||
public int TokenExpiryDays { get; set; } = 7;
|
|
||||||
public int MinMatchScore { get; set; } = 15;
|
|
||||||
public int MaxJobsToMatch { get; set; } = 15;
|
|
||||||
public List<JobProviderConfig> Providers { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class JobProviderConfig
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
public string SearchUrlTemplate { get; set; } = string.Empty;
|
|
||||||
public string JobLinkContains { get; set; } = string.Empty;
|
|
||||||
public List<string> InitialKeywords { get; set; } = [];
|
|
||||||
public int MaxResults { get; set; } = 20;
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
using EmailApi.Models.Requests;
|
using Email.Models.Requests;
|
||||||
using Refit;
|
using Refit;
|
||||||
|
|
||||||
namespace EmailApi.Models.Clients;
|
namespace Email.Models.Clients;
|
||||||
|
|
||||||
public interface IEmailApiClient
|
public interface IEmailApiClient
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace EmailApi.Models.Requests;
|
namespace Email.Models.Requests;
|
||||||
|
|
||||||
public sealed class SendEmailRequest
|
public sealed class SendEmailRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace EmailApi.Models.Settings;
|
namespace Email.Models.Settings;
|
||||||
|
|
||||||
public sealed class EmailApiSettings
|
public sealed class EmailApiSettings
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
namespace Models.Settings;
|
namespace Email.Models.Settings;
|
||||||
|
|
||||||
public sealed class SmtpSettings
|
public sealed class SmtpSettings
|
||||||
{
|
{
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
using EmailApi.Models.Requests;
|
using Api.Services;
|
||||||
using EmailApi.Services;
|
using Email.Models.Requests;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Swashbuckle.AspNetCore.Annotations;
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
|
||||||
namespace EmailApi.Controllers;
|
namespace Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal email relay. Accepts an HTML body fragment from trusted callers
|
||||||
|
/// (api, cv-search-job), wraps it in the branded HTML shell, and dispatches
|
||||||
|
/// via SMTP. Protected by X-Internal-Api-Key.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/email")]
|
[Route("api/email")]
|
||||||
public sealed class EmailController : ControllerBase
|
public sealed class EmailController : ControllerBase
|
||||||
@@ -13,9 +18,27 @@ public sealed class EmailController : ControllerBase
|
|||||||
|
|
||||||
public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher;
|
public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an HTML email via SMTP. The supplied body fragment is wrapped in
|
||||||
|
/// the branded HTML shell before dispatch. Attachments are resolved from
|
||||||
|
/// the shared file storage volume using the relative path in
|
||||||
|
/// <see cref="SendEmailRequest.AttachmentPath"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Email payload: recipients, subject, HTML body fragment, optional attachment path.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>204 No Content on success.</returns>
|
||||||
[HttpPost("send")]
|
[HttpPost("send")]
|
||||||
[SwaggerOperation(Summary = "Send an HTML email via SMTP")]
|
[SwaggerOperation(
|
||||||
|
Summary = "Send an HTML email via SMTP",
|
||||||
|
Description = "Wraps the provided HTML body in the branded shell and sends via SMTP. " +
|
||||||
|
"If AttachmentPath is set, resolves the file from the shared file-storage volume. " +
|
||||||
|
"Returns 204 on success; 400 when the request body is invalid; 500 on SMTP failure.")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status204NoContent, "Email dispatched successfully")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Request body is missing or invalid")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status500InternalServerError, "SMTP dispatch failed")]
|
||||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||||
public async Task<IActionResult> Send([FromBody] SendEmailRequest request, CancellationToken ct)
|
public async Task<IActionResult> Send([FromBody] SendEmailRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await _dispatcher.SendAsync(request, ct);
|
await _dispatcher.SendAsync(request, ct);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ ARG BUILD_CONFIGURATION=Release
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY Apis/email-api/email-api.csproj Apis/email-api/
|
COPY Apis/email-api/email-api.csproj Apis/email-api/
|
||||||
|
COPY Apis/email-data/email-data.csproj Apis/email-data/
|
||||||
|
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
|
||||||
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
|
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
|
||||||
COPY Apis/api-models/api-models.csproj Apis/api-models/
|
COPY Apis/api-models/api-models.csproj Apis/api-models/
|
||||||
COPY Apis/common/common.csproj Apis/common/
|
COPY Apis/common/common.csproj Apis/common/
|
||||||
@@ -13,6 +15,8 @@ COPY Directory.Packages.props ./
|
|||||||
RUN dotnet restore Apis/email-api/email-api.csproj
|
RUN dotnet restore Apis/email-api/email-api.csproj
|
||||||
|
|
||||||
COPY Apis/email-api/ Apis/email-api/
|
COPY Apis/email-api/ Apis/email-api/
|
||||||
|
COPY Apis/email-data/ Apis/email-data/
|
||||||
|
COPY Apis/shared-data/ Apis/shared-data/
|
||||||
COPY Apis/email-api-models/ Apis/email-api-models/
|
COPY Apis/email-api-models/ Apis/email-api-models/
|
||||||
COPY Apis/api-models/ Apis/api-models/
|
COPY Apis/api-models/ Apis/api-models/
|
||||||
COPY Apis/common/ Apis/common/
|
COPY Apis/common/ Apis/common/
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using EmailApi.Services;
|
using Email.Data;
|
||||||
|
using Email.Data.Repositories;
|
||||||
|
using Email.Data.Repositories.Contracts;
|
||||||
|
using Email.Data.Services;
|
||||||
|
using Api.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Email.Models.Settings;
|
||||||
using Models.Settings;
|
using Models.Settings;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using StartupHelpers;
|
using StartupHelpers;
|
||||||
@@ -24,6 +30,19 @@ try
|
|||||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||||
|
|
||||||
|
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.AddScoped<SmtpEmailDispatcher>();
|
builder.Services.AddScoped<SmtpEmailDispatcher>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
@@ -32,14 +51,20 @@ try
|
|||||||
|
|
||||||
app.UseDefaultSerilogRequestLogging();
|
app.UseDefaultSerilogRequestLogging();
|
||||||
app.UseJsonExceptionHandler(ServiceName);
|
app.UseJsonExceptionHandler(ServiceName);
|
||||||
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
|
|
||||||
|
|
||||||
app.UseInternalApiKeyProtection();
|
app.UseInternalApiKeyProtection();
|
||||||
|
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
|
||||||
|
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
Log.Information("Running EF Core migrations if any");
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EmailDbContext>();
|
||||||
|
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,58 +1,46 @@
|
|||||||
using EmailApi.Models.Requests;
|
using Email.Data.Services;
|
||||||
|
using Email.Models.Requests;
|
||||||
using MailKit.Net.Smtp;
|
using MailKit.Net.Smtp;
|
||||||
using MailKit.Security;
|
using MailKit.Security;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
|
using Email.Models.Settings;
|
||||||
using Models.Settings;
|
using Models.Settings;
|
||||||
|
|
||||||
namespace EmailApi.Services;
|
namespace Api.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wraps an HTML body fragment in the branded HTML shell and sends the resulting email via SMTP using MailKit.
|
||||||
|
/// Attaches files from the shared file-storage volume when an attachment path is provided.
|
||||||
|
/// </summary>
|
||||||
public sealed class SmtpEmailDispatcher
|
public sealed class SmtpEmailDispatcher
|
||||||
{
|
{
|
||||||
private readonly SmtpSettings _smtp;
|
private readonly SmtpSettings _smtp;
|
||||||
private readonly FileStorageSettings _fileStorage;
|
private readonly FileStorageSettings _fileStorage;
|
||||||
|
private readonly IEmailTemplateService _templates;
|
||||||
private readonly ILogger<SmtpEmailDispatcher> _log;
|
private readonly ILogger<SmtpEmailDispatcher> _log;
|
||||||
private readonly string _environmentName;
|
private readonly string _environmentName;
|
||||||
|
|
||||||
private static readonly string HtmlShellStart = """
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
|
|
||||||
<body style="margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif">
|
|
||||||
<table width="100%" cellpadding="0" cellspacing="0" style="padding:20px 0">
|
|
||||||
<tr><td align="center">
|
|
||||||
<table width="600" cellpadding="0" cellspacing="0"
|
|
||||||
style="background:#ffffff;border-radius:8px;max-width:600px">
|
|
||||||
<tr><td style="background:#2c5282;padding:24px 32px;border-radius:8px 8px 0 0">
|
|
||||||
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600">myAi</h1>
|
|
||||||
</td></tr>
|
|
||||||
<tr><td style="padding:32px">
|
|
||||||
""";
|
|
||||||
|
|
||||||
private static readonly string HtmlShellEnd = """
|
|
||||||
</td></tr>
|
|
||||||
<tr><td style="background:#f8f9fa;padding:16px 32px;text-align:center;
|
|
||||||
color:#6c757d;font-size:12px;border-radius:0 0 8px 8px">
|
|
||||||
Automated message from myAi.
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</td></tr>
|
|
||||||
</table>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
""";
|
|
||||||
|
|
||||||
public SmtpEmailDispatcher(
|
public SmtpEmailDispatcher(
|
||||||
IOptions<SmtpSettings> smtp,
|
IOptions<SmtpSettings> smtp,
|
||||||
IOptions<FileStorageSettings> fileStorage,
|
IOptions<FileStorageSettings> fileStorage,
|
||||||
|
IEmailTemplateService templates,
|
||||||
ILogger<SmtpEmailDispatcher> log)
|
ILogger<SmtpEmailDispatcher> log)
|
||||||
{
|
{
|
||||||
_smtp = smtp.Value;
|
_smtp = smtp.Value;
|
||||||
_fileStorage = fileStorage.Value;
|
_fileStorage = fileStorage.Value;
|
||||||
|
_templates = templates;
|
||||||
_log = log;
|
_log = log;
|
||||||
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a <see cref="MimeMessage"/> from <paramref name="req"/>, wraps the body in the HTML shell,
|
||||||
|
/// optionally attaches a file, and sends via the configured SMTP server.
|
||||||
|
/// Logs a warning and returns without throwing when the SMTP host is not configured.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="req">Email payload containing recipients, subject, HTML body, and optional attachment path.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
public async Task SendAsync(SendEmailRequest req, CancellationToken ct)
|
public async Task SendAsync(SendEmailRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(_smtp.Host))
|
if (string.IsNullOrWhiteSpace(_smtp.Host))
|
||||||
@@ -70,11 +58,14 @@ public sealed class SmtpEmailDispatcher
|
|||||||
if (!string.IsNullOrWhiteSpace(req.ReplyTo))
|
if (!string.IsNullOrWhiteSpace(req.ReplyTo))
|
||||||
msg.ReplyTo.Add(MailboxAddress.Parse(req.ReplyTo));
|
msg.ReplyTo.Add(MailboxAddress.Parse(req.ReplyTo));
|
||||||
|
|
||||||
msg.Subject = $"[{_environmentName}] {req.Subject}".Trim();
|
msg.Subject = req.Subject.Trim();
|
||||||
|
|
||||||
|
var shellStart = _templates.Get("email.html-shell.start", "*");
|
||||||
|
var shellEnd = _templates.Get("email.html-shell.end", "*");
|
||||||
|
|
||||||
var builder = new BodyBuilder
|
var builder = new BodyBuilder
|
||||||
{
|
{
|
||||||
HtmlBody = HtmlShellStart + req.HtmlBody + HtmlShellEnd
|
HtmlBody = shellStart + req.HtmlBody + shellEnd
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(req.AttachmentPath))
|
if (!string.IsNullOrWhiteSpace(req.AttachmentPath))
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"Serilog": {
|
||||||
|
"Using": [
|
||||||
|
"Serilog.Sinks.Console",
|
||||||
|
"Serilog.Sinks.File"
|
||||||
|
],
|
||||||
|
"MinimumLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Override": {
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.AspNetCore.Hosting": "Information",
|
||||||
|
"Microsoft.AspNetCore.Routing": "Warning",
|
||||||
|
"System.Net.Http.HttpClient": "Warning",
|
||||||
|
"EmailApi": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"WriteTo": [
|
||||||
|
{
|
||||||
|
"Name": "Console",
|
||||||
|
"Args": {
|
||||||
|
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"Name": "File",
|
||||||
|
"Args": {
|
||||||
|
"path": "logs/email-api-.log",
|
||||||
|
"rollingInterval": "Day",
|
||||||
|
"retainedFileCountLimit": 30,
|
||||||
|
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Enrich": [
|
||||||
|
"FromLogContext",
|
||||||
|
"WithMachineName",
|
||||||
|
"WithEnvironmentName"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Warning",
|
||||||
|
"Microsoft.AspNetCore.Hosting": "Information",
|
||||||
|
"Microsoft.AspNetCore.Routing": "Warning",
|
||||||
|
"System.Net.Http.HttpClient": "Warning",
|
||||||
|
"EmailApi": "Information"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"LogEnvironmentOnStartup": true,
|
||||||
|
"AllowedHosts": "*",
|
||||||
|
"KeyVault": {
|
||||||
|
"VaultUri": "",
|
||||||
|
"Enabled": false
|
||||||
|
},
|
||||||
|
"Database": {
|
||||||
|
"Host": "localhost",
|
||||||
|
"Port": 1433,
|
||||||
|
"Name": "MyAiDb",
|
||||||
|
"User": "sa",
|
||||||
|
"Password": "",
|
||||||
|
"TrustServerCertificate": true
|
||||||
|
},
|
||||||
|
"InternalApi": {
|
||||||
|
"ApiKey": "",
|
||||||
|
"RequireApiKey": true
|
||||||
|
},
|
||||||
|
"Smtp": {
|
||||||
|
"Host": "mail.easysoft.ro",
|
||||||
|
"Port": 587,
|
||||||
|
"Username": "no-reply@easysoft.ro",
|
||||||
|
"Password": "",
|
||||||
|
"UseStartTls": true
|
||||||
|
},
|
||||||
|
"FileStorage": {
|
||||||
|
"Path": "Files"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MailKit" />
|
<PackageReference Include="MailKit" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
||||||
<PackageReference Include="Serilog.AspNetCore" />
|
<PackageReference Include="Serilog.AspNetCore" />
|
||||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" />
|
<PackageReference Include="Serilog.Sinks.Console" />
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||||
<ProjectReference Include="..\api-models\api-models.csproj" />
|
<ProjectReference Include="..\api-models\api-models.csproj" />
|
||||||
<ProjectReference Include="..\common\common.csproj" />
|
<ProjectReference Include="..\common\common.csproj" />
|
||||||
|
<ProjectReference Include="..\email-data\email-data.csproj" />
|
||||||
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
|
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Email.Data.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Email.Data;
|
||||||
|
|
||||||
|
public sealed class EmailDbContext : DbContext
|
||||||
|
{
|
||||||
|
public const string SchemaName = MigrationConstants.SchemaName;
|
||||||
|
public const string MigrationTableName = MigrationConstants.MigrationTableName;
|
||||||
|
|
||||||
|
public EmailDbContext(DbContextOptions<EmailDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
public DbSet<EmailTemplateEntity> Templates => Set<EmailTemplateEntity>();
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
{
|
||||||
|
base.OnConfiguring(optionsBuilder);
|
||||||
|
// Configure migration history table to use schema-qualified name: [email].[_Migrations]
|
||||||
|
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.HasDefaultSchema(SchemaName);
|
||||||
|
|
||||||
|
modelBuilder.Entity<EmailTemplateEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("Templates");
|
||||||
|
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()");
|
||||||
|
entity.Property(x => x.OperatorCopy).HasMaxLength(256).HasDefaultValue(string.Empty);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace Email.Data.Entities;
|
||||||
|
|
||||||
|
// composite PK (Key + Language) — BaseEntity not applicable
|
||||||
|
public sealed class EmailTemplateEntity
|
||||||
|
{
|
||||||
|
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; }
|
||||||
|
public string OperatorCopy { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user