Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e260982a91 | |||
| ba92c9f793 | |||
| 8878efe184 | |||
| 6fad147650 | |||
| 8126e7c112 | |||
| 26b13f6dbf | |||
| 270deaaef6 | |||
| 2d9f05cf33 | |||
| 0829eba2b1 |
@@ -11,9 +11,11 @@ env:
|
||||
API_IMAGE: apps/myai-api
|
||||
CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api
|
||||
RAG_API_IMAGE: apps/myai-rag-api
|
||||
EMAIL_API_IMAGE: apps/myai-email-api
|
||||
WEB_IMAGE: apps/myai-web
|
||||
CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job
|
||||
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
|
||||
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
|
||||
IMAGE_TAG: staging
|
||||
|
||||
jobs:
|
||||
@@ -45,6 +47,10 @@ jobs:
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" .
|
||||
@@ -57,6 +63,10 @@ jobs:
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}"
|
||||
@@ -69,6 +79,10 @@ jobs:
|
||||
run: |
|
||||
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
|
||||
run: |
|
||||
docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}"
|
||||
@@ -79,4 +93,8 @@ jobs:
|
||||
|
||||
- name: Push CV search job image
|
||||
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; }
|
||||
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
|
||||
public string? Language { get; set; }
|
||||
/// <summary>Client IP address — set by the api layer from the HTTP context before forwarding. Not supplied by the browser.</summary>
|
||||
public string? ClientIpAddress { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
namespace Models.Settings
|
||||
{
|
||||
public class SmtpSettings
|
||||
{
|
||||
public string Host { get; set; } = "";
|
||||
public int Port { get; set; } = 587;
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public bool UseStartTls { get; set; } = true;
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ namespace Api.Controllers
|
||||
{
|
||||
Error = "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)
|
||||
{
|
||||
_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.");
|
||||
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)
|
||||
{
|
||||
_logger.LogError(ex, "CV upload proxy request failed.");
|
||||
@@ -153,6 +163,7 @@ public sealed class CvMatcherController : ControllerBase
|
||||
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
|
||||
}
|
||||
|
||||
request.ClientIpAddress = userIp;
|
||||
_logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}",
|
||||
request.CvDocumentId,
|
||||
!string.IsNullOrWhiteSpace(request.JobUrl),
|
||||
@@ -171,10 +182,13 @@ public sealed class CvMatcherController : ControllerBase
|
||||
try
|
||||
{
|
||||
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 },
|
||||
ct);
|
||||
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
|
||||
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
|
||||
if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
|
||||
{
|
||||
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
|
||||
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -196,6 +210,16 @@ public sealed class CvMatcherController : ControllerBase
|
||||
_logger.LogWarning("Job match proxy request was cancelled by the client.");
|
||||
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)
|
||||
{
|
||||
_logger.LogError(ex, "Job match proxy request failed.");
|
||||
@@ -223,38 +247,24 @@ public sealed class CvMatcherController : ControllerBase
|
||||
{
|
||||
var result = await _jobSearchApi.StartSearchAsync(t, ct);
|
||||
var lang = "en";
|
||||
var html = result.Status switch
|
||||
var (title, message) = result.Status switch
|
||||
{
|
||||
StartJobSearchStatus.Started =>
|
||||
HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
|
||||
StartJobSearchStatus.AlreadyUsed =>
|
||||
HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.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))
|
||||
StartJobSearchStatus.Started => (_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.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
|
||||
_ => (_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)
|
||||
{
|
||||
_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)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using Common.Responses;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
@@ -17,42 +18,44 @@ namespace Api.Controllers
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[EnableCors("FrontendOnly")]
|
||||
[EnableRateLimiting("download")]
|
||||
public sealed class FileDownloadController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<FileDownloadController> _logger;
|
||||
private readonly FileStorageSettings _fileStorageSettings;
|
||||
private readonly IContentTypeProvider _contentTypeProvider;
|
||||
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(
|
||||
ILogger<FileDownloadController> logger,
|
||||
IOptions<FileStorageSettings> fileStorageSettings,
|
||||
IContentTypeProvider contentTypeProvider,
|
||||
IEmailSender emailSender)
|
||||
IEmailSender emailSender,
|
||||
ICaptchaVerifier captcha)
|
||||
{
|
||||
_logger = logger;
|
||||
_fileStorageSettings = fileStorageSettings.Value;
|
||||
_contentTypeProvider = contentTypeProvider;
|
||||
_emailSender = emailSender;
|
||||
_captcha = captcha;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a file with support for resume (range requests) and chunked transfer.
|
||||
/// 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.
|
||||
/// </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>
|
||||
/// <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?}")]
|
||||
[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.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.Status416RangeNotSatisfiable, "Requested byte range is invalid")]
|
||||
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")]
|
||||
@@ -62,10 +65,30 @@ namespace Api.Controllers
|
||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)]
|
||||
[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
|
||||
{
|
||||
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))
|
||||
{
|
||||
fileName = _fileStorageSettings.DefaultFileName;
|
||||
@@ -103,7 +126,6 @@ namespace Api.Controllers
|
||||
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
|
||||
contentType = "application/octet-stream";
|
||||
|
||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
|
||||
+7
-2
@@ -2,10 +2,13 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
|
||||
COPY Directory.Packages.props ./
|
||||
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/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/common/common.csproj Apis/common/
|
||||
COPY Apis/myai-data/myai-data.csproj Apis/myai-data/
|
||||
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
|
||||
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
|
||||
@@ -13,9 +16,11 @@ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
|
||||
RUN dotnet restore Apis/api/api.csproj
|
||||
|
||||
COPY Apis/api/ Apis/api/
|
||||
COPY Apis/common/ Apis/common/
|
||||
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/common/ Apis/common/
|
||||
COPY Apis/myai-data/ Apis/myai-data/
|
||||
COPY Apis/shared-data/ Apis/shared-data/
|
||||
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
||||
|
||||
+35
-2
@@ -1,6 +1,12 @@
|
||||
using System.Reflection;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using Email.Data;
|
||||
using Email.Data.Repositories;
|
||||
using Email.Data.Repositories.Contracts;
|
||||
using Email.Data.Services;
|
||||
using Email.Models.Clients;
|
||||
using Email.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Models.Settings;
|
||||
using MyAi.Data;
|
||||
@@ -29,10 +35,10 @@ try
|
||||
builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google"));
|
||||
builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact"));
|
||||
builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe"));
|
||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
|
||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
builder.Services.Configure<EmailApiSettings>(builder.Configuration.GetSection("EmailApi"));
|
||||
|
||||
builder.Services.AddDbContext<MyAiDbContext>(options =>
|
||||
{
|
||||
@@ -45,10 +51,34 @@ try
|
||||
});
|
||||
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.AddSingleton<IEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddSingleton<IEmailSender, EmailApiEmailSender>();
|
||||
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
||||
|
||||
static void ConfigureEmailApiClient(IServiceProvider sp, HttpClient client)
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var baseUrl = config["EmailApi:BaseUrl"] ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(baseUrl))
|
||||
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
|
||||
var key = config["EmailApi:InternalApiKey"];
|
||||
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
}
|
||||
|
||||
static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client)
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
@@ -60,6 +90,9 @@ try
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
}
|
||||
|
||||
builder.Services.AddRefitClient<IEmailApiClient>()
|
||||
.ConfigureHttpClient(ConfigureEmailApiClient);
|
||||
|
||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
||||
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
|
||||
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
using Api.Services.Contracts.Models;
|
||||
using Api.Services.Contracts.Models;
|
||||
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a reCAPTCHA token against the Google verification API.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,65 @@
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Models.Requests;
|
||||
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstraction for sending transactional emails from the public API.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Email.Data.Services;
|
||||
using Email.Models.Clients;
|
||||
using Email.Models.Requests;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Models.Requests;
|
||||
using Models.Settings;
|
||||
using System.Net;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IEmailSender"/> by delegating all email dispatch to the internal email-api service via Refit.
|
||||
/// </summary>
|
||||
public sealed class EmailApiEmailSender : IEmailSender
|
||||
{
|
||||
private readonly IEmailApiClient _emailApi;
|
||||
private readonly ContactSettings _contact;
|
||||
private readonly SubscribeSettings _subscribe;
|
||||
private readonly FileStorageSettings _fileStorage;
|
||||
private readonly IEmailTemplateService _emailTemplates;
|
||||
private readonly ILogger<EmailApiEmailSender> _log;
|
||||
|
||||
public EmailApiEmailSender(
|
||||
IEmailApiClient emailApi,
|
||||
IOptions<ContactSettings> contact,
|
||||
IOptions<SubscribeSettings> subscribe,
|
||||
IOptions<FileStorageSettings> fileStorage,
|
||||
IEmailTemplateService emailTemplates,
|
||||
ILogger<EmailApiEmailSender> log)
|
||||
{
|
||||
_emailApi = emailApi;
|
||||
_contact = contact.Value;
|
||||
_subscribe = subscribe.Value;
|
||||
_fileStorage = fileStorage.Value;
|
||||
_emailTemplates = emailTemplates;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
|
||||
{
|
||||
_log.LogDebug("Contact email skipped - ToEmail not configured");
|
||||
throw new InvalidOperationException("Contact email recipient is not configured.");
|
||||
}
|
||||
|
||||
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
|
||||
req.Email, _contact.ToEmail);
|
||||
|
||||
var htmlBody = $"""
|
||||
<h2 style="color:#2c5282;margin:0 0 20px">New Contact Message</h2>
|
||||
<table cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:24px">
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;width:100px;border:1px solid #dee2e6;color:#495057">Name</td>
|
||||
<td style="border:1px solid #dee2e6">{req.Name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Email</td>
|
||||
<td style="border:1px solid #dee2e6">{req.Email}</td>
|
||||
</tr>
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Subject</td>
|
||||
<td style="border:1px solid #dee2e6">{req.Subject}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3 style="color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px">Message</h3>
|
||||
<p style="color:#495057;line-height:1.7;white-space:pre-wrap">{req.Message}</p>
|
||||
""";
|
||||
|
||||
await _emailApi.SendAsync(new SendEmailRequest
|
||||
{
|
||||
To = [_contact.ToEmail],
|
||||
ReplyTo = req.Email,
|
||||
Subject = $"{_contact.SubjectPrefix} {req.Subject}".Trim(),
|
||||
HtmlBody = htmlBody
|
||||
}, ct);
|
||||
|
||||
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
|
||||
{
|
||||
_log.LogDebug("Subscription email skipped - ToEmail not configured");
|
||||
throw new InvalidOperationException("Subscription email recipient is not configured.");
|
||||
}
|
||||
|
||||
_log.LogInformation("Processing subscription request for {Email}", req.Email);
|
||||
|
||||
var htmlBody = $"""
|
||||
<h2 style="color:#2c5282;margin:0 0 20px">New Subscription Request</h2>
|
||||
<p style="color:#495057">A new user has subscribed:</p>
|
||||
<table cellpadding="10" cellspacing="0" style="border-collapse:collapse;margin-top:12px">
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057;padding:10px 16px">Email</td>
|
||||
<td style="border:1px solid #dee2e6;padding:10px 16px">{req.Email}</td>
|
||||
</tr>
|
||||
</table>
|
||||
""";
|
||||
|
||||
await _emailApi.SendAsync(new SendEmailRequest
|
||||
{
|
||||
To = [_subscribe.ToEmail],
|
||||
ReplyTo = req.Email,
|
||||
Subject = _subscribe.SubjectPrefix.Trim(),
|
||||
HtmlBody = htmlBody
|
||||
}, ct);
|
||||
|
||||
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
|
||||
{
|
||||
_log.LogDebug("File download notification skipped - ToEmail not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
|
||||
|
||||
var htmlBody = $"""
|
||||
<h2 style="color:#2c5282;margin:0 0 20px">File Download Notification</h2>
|
||||
<table cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse">
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;width:120px;border:1px solid #dee2e6;color:#495057">File</td>
|
||||
<td style="border:1px solid #dee2e6">{fileName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Downloaded at</td>
|
||||
<td style="border:1px solid #dee2e6">{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</td>
|
||||
</tr>
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td>
|
||||
<td style="border:1px solid #dee2e6">{userIp ?? "Unknown"}</td>
|
||||
</tr>
|
||||
</table>
|
||||
""";
|
||||
|
||||
await _emailApi.SendAsync(new SendEmailRequest
|
||||
{
|
||||
To = [_fileStorage.ToEmail],
|
||||
Subject = $"{_fileStorage.SubjectPrefix} {fileName}".Trim(),
|
||||
HtmlBody = htmlBody
|
||||
}, ct);
|
||||
|
||||
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
|
||||
{
|
||||
var operatorCopy = _emailTemplates.GetOperatorCopy("email.match.subject", "en");
|
||||
|
||||
var recipients = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(explicitTo))
|
||||
recipients.Add(explicitTo);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(operatorCopy) &&
|
||||
!recipients.Any(x => string.Equals(x, operatorCopy, StringComparison.OrdinalIgnoreCase)))
|
||||
recipients.Add(operatorCopy);
|
||||
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
_log.LogDebug("Match email skipped - no recipients configured");
|
||||
return;
|
||||
}
|
||||
|
||||
string? relativeAttachment = null;
|
||||
if (!string.IsNullOrWhiteSpace(attachmentPath))
|
||||
relativeAttachment = Path.GetFileName(attachmentPath);
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
|
||||
|
||||
await _emailApi.SendAsync(new SendEmailRequest
|
||||
{
|
||||
To = [recipient],
|
||||
Subject = subject,
|
||||
HtmlBody = body,
|
||||
AttachmentPath = relativeAttachment
|
||||
}, ct);
|
||||
|
||||
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
||||
{
|
||||
// Build HTML lists for strengths, gaps, and recommendations
|
||||
var strengths = result.Strengths?.Count > 0
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
|
||||
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||
|
||||
var gaps = result.Gaps?.Count > 0
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
|
||||
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||
|
||||
var recommendations = result.Recommendations?.Count > 0
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
|
||||
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||
|
||||
// Render the HTML template with substituted values
|
||||
// email.match.body is now stored as HTML in the database
|
||||
var body = _emailTemplates.Render("email.match.body", language,
|
||||
("cvDocumentId", cvDocumentId),
|
||||
("jobLabel", jobLabel ?? "N/A"),
|
||||
("jobUrl", result.JobUrl ?? "N/A"),
|
||||
("score", result.Score.ToString()),
|
||||
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
|
||||
("strengths", strengths),
|
||||
("gaps", gaps),
|
||||
("recommendations", recommendations));
|
||||
|
||||
// Append the job search footer if link is provided
|
||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||
{
|
||||
body += _emailTemplates.Render("email.match.job-search-footer", language,
|
||||
("jobSearchLink", jobSearchLink),
|
||||
("expiryDays", expiryDays.ToString()));
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
|
||||
_emailTemplates.Render("email.match.subject", language,
|
||||
("score", score.ToString()),
|
||||
("jobLabel", jobLabel ?? "Job"));
|
||||
}
|
||||
@@ -5,6 +5,9 @@ using Models.Settings;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies reCAPTCHA v2/v3 tokens by calling the Google site-verify API.
|
||||
/// </summary>
|
||||
public sealed class RecaptchaVerifier : ICaptchaVerifier
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
@@ -18,6 +21,7 @@ namespace Api.Services
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct)
|
||||
{
|
||||
_log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown");
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using MimeKit;
|
||||
using Models.Settings;
|
||||
using Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using MyAi.Data.Services;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
public sealed class SmtpEmailSender : IEmailSender
|
||||
{
|
||||
private readonly SmtpSettings _smtp;
|
||||
private readonly ContactSettings _contact;
|
||||
private readonly SubscribeSettings _subscribe;
|
||||
private readonly FileStorageSettings _fileStorage;
|
||||
private readonly ITemplateService _templates;
|
||||
private readonly ILogger<SmtpEmailSender> _log;
|
||||
private readonly string _environmentName;
|
||||
|
||||
public SmtpEmailSender(
|
||||
IOptions<SmtpSettings> smtp,
|
||||
IOptions<ContactSettings> contact,
|
||||
IOptions<SubscribeSettings> subscribe,
|
||||
IOptions<FileStorageSettings> fileStorage,
|
||||
ITemplateService templates,
|
||||
ILogger<SmtpEmailSender> log)
|
||||
{
|
||||
_smtp = smtp.Value;
|
||||
_contact = contact.Value;
|
||||
_subscribe = subscribe.Value;
|
||||
_fileStorage = fileStorage.Value;
|
||||
_templates = templates;
|
||||
_log = log;
|
||||
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||
}
|
||||
|
||||
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
|
||||
{
|
||||
_log.LogDebug("Contact email skipped - ToEmail not configured");
|
||||
throw new InvalidOperationException("Contact email recipient is not configured.");
|
||||
}
|
||||
|
||||
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
|
||||
req.Email, _contact.ToEmail);
|
||||
|
||||
var msg = new MimeMessage();
|
||||
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
|
||||
msg.To.Add(MailboxAddress.Parse(_contact.ToEmail));
|
||||
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
|
||||
msg.Subject = $"{_contact.SubjectPrefix} [{_environmentName}] {req.Subject}".Trim();
|
||||
|
||||
var body =
|
||||
$@"New contact form submission:
|
||||
|
||||
Name: {req.Name}
|
||||
Email: {req.Email}
|
||||
Subject: {req.Subject}
|
||||
|
||||
Message:
|
||||
{req.Message}
|
||||
";
|
||||
|
||||
msg.Body = new TextPart("plain") { Text = body };
|
||||
|
||||
await SendEmailAsync(msg, "contact email", ct);
|
||||
|
||||
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
|
||||
}
|
||||
|
||||
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
|
||||
{
|
||||
_log.LogDebug("Subscription email skipped - ToEmail not configured");
|
||||
throw new InvalidOperationException("Subscription email recipient is not configured.");
|
||||
}
|
||||
|
||||
_log.LogInformation("Processing subscription request for {Email}", req.Email);
|
||||
|
||||
var msg = new MimeMessage();
|
||||
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
|
||||
msg.To.Add(MailboxAddress.Parse(_subscribe.ToEmail));
|
||||
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
|
||||
msg.Subject = $"{_subscribe.SubjectPrefix} [{_environmentName}]".Trim();
|
||||
|
||||
var body =
|
||||
$@"New subscription request:
|
||||
|
||||
Email: {req.Email}
|
||||
";
|
||||
|
||||
msg.Body = new TextPart("plain") { Text = body };
|
||||
|
||||
await SendEmailAsync(msg, "subscription email", ct);
|
||||
|
||||
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
|
||||
}
|
||||
|
||||
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
|
||||
{
|
||||
_log.LogDebug("File download notification skipped - ToEmail not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
|
||||
|
||||
var msg = new MimeMessage();
|
||||
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
|
||||
msg.To.Add(MailboxAddress.Parse(_fileStorage.ToEmail));
|
||||
msg.Subject = $"{_fileStorage.SubjectPrefix} [{_environmentName}] {fileName}".Trim();
|
||||
|
||||
var body =
|
||||
$@"File download notification:
|
||||
|
||||
File: {fileName}
|
||||
Downloaded at: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
|
||||
IP Address: {userIp ?? "Unknown"}
|
||||
";
|
||||
|
||||
msg.Body = new TextPart("plain") { Text = body };
|
||||
|
||||
await SendEmailAsync(msg, "file download notification email", ct);
|
||||
|
||||
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the SMTP server and authenticates if credentials are configured.
|
||||
/// </summary>
|
||||
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
|
||||
{
|
||||
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
|
||||
_log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}",
|
||||
_smtp.Host, _smtp.Port, tls);
|
||||
|
||||
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_smtp.Username))
|
||||
{
|
||||
_log.LogDebug("Authenticating with SMTP server as {Username}", _smtp.Username);
|
||||
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an email message using SMTP.
|
||||
/// </summary>
|
||||
/// <param name="message">The email message to send.</param>
|
||||
/// <param name="messageType">Description of the message type for logging purposes.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
private async Task SendEmailAsync(MimeMessage message, string messageType, CancellationToken ct)
|
||||
{
|
||||
using var client = new SmtpClient();
|
||||
|
||||
await ConnectAndAuthenticateAsync(client, ct);
|
||||
|
||||
_log.LogDebug("Sending {MessageType} message", messageType);
|
||||
await client.SendAsync(message, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
}
|
||||
|
||||
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
|
||||
{
|
||||
var recipients = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(explicitTo))
|
||||
{
|
||||
recipients.Add(explicitTo);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_contact.ToEmail) &&
|
||||
!recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
recipients.Add(_contact.ToEmail);
|
||||
}
|
||||
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
_log.LogDebug("Match email skipped - no recipients configured (user email and Contact:ToEmail missing)");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
|
||||
|
||||
var msg = new MimeMessage();
|
||||
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
|
||||
msg.To.Add(MailboxAddress.Parse(recipient));
|
||||
msg.Subject = $"[{_environmentName}] {subject}".Trim();
|
||||
|
||||
var builder = new BodyBuilder
|
||||
{
|
||||
TextBody = body
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
|
||||
{
|
||||
builder.Attachments.Add(attachmentPath);
|
||||
}
|
||||
|
||||
msg.Body = builder.ToMessageBody();
|
||||
|
||||
await SendEmailAsync(msg, "cv match email", ct);
|
||||
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
|
||||
}
|
||||
}
|
||||
|
||||
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
||||
{
|
||||
var strengths = result.Strengths?.Count > 0
|
||||
? "- " + string.Join("\n- ", result.Strengths)
|
||||
: string.Empty;
|
||||
var gaps = result.Gaps?.Count > 0
|
||||
? "- " + string.Join("\n- ", result.Gaps)
|
||||
: string.Empty;
|
||||
var recommendations = result.Recommendations?.Count > 0
|
||||
? "- " + string.Join("\n- ", result.Recommendations)
|
||||
: string.Empty;
|
||||
|
||||
var body = _templates.Render("email.match.body", language,
|
||||
("cvDocumentId", cvDocumentId),
|
||||
("jobLabel", jobLabel ?? "N/A"),
|
||||
("jobUrl", result.JobUrl ?? "N/A"),
|
||||
("score", result.Score.ToString()),
|
||||
("summary", result.Summary ?? string.Empty),
|
||||
("strengths", strengths),
|
||||
("gaps", gaps),
|
||||
("recommendations", recommendations));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||
{
|
||||
body += _templates.Render("email.match.job-search-footer", language,
|
||||
("jobSearchLink", jobSearchLink),
|
||||
("expiryDays", expiryDays.ToString()));
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
|
||||
_templates.Render("email.match.subject", language,
|
||||
("score", score.ToString()),
|
||||
("jobLabel", jobLabel ?? "Job"));
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -19,7 +19,7 @@
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="MailKit" />
|
||||
<!-- MailKit removed — email sending delegated to email-api service -->
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
@@ -36,6 +36,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\api-models\api-models.csproj" />
|
||||
<ProjectReference Include="..\email-data\email-data.csproj" />
|
||||
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
|
||||
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -30,25 +29,6 @@
|
||||
"retainedFileCountLimit": 30,
|
||||
"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": [
|
||||
@@ -110,6 +90,10 @@
|
||||
"BaseUrl": "",
|
||||
"InternalApiKey": ""
|
||||
},
|
||||
"EmailApi": {
|
||||
"BaseUrl": "",
|
||||
"InternalApiKey": ""
|
||||
},
|
||||
"RateLimiting": {
|
||||
"Global": {
|
||||
"PermitLimit": 120,
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
namespace Common.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Standard error body returned by all API endpoints on 4xx and 5xx responses.
|
||||
/// </summary>
|
||||
public sealed class ErrorResponse
|
||||
{
|
||||
/// <summary>Human-readable error message, safe to display directly to the end user for 4xx responses.</summary>
|
||||
public string Error { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Machine-readable error code for programmatic handling (e.g. <c>"captcha_verification_failed"</c>).</summary>
|
||||
public string? Code { get; init; }
|
||||
|
||||
/// <summary>Optional additional detail for debugging (not shown in UI).</summary>
|
||||
public string? Detail { get; init; }
|
||||
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,6 @@ public sealed class CreateJobSearchTokenRequest
|
||||
public string CvDocumentId { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Language { get; set; } = "en";
|
||||
public List<string> Keywords { get; set; } = [];
|
||||
public string? Location { get; set; }
|
||||
}
|
||||
|
||||
@@ -9,5 +9,7 @@
|
||||
public string? Email { get; set; }
|
||||
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,9 @@ namespace CvMatcher.Models.Responses;
|
||||
|
||||
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> Recommendations { 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 string? JobDocumentId { get; set; }
|
||||
public string? JobUrl { get; set; }
|
||||
|
||||
@@ -7,9 +7,12 @@ public sealed class JobSearchSettings
|
||||
public int TokenExpiryDays { get; set; } = 7;
|
||||
public int MinMatchScore { 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 string Name { get; set; } = string.Empty;
|
||||
@@ -18,4 +21,9 @@ public sealed class JobProviderConfig
|
||||
public string JobLinkContains { get; set; } = string.Empty;
|
||||
public List<string> InitialKeywords { get; set; } = [];
|
||||
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))
|
||||
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, ct);
|
||||
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -2,11 +2,13 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
|
||||
COPY Directory.Packages.props ./
|
||||
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-matcher-data/cv-matcher-data.csproj Apis/cv-matcher-data/
|
||||
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/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/
|
||||
COPY Apis/myai-data/myai-data.csproj Apis/myai-data/
|
||||
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
|
||||
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
|
||||
@@ -19,6 +21,7 @@ COPY Apis/cv-search-data/ Apis/cv-search-data/
|
||||
COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/
|
||||
COPY Apis/common/ Apis/common/
|
||||
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
|
||||
COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/
|
||||
COPY Apis/myai-data/ Apis/myai-data/
|
||||
COPY Apis/shared-data/ Apis/shared-data/
|
||||
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
||||
|
||||
@@ -10,11 +10,10 @@ using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Settings;
|
||||
using CvSearch.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MyAi.Data;
|
||||
using MyAi.Data.Services;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using Common.Settings;
|
||||
using PageFetcher.Models;
|
||||
using StartupHelpers;
|
||||
using System.Reflection;
|
||||
|
||||
@@ -38,6 +37,16 @@ try
|
||||
builder.Services.Configure<CvMatcher.Models.Settings.AiSettings>(builder.Configuration.GetSection("Ai"));
|
||||
builder.Services.Configure<MatcherSettings>(builder.Configuration.GetSection("Matcher"));
|
||||
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>()
|
||||
.ConfigureHttpClient((sp, c) =>
|
||||
@@ -52,7 +61,7 @@ try
|
||||
|
||||
builder.Services.AddScoped<IRagApiClient, RagApiClient>();
|
||||
builder.Services.AddHttpClient<IMatcherAiClient, MatcherAiClient>();
|
||||
builder.Services.AddHttpClient<IJobTextExtractor, JobTextExtractor>();
|
||||
builder.Services.AddScoped<IJobTextExtractor, JobTextExtractor>();
|
||||
|
||||
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<IAiPromptsRepository, EfAiPromptsRepository>();
|
||||
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
|
||||
builder.Services.AddScoped<IJobTokenService, JobTokenService>();
|
||||
|
||||
@@ -122,11 +121,6 @@ try
|
||||
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete", ServiceName);
|
||||
app.Run();
|
||||
|
||||
@@ -3,9 +3,34 @@ using CvMatcher.Models.Responses;
|
||||
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates CV indexing, job matching, and job discovery operations.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <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);
|
||||
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
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
|
||||
{
|
||||
/// <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);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Manages one-time job search tokens and the sessions they trigger.
|
||||
/// </summary>
|
||||
public interface IJobTokenService
|
||||
{
|
||||
Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct);
|
||||
/// <summary>
|
||||
/// 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, 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="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// One of the <c>StartJobSearchStatus</c> string constants:
|
||||
/// <c>Started</c>, <c>AlreadyUsed</c>, <c>Expired</c>, or <c>NotFound</c>.
|
||||
/// </returns>
|
||||
Task<string> TriggerStartAsync(string tokenId, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -7,35 +7,38 @@ using CvMatcher.Models.Responses;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MyAi.Data.Services;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates CV upload, RAG indexing, job text extraction, LLM scoring, and result caching.
|
||||
/// </summary>
|
||||
public sealed class CvMatcherService : ICvMatcherService
|
||||
{
|
||||
private readonly IRagApiClient _rag;
|
||||
private readonly IJobTextExtractor _jobTextExtractor;
|
||||
private readonly IMatcherAiClient _ai;
|
||||
private readonly IMatcherRepository _repository;
|
||||
private readonly IAiPromptsRepository _aiPrompts;
|
||||
private readonly MatcherSettings _settings;
|
||||
private readonly ITemplateService _templates;
|
||||
|
||||
public CvMatcherService(
|
||||
IRagApiClient rag,
|
||||
IJobTextExtractor jobTextExtractor,
|
||||
IMatcherAiClient ai,
|
||||
IMatcherRepository repository,
|
||||
IOptions<MatcherSettings> options,
|
||||
ITemplateService templates)
|
||||
IAiPromptsRepository aiPrompts,
|
||||
IOptions<MatcherSettings> options)
|
||||
{
|
||||
_rag = rag;
|
||||
_jobTextExtractor = jobTextExtractor;
|
||||
_ai = ai;
|
||||
_repository = repository;
|
||||
_aiPrompts = aiPrompts;
|
||||
_settings = options.Value;
|
||||
_templates = templates;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken 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)
|
||||
{
|
||||
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);
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct)
|
||||
{
|
||||
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)?
|
||||
.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);
|
||||
if (cached is not null) return cached;
|
||||
@@ -113,10 +123,11 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
var cvText = Limit(cv.Text, 18000);
|
||||
var jobText = Limit(job.Text, 14000);
|
||||
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", "*",
|
||||
("languageName", languageName));
|
||||
var systemPrompt = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", language, ct)
|
||||
?? 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 = $"""
|
||||
CV:
|
||||
@@ -134,17 +145,14 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
result.JobDocumentId = job.Id;
|
||||
result.JobUrl = job.SourceUrl;
|
||||
result.Cached = false;
|
||||
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct);
|
||||
|
||||
//await _email.SendMatchAsync(
|
||||
// email,
|
||||
// $"MyAi.ro CV Match: {result.Score}% - {job.Title}",
|
||||
// BuildEmailBody(cv, job, result),
|
||||
// ct);
|
||||
|
||||
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, email, clientIpAddress, ct);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialises the LLM's JSON output into a <see cref="JobMatchResponse"/>.
|
||||
/// Returns a safe fallback response instead of throwing when the JSON cannot be parsed.
|
||||
/// </summary>
|
||||
private static JobMatchResponse ParseResult(string json)
|
||||
{
|
||||
try
|
||||
@@ -165,48 +173,28 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a descriptive search query from the CV text for use in vector similarity search.
|
||||
/// </summary>
|
||||
private static string BuildCvSearchProfile(string cvText)
|
||||
{
|
||||
var text = Limit(cvText, 10000);
|
||||
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)
|
||||
{
|
||||
var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140);
|
||||
return first ?? "Job description";
|
||||
}
|
||||
|
||||
/// <summary>Returns the base language code, lower-cased, defaulting to <c>"en"</c>.</summary>
|
||||
private static string NormalizeLanguage(string? language) =>
|
||||
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
|
||||
|
||||
private static string LanguageName(string language) => language switch
|
||||
{
|
||||
"ro" => "Romanian",
|
||||
"en" => "English",
|
||||
_ => "English"
|
||||
};
|
||||
|
||||
/// <summary>Truncates <paramref name="value"/> to at most <paramref name="max"/> characters.</summary>
|
||||
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 Api.Services.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PageFetcher.Models;
|
||||
|
||||
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
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly IPageFetcherApiClient _pageFetcher;
|
||||
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;
|
||||
_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)
|
||||
{
|
||||
var pasted = Normalize(jobDescription ?? string.Empty);
|
||||
@@ -26,23 +28,28 @@ public sealed class JobTextExtractor : IJobTextExtractor
|
||||
|
||||
if (string.IsNullOrWhiteSpace(jobUrl)) return string.Empty;
|
||||
if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
|
||||
{
|
||||
throw new InvalidOperationException("Invalid job URL.");
|
||||
}
|
||||
|
||||
var html = await _http.GetStringAsync(uri, ct);
|
||||
html = Regex.Replace(html, "<script[\\s\\S]*?</script>", " ", RegexOptions.IgnoreCase);
|
||||
html = Regex.Replace(html, "<style[\\s\\S]*?</style>", " ", RegexOptions.IgnoreCase);
|
||||
html = Regex.Replace(html, "<[^>]+>", " ");
|
||||
return Limit(Normalize(WebUtility.HtmlDecode(html)));
|
||||
var response = await _pageFetcher.FetchAsync(new FetchPageRequest
|
||||
{
|
||||
Url = jobUrl,
|
||||
CallerService = "cv-matcher-api"
|
||||
}, 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)
|
||||
{
|
||||
var max = Math.Max(4000, _settings.MaxJobTextChars);
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Api.Clients.Api.Contracts;
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvSearch.Data;
|
||||
@@ -11,33 +9,48 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
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
|
||||
{
|
||||
private readonly CvSearchDbContext _db;
|
||||
private readonly IRagApiClient _rag;
|
||||
private readonly JobSearchSettings _settings;
|
||||
private readonly ILogger<JobTokenService> _logger;
|
||||
|
||||
public JobTokenService(
|
||||
CvSearchDbContext db,
|
||||
IRagApiClient rag,
|
||||
IOptions<JobSearchSettings> settings,
|
||||
ILogger<JobTokenService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_rag = rag;
|
||||
_settings = settings.Value;
|
||||
_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, 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
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
CvDocumentId = cvDocumentId,
|
||||
Email = email,
|
||||
Language = language,
|
||||
Keywords = string.Join(",", keywords),
|
||||
Location = location,
|
||||
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
|
||||
Used = false,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
@@ -45,10 +58,11 @@ public sealed class JobTokenService : IJobTokenService
|
||||
|
||||
_db.JobSearchTokens.Add(token);
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> TriggerStartAsync(string tokenId, CancellationToken ct)
|
||||
{
|
||||
var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct);
|
||||
@@ -59,11 +73,15 @@ public sealed class JobTokenService : IJobTokenService
|
||||
token.Used = true;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct);
|
||||
var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty;
|
||||
var keywords = token.Keywords;
|
||||
|
||||
var enabledProviders = await _db.JobProviders
|
||||
.Where(p => p.Enabled)
|
||||
.OrderBy(p => p.DisplayOrder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var providerConfigJson = JsonSerializer.Serialize(
|
||||
_settings.Providers.Where(p => p.Enabled).ToList(),
|
||||
enabledProviders.Select(ToConfig).ToList(),
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
var session = new JobSearchSessionEntity
|
||||
@@ -75,35 +93,43 @@ public sealed class JobTokenService : IJobTokenService
|
||||
Language = token.Language,
|
||||
Status = JobSearchStatus.Pending,
|
||||
Keywords = keywords,
|
||||
Location = token.Location,
|
||||
ProviderConfigJson = providerConfigJson,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
_db.JobSearchSessions.Add(session);
|
||||
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;
|
||||
}
|
||||
|
||||
private static string ExtractKeywords(string cvText)
|
||||
private static JobProviderConfig ToConfig(JobProviderEntity entity)
|
||||
{
|
||||
var lines = cvText
|
||||
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 5 && l.Length < 200)
|
||||
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
|
||||
.Take(5)
|
||||
.ToList();
|
||||
List<string> keywords;
|
||||
try
|
||||
{
|
||||
keywords = JsonSerializer.Deserialize<List<string>>(entity.InitialKeywordsJson,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
keywords = [];
|
||||
}
|
||||
|
||||
var words = lines
|
||||
.SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(w => Regex.Replace(w, @"[^\w\-]", ""))
|
||||
.Where(w => w.Length > 2)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
return string.Join(",", words);
|
||||
return new JobProviderConfig
|
||||
{
|
||||
Name = entity.Name,
|
||||
Enabled = entity.Enabled,
|
||||
SearchUrlTemplate = entity.SearchUrlTemplate,
|
||||
JobLinkContains = entity.JobLinkContains,
|
||||
InitialKeywords = keywords,
|
||||
MaxResults = entity.MaxResults,
|
||||
RequireKeywordInAnchor = entity.RequireKeywordInAnchor
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
@@ -30,25 +29,6 @@
|
||||
"retainedFileCountLimit": 30,
|
||||
"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": [
|
||||
@@ -112,32 +92,6 @@
|
||||
"JobSearchLinkBaseUrl": "https://myai.ro",
|
||||
"TokenExpiryDays": 7,
|
||||
"MinMatchScore": 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
|
||||
}
|
||||
]
|
||||
"MaxJobsToMatch": 15
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
<ProjectReference Include="..\cv-search-data\cv-search-data.csproj" />
|
||||
<ProjectReference Include="..\cv-matcher-data\cv-matcher-data.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="..\myai-data\myai-data.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,8 +5,8 @@ namespace CvMatcher.Data;
|
||||
|
||||
public sealed class CvMatcherDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "cvMatcher";
|
||||
public const string MigrationTableName = "_Migrations";
|
||||
public const string SchemaName = MigrationConstants.SchemaName;
|
||||
public const string MigrationTableName = MigrationConstants.MigrationTableName;
|
||||
|
||||
public CvMatcherDbContext(DbContextOptions<CvMatcherDbContext> options) : base(options)
|
||||
{
|
||||
@@ -14,6 +14,14 @@ public sealed class CvMatcherDbContext : DbContext
|
||||
|
||||
public DbSet<CvMatchResultEntity> CvMatchResults => Set<CvMatchResultEntity>();
|
||||
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)
|
||||
{
|
||||
@@ -28,7 +36,9 @@ public sealed class CvMatcherDbContext : DbContext
|
||||
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
|
||||
entity.Property(x => x.ResultJson).IsRequired();
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.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 =>
|
||||
@@ -41,5 +51,16 @@ public sealed class CvMatcherDbContext : DbContext
|
||||
entity.Property(x => x.ResponseText).IsRequired();
|
||||
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 Language { get; set; } = string.Empty;
|
||||
@@ -9,4 +9,6 @@ public sealed class CvMatchResultEntity : BaseEntity
|
||||
public string Language { get; set; } = "en";
|
||||
public string ResultJson { get; set; } = string.Empty;
|
||||
public int Score { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? ClientIpAddress { get; set; }
|
||||
}
|
||||
|
||||
@@ -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 CvMatcher.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
namespace CvMatcher.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvMatcherDbContext))]
|
||||
[Migration("20260524140335_AddLanguageToCvMatchResult")]
|
||||
partial class AddLanguageToCvMatchResult
|
||||
[Migration("20260601133028_InitialSchema")]
|
||||
partial class InitialSchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -26,6 +26,37 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
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")
|
||||
@@ -49,7 +80,7 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
@@ -60,7 +91,7 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||
.IsUnique();
|
||||
|
||||
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 CvMatcher.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
namespace CvMatcher.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvMatcherDbContext))]
|
||||
[Migration("20260507140442_InitialCvMatcherSchema")]
|
||||
partial class InitialCvMatcherSchema
|
||||
[Migration("20260608124331_ImproveKeywordsAndAddLocation")]
|
||||
partial class ImproveKeywordsAndAddLocation
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -26,6 +26,37 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
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")
|
||||
@@ -47,6 +78,10 @@ namespace CvMatcher.Data.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
@@ -56,7 +91,7 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||
.IsUnique();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvMatcher.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -23,12 +23,47 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
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")
|
||||
@@ -39,6 +74,10 @@ namespace CvMatcher.Data.Migrations
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("JobDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
@@ -46,7 +85,7 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
@@ -57,7 +96,7 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||
.IsUnique();
|
||||
|
||||
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<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 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.Models.Responses;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace CvMatcher.Data.Repositories;
|
||||
|
||||
@@ -39,7 +40,7 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, CancellationToken ct)
|
||||
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct)
|
||||
{
|
||||
var exists = await _db.CvMatchResults.AnyAsync(
|
||||
x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language,
|
||||
@@ -47,18 +48,33 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
||||
|
||||
if (exists) return;
|
||||
|
||||
_db.CvMatchResults.Add(new CvMatchResultEntity
|
||||
try
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
CvDocumentId = cvDocumentId,
|
||||
JobDocumentId = jobDocumentId,
|
||||
Language = language,
|
||||
ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)),
|
||||
Score = response.Score,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
_db.CvMatchResults.Add(new CvMatchResultEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
CvDocumentId = cvDocumentId,
|
||||
JobDocumentId = jobDocumentId,
|
||||
Language = language,
|
||||
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)
|
||||
@@ -15,5 +15,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\shared-data\shared-data.csproj" />
|
||||
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -5,14 +5,22 @@ namespace CvSearch.Data;
|
||||
|
||||
public sealed class CvSearchDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "cvSearch";
|
||||
public const string MigrationTableName = "_Migrations";
|
||||
public const string SchemaName = MigrationConstants.SchemaName;
|
||||
public const string MigrationTableName = MigrationConstants.MigrationTableName;
|
||||
|
||||
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>();
|
||||
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)
|
||||
{
|
||||
@@ -26,6 +34,7 @@ public sealed class CvSearchDbContext : DbContext
|
||||
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.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty);
|
||||
entity.Property(x => x.Used).HasDefaultValue(false);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
@@ -58,5 +67,18 @@ public sealed class CvSearchDbContext : DbContext
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
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;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ public sealed class JobSearchSessionEntity : BaseEntity
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Status { get; set; } = JobSearchStatus.Pending;
|
||||
public string Keywords { get; set; } = string.Empty;
|
||||
public string? Location { get; set; }
|
||||
public string? ProviderConfigJson { get; set; }
|
||||
public string Language { get; set; } = "en";
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ public sealed class JobSearchTokenEntity : BaseEntity
|
||||
public string Language { get; set; } = "en";
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public bool Used { get; set; }
|
||||
public string Keywords { get; set; } = string.Empty;
|
||||
public string? Location { get; set; }
|
||||
}
|
||||
|
||||
@@ -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 Microsoft.EntityFrameworkCore.Migrations;
|
||||
using CvSearch.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
@@ -12,11 +13,11 @@ namespace CvSearch.Data.Migrations
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "cvSearch");
|
||||
name: MigrationConstants.SchemaName);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchResults",
|
||||
schema: "cvSearch",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
@@ -36,7 +37,7 @@ namespace CvSearch.Data.Migrations
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchSessions",
|
||||
schema: "cvSearch",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
@@ -55,7 +56,7 @@ namespace CvSearch.Data.Migrations
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchTokens",
|
||||
schema: "cvSearch",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
@@ -72,13 +73,13 @@ namespace CvSearch.Data.Migrations
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobSearchResults_SessionId",
|
||||
schema: "cvSearch",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobSearchResults",
|
||||
column: "SessionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobSearchSessions_Status",
|
||||
schema: "cvSearch",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobSearchSessions",
|
||||
column: "Status");
|
||||
}
|
||||
@@ -88,15 +89,15 @@ namespace CvSearch.Data.Migrations
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchResults",
|
||||
schema: "cvSearch");
|
||||
schema: MigrationConstants.SchemaName);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchSessions",
|
||||
schema: "cvSearch");
|
||||
schema: MigrationConstants.SchemaName);
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchTokens",
|
||||
schema: "cvSearch");
|
||||
schema: MigrationConstants.SchemaName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using CvSearch.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
@@ -12,7 +13,7 @@ namespace CvSearch.Data.Migrations
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Language",
|
||||
schema: "cvSearch",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobSearchTokens",
|
||||
type: "nvarchar(8)",
|
||||
maxLength: 8,
|
||||
@@ -21,7 +22,7 @@ namespace CvSearch.Data.Migrations
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Language",
|
||||
schema: "cvSearch",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobSearchSessions",
|
||||
type: "nvarchar(8)",
|
||||
maxLength: 8,
|
||||
@@ -34,12 +35,12 @@ namespace CvSearch.Data.Migrations
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Language",
|
||||
schema: "cvSearch",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobSearchTokens");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Language",
|
||||
schema: "cvSearch",
|
||||
schema: MigrationConstants.SchemaName,
|
||||
table: "JobSearchSessions");
|
||||
}
|
||||
}
|
||||
|
||||
+55
-7
@@ -1,6 +1,6 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvSearch.Models.Data;
|
||||
using CvSearch.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Models.Migrations
|
||||
namespace CvSearch.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvSearchDbContext))]
|
||||
[Migration("20260524145702_AddLanguageToJobSearchEntities")]
|
||||
partial class AddLanguageToJobSearchEntities
|
||||
[Migration("20260529084440_AddJobProviders")]
|
||||
partial class AddJobProviders
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
|
||||
|
||||
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")
|
||||
.HasMaxLength(64)
|
||||
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
|
||||
b.ToTable("JobSearchResults", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
@@ -128,7 +176,7 @@ namespace CvSearch.Models.Migrations
|
||||
b.ToTable("JobSearchSessions", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.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 CvSearch.Models.Data;
|
||||
using CvSearch.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Models.Migrations
|
||||
namespace CvSearch.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvSearchDbContext))]
|
||||
[Migration("20260522093356_AddJobSearchTables")]
|
||||
partial class AddJobSearchTables
|
||||
[Migration("20260529130000_AddKeywordsToJobSearchTokens")]
|
||||
partial class AddKeywordsToJobSearchTokens
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
|
||||
|
||||
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")
|
||||
.HasMaxLength(64)
|
||||
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
|
||||
b.ToTable("JobSearchResults", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
@@ -101,6 +149,13 @@ namespace CvSearch.Models.Migrations
|
||||
.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)");
|
||||
|
||||
@@ -121,7 +176,7 @@ namespace CvSearch.Models.Migrations
|
||||
b.ToTable("JobSearchSessions", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
@@ -145,6 +200,20 @@ namespace CvSearch.Models.Migrations
|
||||
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")
|
||||
@@ -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 CvSearch.Models.Data;
|
||||
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.Models.Migrations
|
||||
namespace CvSearch.Data.Migrations
|
||||
{
|
||||
[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
|
||||
modelBuilder
|
||||
@@ -23,7 +26,55 @@ namespace CvSearch.Models.Migrations
|
||||
|
||||
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")
|
||||
.HasMaxLength(64)
|
||||
@@ -72,7 +123,7 @@ namespace CvSearch.Models.Migrations
|
||||
b.ToTable("JobSearchResults", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
@@ -125,7 +176,7 @@ namespace CvSearch.Models.Migrations
|
||||
b.ToTable("JobSearchSessions", "cvSearch");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
@@ -149,6 +200,13 @@ namespace CvSearch.Models.Migrations
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvSearch.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -23,6 +23,57 @@ namespace CvSearch.Data.Migrations
|
||||
|
||||
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")
|
||||
@@ -105,6 +156,9 @@ namespace CvSearch.Data.Migrations
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -149,6 +203,13 @@ namespace CvSearch.Data.Migrations
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -156,6 +217,9 @@ namespace CvSearch.Data.Migrations
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using Email.Models.Requests;
|
||||
using Refit;
|
||||
|
||||
namespace Email.Models.Clients;
|
||||
|
||||
public interface IEmailApiClient
|
||||
{
|
||||
[Post("/api/email/send")]
|
||||
Task SendAsync(SendEmailRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Email.Models.Requests;
|
||||
|
||||
public sealed class SendEmailRequest
|
||||
{
|
||||
public required List<string> To { get; init; }
|
||||
public string? ReplyTo { get; init; }
|
||||
public required string Subject { get; init; }
|
||||
public required string HtmlBody { get; init; }
|
||||
public string? AttachmentPath { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Email.Models.Settings;
|
||||
|
||||
public sealed class EmailApiSettings
|
||||
{
|
||||
public string BaseUrl { get; set; } = "";
|
||||
public string InternalApiKey { get; set; } = "";
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Email.Models.Settings;
|
||||
|
||||
public sealed class SmtpSettings
|
||||
{
|
||||
public string Host { get; set; } = "";
|
||||
public int Port { get; set; } = 587;
|
||||
public string Username { get; set; } = "";
|
||||
public string Password { get; set; } = "";
|
||||
public bool UseStartTls { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>email-api-models</AssemblyName>
|
||||
<RootNamespace>EmailApi.Models</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,40 @@
|
||||
# email-api — Internal Email Sending Service
|
||||
|
||||
Internal only. Reachable at `http://email-api:8080` within `myai-network`. Not exposed to the internet.
|
||||
|
||||
## Responsibilities
|
||||
|
||||
- Accepts `POST /api/email/send` requests from internal services (`api`, `cv-search-job`)
|
||||
- Wraps the provided HTML body fragment in a branded HTML shell (blue header, white card, grey footer)
|
||||
- Sends the email via SMTP using MailKit
|
||||
- Attaches files from the shared `Files` volume when `AttachmentPath` is provided
|
||||
- Protected by `X-Internal-Api-Key` via `UseInternalApiKeyProtection()`
|
||||
|
||||
## Key route
|
||||
|
||||
| Method | Route | Description |
|
||||
|--------|-------|-------------|
|
||||
| POST | `/api/email/send` | Send an HTML email. Returns 204 No Content. |
|
||||
|
||||
## Request body (`SendEmailRequest`)
|
||||
|
||||
| Field | Required | Notes |
|
||||
|-------|----------|-------|
|
||||
| `To` | ✅ | List of recipient addresses |
|
||||
| `ReplyTo` | ❌ | Optional reply-to address |
|
||||
| `Subject` | ✅ | Plain text (service prepends `[ENV_NAME]`) |
|
||||
| `HtmlBody` | ✅ | HTML fragment — wrapped in branded shell by this service |
|
||||
| `AttachmentPath` | ❌ | Path relative to `FileStorage:Path`, e.g. `"{cvDocumentId}.pdf"` |
|
||||
|
||||
## Consumers
|
||||
|
||||
- `api` — via `IEmailApiClient` Refit interface (contact, subscribe, file-download, match emails)
|
||||
- `cv-search-job` — via `IEmailApiClient` Refit interface (job search results email)
|
||||
|
||||
## Settings
|
||||
|
||||
| Section | Env var | Notes |
|
||||
|---------|---------|-------|
|
||||
| `Smtp` | `Smtp__Host`, `Smtp__Username`, `Smtp__Password`, etc. | SMTP server config — only configured here, not in consumers |
|
||||
| `FileStorage` | `FileStorage__Path` | Must match the shared `Files` volume mount path |
|
||||
| `InternalApi` | `EmailApi__InternalApiKey` | API key enforced on every request |
|
||||
@@ -0,0 +1,47 @@
|
||||
using Api.Services;
|
||||
using Email.Models.Requests;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
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]
|
||||
[Route("api/email")]
|
||||
public sealed class EmailController : ControllerBase
|
||||
{
|
||||
private readonly SmtpEmailDispatcher _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")]
|
||||
[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.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
|
||||
public async Task<IActionResult> Send([FromBody] SendEmailRequest request, CancellationToken ct)
|
||||
{
|
||||
await _dispatcher.SendAsync(request, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
ARG BUILD_CONFIGURATION=Release
|
||||
WORKDIR /src
|
||||
|
||||
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/api-models/api-models.csproj Apis/api-models/
|
||||
COPY Apis/common/common.csproj Apis/common/
|
||||
COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/
|
||||
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
|
||||
COPY Directory.Packages.props ./
|
||||
|
||||
RUN dotnet restore Apis/email-api/email-api.csproj
|
||||
|
||||
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/api-models/ Apis/api-models/
|
||||
COPY Apis/common/ Apis/common/
|
||||
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
||||
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
||||
|
||||
RUN dotnet publish Apis/email-api/email-api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
EXPOSE 8080
|
||||
ENV ASPNETCORE_URLS=http://0.0.0.0:8080
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
ENTRYPOINT ["dotnet", "email-api.dll"]
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Reflection;
|
||||
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 Serilog;
|
||||
using StartupHelpers;
|
||||
|
||||
StartupExtensions.LoadDotEnvFile();
|
||||
|
||||
const string ServiceName = "email-api";
|
||||
var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly());
|
||||
|
||||
try
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.ConfigureJsonSerilog(ServiceName, appVersion);
|
||||
Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion);
|
||||
|
||||
builder.AddAzureKeyVaultIfConfigured();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "Email API");
|
||||
|
||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||
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>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.LogStartupDiagnostics(ServiceName);
|
||||
|
||||
app.UseDefaultSerilogRequestLogging();
|
||||
app.UseJsonExceptionHandler(ServiceName);
|
||||
app.UseInternalApiKeyProtection();
|
||||
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
|
||||
|
||||
app.UseRouting();
|
||||
app.UseAuthorization();
|
||||
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);
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.Information("Shutting down {Service}", ServiceName);
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"profiles": {
|
||||
"email-api": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:61871;http://localhost:61872"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using Email.Data.Services;
|
||||
using Email.Models.Requests;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using Email.Models.Settings;
|
||||
using Models.Settings;
|
||||
|
||||
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
|
||||
{
|
||||
private readonly SmtpSettings _smtp;
|
||||
private readonly FileStorageSettings _fileStorage;
|
||||
private readonly IEmailTemplateService _templates;
|
||||
private readonly ILogger<SmtpEmailDispatcher> _log;
|
||||
private readonly string _environmentName;
|
||||
|
||||
public SmtpEmailDispatcher(
|
||||
IOptions<SmtpSettings> smtp,
|
||||
IOptions<FileStorageSettings> fileStorage,
|
||||
IEmailTemplateService templates,
|
||||
ILogger<SmtpEmailDispatcher> log)
|
||||
{
|
||||
_smtp = smtp.Value;
|
||||
_fileStorage = fileStorage.Value;
|
||||
_templates = templates;
|
||||
_log = log;
|
||||
_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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_smtp.Host))
|
||||
{
|
||||
_log.LogWarning("SMTP host not configured — email skipped (to: {To})", string.Join(", ", req.To));
|
||||
return;
|
||||
}
|
||||
|
||||
var msg = new MimeMessage();
|
||||
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
|
||||
|
||||
foreach (var to in req.To)
|
||||
msg.To.Add(MailboxAddress.Parse(to));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.ReplyTo))
|
||||
msg.ReplyTo.Add(MailboxAddress.Parse(req.ReplyTo));
|
||||
|
||||
msg.Subject = $"[{_environmentName}] {req.Subject}".Trim();
|
||||
|
||||
var shellStart = _templates.Get("email.html-shell.start", "*");
|
||||
var shellEnd = _templates.Get("email.html-shell.end", "*");
|
||||
|
||||
var builder = new BodyBuilder
|
||||
{
|
||||
HtmlBody = shellStart + req.HtmlBody + shellEnd
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(req.AttachmentPath))
|
||||
{
|
||||
var fullPath = Path.Combine(_fileStorage.Path, req.AttachmentPath);
|
||||
if (File.Exists(fullPath))
|
||||
{
|
||||
builder.Attachments.Add(fullPath);
|
||||
_log.LogDebug("Attachment added: {Path}", fullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
_log.LogWarning("Attachment not found, skipping: {Path}", fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
msg.Body = builder.ToMessageBody();
|
||||
|
||||
_log.LogInformation("Sending email to {Recipients} subject {Subject}",
|
||||
string.Join(", ", req.To), req.Subject);
|
||||
|
||||
using var client = new SmtpClient();
|
||||
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_smtp.Username))
|
||||
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
|
||||
|
||||
await client.SendAsync(msg, ct);
|
||||
await client.DisconnectAsync(true, ct);
|
||||
|
||||
_log.LogInformation("Email sent successfully to {Recipients}", string.Join(", ", req.To));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<RootNamespace>EmailApi</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.Email" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\api-models\api-models.csproj" />
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\email-data\email-data.csproj" />
|
||||
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
|
||||
</ItemGroup>
|
||||
</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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Email.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Schema constants used by EmailDbContext and migrations.
|
||||
/// Centralized to avoid hardcoded strings and ensure consistency.
|
||||
/// </summary>
|
||||
public static class MigrationConstants
|
||||
{
|
||||
public const string SchemaName = "email";
|
||||
public const string MigrationTableName = "_Migrations";
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Email.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Email.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EmailDbContext))]
|
||||
[Migration("20260601133043_InitialSchema")]
|
||||
partial class InitialSchema
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("email")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("OperatorCopy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("Templates", "email");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using Email.Data;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Email.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: MigrationConstants.SchemaName);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Templates",
|
||||
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()"),
|
||||
OperatorCopy = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language });
|
||||
});
|
||||
|
||||
Seed(migrationBuilder);
|
||||
}
|
||||
|
||||
private static void Seed(MigrationBuilder m)
|
||||
{
|
||||
void Row(string key, string lang, string value, string description = "")
|
||||
=> m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], MigrationConstants.SchemaName);
|
||||
|
||||
// Match result email — subject
|
||||
Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email");
|
||||
Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV");
|
||||
|
||||
// Match result email — body (HTML formatted)
|
||||
Row("email.match.body", "en",
|
||||
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">CV Match Report</h2>
|
||||
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 30px; border-collapse: collapse;"">
|
||||
<tr style=""background-color: #2c5282; color: white;"">
|
||||
<td width=""130"" style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #2c5282;"">CV ID</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #2c5282;"">{{cvDocumentId}}</td>
|
||||
</tr>
|
||||
<tr style=""background-color: #f8f9fa;"">
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Job</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #333;"">{{jobLabel}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">URL</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
|
||||
</tr>
|
||||
<tr style=""background-color: #f8f9fa;"">
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Score</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #27ae60; font-weight: bold; font-size: 18px;"">{{score}}%</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Summary</h3>
|
||||
<p style=""line-height: 1.6; color: #333;"">{{summary}}</p>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Strengths</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{strengths}}</div>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Gaps</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{gaps}}</div>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Recommendations</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{recommendations}}</div>",
|
||||
"Body for the CV match result email (HTML formatted)");
|
||||
Row("email.match.body", "ro",
|
||||
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Report Potrivire CV</h2>
|
||||
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 30px; border-collapse: collapse;"">
|
||||
<tr style=""background-color: #2c5282; color: white;"">
|
||||
<td width=""130"" style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #2c5282;"">ID Document CV</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #2c5282;"">{{cvDocumentId}}</td>
|
||||
</tr>
|
||||
<tr style=""background-color: #f8f9fa;"">
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Job</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #333;"">{{jobLabel}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">URL</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
|
||||
</tr>
|
||||
<tr style=""background-color: #f8f9fa;"">
|
||||
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Scor</td>
|
||||
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #27ae60; font-weight: bold; font-size: 18px;"">{{score}}%</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Rezumat</h3>
|
||||
<p style=""line-height: 1.6; color: #333;"">{{summary}}</p>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Puncte Forte</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{strengths}}</div>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Lipsuri</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{gaps}}</div>
|
||||
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Recomandări</h3>
|
||||
<div style=""line-height: 1.8; color: #333;"">{{recommendations}}</div>",
|
||||
"Corpul emailului pentru rezultatul potrivirii CV (format HTML)");
|
||||
|
||||
// Match result email — job search CTA footer (HTML formatted)
|
||||
Row("email.match.job-search-footer", "en",
|
||||
@"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
|
||||
<p style=""margin-top: 20px; font-size: 14px; color: #333;"">Want to find more jobs matching your CV?</p>
|
||||
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""margin-bottom: 20px;"">
|
||||
<tr>
|
||||
<td align=""center"" bgcolor=""#2c5282"" style=""background-color: #2c5282; padding: 10px 20px;"">
|
||||
<a href=""{{jobSearchLink}}"" style=""color: #ffffff; font-weight: bold; text-decoration: none; font-size: 14px; display: block;"">Search Jobs</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style=""font-size: 12px; color: #666;"">(link valid for {{expiryDays}} days)</p>",
|
||||
"Job search CTA appended to match result email (HTML formatted)");
|
||||
Row("email.match.job-search-footer", "ro",
|
||||
@"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
|
||||
<p style=""margin-top: 20px; font-size: 14px; color: #333;"">Vrei să găsești mai multe joburi potrivite CV-ului tău?</p>
|
||||
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""margin-bottom: 20px;"">
|
||||
<tr>
|
||||
<td align=""center"" bgcolor=""#2c5282"" style=""background-color: #2c5282; padding: 10px 20px;"">
|
||||
<a href=""{{jobSearchLink}}"" style=""color: #ffffff; font-weight: bold; text-decoration: none; font-size: 14px; display: block;"">Caută Joburi</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style=""font-size: 12px; color: #666;"">(link valabil {{expiryDays}} zile)</p>",
|
||||
"CTA cautare joburi adaugat la emailul de potrivire CV (format HTML)");
|
||||
|
||||
// Job search results email — subject
|
||||
Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email");
|
||||
Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi");
|
||||
|
||||
// Job search results email — body preamble (items appended in code) - HTML formatted
|
||||
Row("email.search-results.body", "en",
|
||||
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Job Search Results</h2>
|
||||
<p style=""margin-bottom: 20px; color: #333;"">MyAi.ro found <strong>{{count}}</strong> jobs matching your CV:</p>
|
||||
{{items}}",
|
||||
"Body preamble for job search results email (HTML formatted)");
|
||||
Row("email.search-results.body", "ro",
|
||||
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Rezultate Căutare Joburi</h2>
|
||||
<p style=""margin-bottom: 20px; color: #333;"">MyAi.ro a găsit <strong>{{count}}</strong> joburi potrivite CV-ului tău:</p>
|
||||
{{items}}",
|
||||
"Corpul emailului de rezultate cautare joburi (format HTML)");
|
||||
|
||||
// Job search results email — scan summary block (keywords + providers used)
|
||||
Row("email.search-results.scan-summary", "en",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||
<div style=""margin-bottom: 8px;""><strong>Keywords used:</strong> {{keywordsHtml}}</div>
|
||||
<div><strong>Providers scanned:</strong> {{providers}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Scan summary block prepended to job search results email (HTML formatted)");
|
||||
Row("email.search-results.scan-summary", "ro",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||
<div style=""margin-bottom: 8px;""><strong>Cuvinte cheie folosite:</strong> {{keywordsHtml}}</div>
|
||||
<div><strong>Furnizori scanați:</strong> {{providers}}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Bloc rezumat scanare adaugat la emailul de rezultate cautare joburi (format HTML)");
|
||||
|
||||
// Job search results email — single job result item card
|
||||
Row("email.search-results.item", "en",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 12px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td style=""padding: 12px 16px; background-color: #ffffff;"">
|
||||
<strong style=""color: #212529;"">{{index}}. {{jobTitle}}</strong>
|
||||
<span style=""background: #28a745; color: #fff; padding: 2px 8px; font-size: 12px; margin-left: 8px;"">{{score}}% match</span>
|
||||
<span style=""color: #6c757d; font-size: 12px; margin-left: 4px;"">[{{providerName}}]</span><br>
|
||||
<a href=""{{jobUrl}}"" style=""color: #2c5282; font-size: 13px;"">{{jobUrl}}</a>
|
||||
{{summary}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Single job result card in job search results email (HTML formatted)");
|
||||
Row("email.search-results.item", "ro",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 12px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||
<tr>
|
||||
<td style=""padding: 12px 16px; background-color: #ffffff;"">
|
||||
<strong style=""color: #212529;"">{{index}}. {{jobTitle}}</strong>
|
||||
<span style=""background: #28a745; color: #fff; padding: 2px 8px; font-size: 12px; margin-left: 8px;"">{{score}}% potrivire</span>
|
||||
<span style=""color: #6c757d; font-size: 12px; margin-left: 4px;"">[{{providerName}}]</span><br>
|
||||
<a href=""{{jobUrl}}"" style=""color: #2c5282; font-size: 13px;"">{{jobUrl}}</a>
|
||||
{{summary}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Card job individual in emailul de rezultate cautare joburi (format HTML)");
|
||||
|
||||
// Job search results email — no results found - HTML formatted
|
||||
Row("email.search-results.empty", "en",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin: 20px 0;"">
|
||||
<tr>
|
||||
<td bgcolor=""#fff3cd"" style=""background-color: #fff3cd; border: 1px solid #ffc107; padding: 15px;"">
|
||||
<p style=""margin: 0; color: #856404;""><strong>No jobs found</strong><br>
|
||||
MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"No results message for job search results email (HTML formatted)");
|
||||
Row("email.search-results.empty", "ro",
|
||||
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin: 20px 0;"">
|
||||
<tr>
|
||||
<td bgcolor=""#fff3cd"" style=""background-color: #fff3cd; border: 1px solid #ffc107; padding: 15px;"">
|
||||
<p style=""margin: 0; color: #856404;""><strong>Niciun job găsit</strong><br>
|
||||
MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>",
|
||||
"Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)");
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Templates",
|
||||
schema: MigrationConstants.SchemaName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Email.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Email.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EmailDbContext))]
|
||||
[Migration("20260601145256_AddHtmlShellTemplates")]
|
||||
partial class AddHtmlShellTemplates
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("email")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("OperatorCopy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("Templates", "email");
|
||||
});
|
||||
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user