Merge pull request 'Staging to Production' (#51) from main into production

Merge staging to production
This commit was merged in pull request #51.
This commit is contained in:
2026-06-08 18:28:46 +00:00
257 changed files with 13854 additions and 3131 deletions
+31 -4
View File
@@ -1,9 +1,9 @@
name: Build and Push Docker Images Production
name: Build and Push Docker Images Staging
on:
push:
branches:
- production
- staging
env:
GIT_HOST: git.easysoft.ro
@@ -11,9 +11,12 @@ 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
IMAGE_TAG: production
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api
IMAGE_TAG: staging
jobs:
build:
@@ -44,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}" .
@@ -52,6 +59,14 @@ jobs:
run: |
docker build -f Jobs/cv-cleanup-job/Dockerfile -t "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" .
- name: Build CV search job image
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}"
@@ -64,10 +79,22 @@ 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}"
- name: Push CV cleanup job image
run: |
docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}"
docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}"
- name: Push CV search job image
run: |
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}"
+3
View File
@@ -3,6 +3,9 @@
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Claude Code session files
.claude/
# Environment Variables - DO NOT COMMIT
*.env
.env
@@ -8,4 +8,8 @@ public sealed class JobMatchRequest
public bool GdprConsent { get; set; }
public string? Email { get; set; }
public string? CaptchaToken { get; set; }
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
public string? Language { get; set; }
/// <summary>Client IP address — set by the api layer from the HTTP context before forwarding. Not supplied by the browser.</summary>
public string? ClientIpAddress { get; set; }
}
+1 -1
View File
@@ -1,4 +1,4 @@
using Shared.Models.Requests;
using Common.Requests;
namespace Models.Requests
{
@@ -0,0 +1,6 @@
namespace Models.Settings;
public sealed class JobSearchLinkSettings
{
public string BaseUrl { get; set; } = string.Empty;
}
-11
View File
@@ -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;
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -8,11 +8,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\shared-models\shared-models.csproj" />
<ProjectReference Include="..\common\common.csproj" />
</ItemGroup>
</Project>
+45
View File
@@ -0,0 +1,45 @@
# api — Public-Facing Proxy API
Internal port 8080. The only service exposed to the internet.
## Responsibilities
- Validates reCAPTCHA on CV upload and match requests
- Proxies CV operations to `cv-matcher-api` via Refit (`ICvMatcherApi`, `IJobSearchApi`)
- Sends match result emails via SMTP (`SmtpEmailSender`)
- Includes a job search link in match emails when a `CvDocumentId` is present
- Serves the job-search-start page (`GET /api/cv-matcher/job-search/start?t=<token>`)
- Enforces rate limiting (`cvMatcher` policy: 10 req / 10 min)
- Enforces CORS (allow list from `Cors__AllowedOrigins__*` env vars)
- Caches uploaded CV PDFs locally to `FileStorage:Path` for email attachment
## Key routes
| Method | Route | Description |
|--------|-------|-------------|
| POST | `/api/cv-matcher/upload` | Upload CV PDF, forward to cv-matcher-api |
| POST | `/api/cv-matcher/match` | Match CV+job, send email with job search link |
| GET | `/api/cv-matcher/job-search/start?t=<token>` | One-click job search start; returns plain HTML |
| GET | `/api/health` | Health check |
## Job search link flow
1. After a successful match with an email, `CvMatcherController.MatchJob` calls `IJobSearchApi.CreateTokenAsync`
2. Builds link: `{JobSearch:BaseUrl}/api/cv-matcher/job-search/start?t={tokenId}`
3. Passes link to `SmtpEmailSender.BuildMatchEmailBody(result, jobSearchLink)`
4. When user clicks link → `GET /api/cv-matcher/job-search/start?t=` → proxies to `cv-matcher-api POST /api/cv/job-search/token/{tokenId}/start`
5. Returns styled HTML page (Started / AlreadyUsed / Expired / NotFound)
## Settings
| Section | Key env var | Notes |
|---------|-------------|-------|
| `CvMatcherApi` | `CvMatcherApi__BaseUrl`, `CvMatcherApi__InternalApiKey` | Shared by both Refit clients |
| `JobSearch` | `JobSearch__BaseUrl` | Base URL for link generation only (maps to `JobSearchLinkSettings.BaseUrl`) |
| `FileStorage` | `FileStorage__Path` | Directory for cached CV PDFs; shared volume with cv-search-job |
| `Smtp` | `Smtp__Host`, `Smtp__Username`, etc. | Used by SmtpEmailSender |
| `Captcha` | `Captcha__SecretKey` | reCAPTCHA v3 secret |
## HTML page generation
`CvMatcherController.HtmlPage(title, message)` uses `$$"""` raw string literal so CSS `{` / `}` are literal. Do not change to `$"""` — causes CS9006.
@@ -0,0 +1,14 @@
using CvMatcher.Models.Requests;
using CvMatcher.Models.Responses;
using Refit;
namespace Api.Clients.Api.Contracts;
public interface IJobSearchApi
{
[Post("/api/cv/job-search/token")]
Task<CreateJobSearchTokenResponse> CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct);
[Post("/api/cv/job-search/token/{tokenId}/start")]
Task<StartJobSearchResponse> StartSearchAsync(string tokenId, [Body] StartJobSearchRequest request, CancellationToken ct);
}
+15 -6
View File
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
using Models.Settings;
using Swashbuckle.AspNetCore.Annotations;
using Models.Requests;
using Shared.Models.Responses;
using Common.Responses;
namespace Api.Controllers
{
@@ -29,8 +29,10 @@ namespace Api.Controllers
/// <summary>
/// Returns the public reCAPTCHA site key used by the client to render the widget.
/// </summary>
/// <returns>200 OK with the configured public site key as a plain string.</returns>
[HttpGet]
[SwaggerOperation(Summary = "Get captcha site key")]
[SwaggerOperation(Summary = "Get captcha public key", Description = "Returns the public reCAPTCHA site key required by the frontend to render the challenge widget.")]
[SwaggerResponse(StatusCodes.Status200OK, "Public site key returned")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult GetSiteKey()
{
@@ -38,13 +40,20 @@ namespace Api.Controllers
}
/// <summary>
/// Verify a captcha token and return the verification verdict.
/// Verifies a reCAPTCHA token submitted by the client and returns the full verification verdict.
/// </summary>
/// <param name="req">The verification request containing the token and optional expected action name.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with the full captcha verdict when verification passes;
/// 400 Bad Request with an <see cref="ErrorResponse"/> if the token is missing or verification fails.
/// </returns>
[HttpPost("verify")]
[SwaggerOperation(Summary = "Verify captcha token")]
[SwaggerOperation(Summary = "Verify captcha token", Description = "Verifies a reCAPTCHA token and returns the provider verdict including the score.")]
[SwaggerResponse(StatusCodes.Status200OK, "Token verified successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Token missing or verification failed", typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Captcha verification failed or token missing", typeof(ErrorResponse))]
public async Task<IActionResult> Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct)
{
if (req is null || string.IsNullOrWhiteSpace(req.Token))
@@ -61,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
});
}
+2 -2
View File
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
using Models.Settings;
using Models.Requests;
using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses;
using Common.Responses;
namespace Api.Controllers
{
@@ -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" });
}
}
+120 -8
View File
@@ -1,13 +1,15 @@
using Api.Clients.Api.Contracts;
using CvMatcher.Models.Requests;
using CvMatcher.Models.Responses;
using Models.Requests;
using Models.Settings;
using Api.Services.Contracts;
using Api.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses;
using Common.Responses;
using MyAi.Data.Services;
namespace Api.Controllers;
@@ -20,30 +22,47 @@ namespace Api.Controllers;
public sealed class CvMatcherController : ControllerBase
{
private readonly ICvMatcherApi _cvApi;
private readonly IJobSearchApi _jobSearchApi;
private readonly ICaptchaVerifier _captcha;
private readonly FileStorageSettings _fileStorageSettings;
private readonly JobSearchLinkSettings _jobSearchLinkSettings;
private readonly IEmailSender _emailSender;
private readonly ITemplateService _templates;
private readonly ILogger<CvMatcherController> _logger;
public CvMatcherController(
ICvMatcherApi cvApi,
IJobSearchApi jobSearchApi,
ICaptchaVerifier captcha,
IOptions<FileStorageSettings> fileStorageSettings,
IOptions<JobSearchLinkSettings> jobSearchLinkSettings,
IEmailSender emailSender,
ITemplateService templates,
ILogger<CvMatcherController> logger)
{
_cvApi = cvApi;
_jobSearchApi = jobSearchApi;
_captcha = captcha;
_fileStorageSettings = fileStorageSettings.Value;
_jobSearchLinkSettings = jobSearchLinkSettings.Value;
_emailSender = emailSender;
_templates = templates;
_logger = logger;
}
/// <summary>
/// Upload a CV PDF to the cv-matcher-api.
/// Proxies a CV PDF upload to the internal cv-matcher-api for indexing.
/// Validates the reCAPTCHA token and GDPR consent before forwarding.
/// Caches the uploaded file locally so it can be attached to the match result email.
/// </summary>
/// <param name="request">The uploaded CV request.</param>
/// <param name="request">Multipart form containing the CV PDF, captcha token, and GDPR consent flag.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with the document ID and cache status from cv-matcher-api;
/// 400 Bad Request if the file is missing or captcha verification fails;
/// 499 if the client cancelled the request;
/// 502 Bad Gateway if the upstream cv-matcher-api call fails.
/// </returns>
[HttpPost("upload")]
[RequestSizeLimit(8 * 1024 * 1024)]
[ProducesResponseType(StatusCodes.Status200OK)]
@@ -93,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.");
@@ -101,10 +130,18 @@ public sealed class CvMatcherController : ControllerBase
}
/// <summary>
/// Proxy a job matching request to the cv-matcher-api.
/// Proxies a CV-to-job match request to the internal cv-matcher-api.
/// Validates the reCAPTCHA token, then forwards the request and emails the scored result to the user.
/// When an email is provided, also creates a one-time job-search token and appends the search link to the email.
/// </summary>
/// <param name="request">Job match request payload containing CV document id or job description/url.</param>
/// <param name="request">Match request containing the CV document ID, a job URL or inline description, and an optional recipient email.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with the <see cref="JobMatchResponse"/> score, strengths, and gaps;
/// 400 Bad Request if captcha verification fails;
/// 499 if the client cancelled the request;
/// 502 Bad Gateway if the upstream cv-matcher-api call fails.
/// </returns>
[HttpPost("match-job")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
@@ -126,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),
@@ -136,10 +174,32 @@ public sealed class CvMatcherController : ControllerBase
? request.JobUrl
: "Manual job description";
var language = NormalizeLanguage(request.Language);
string? jobSearchLink = null;
if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId))
{
try
{
var tokenResp = await _jobSearchApi.CreateTokenAsync(
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location, ClientIpAddress = userIp },
ct);
if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
{
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not create job search token. Email link will be omitted.");
}
}
await _emailSender.SendMatchAsync(
request.Email,
SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel),
SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel),
_emailSender.BuildMatchEmailSubject(res.Score, jobLabel, language),
_emailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, language, jobSearchLink),
attachmentPath,
ct);
@@ -150,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.");
@@ -157,6 +227,45 @@ public sealed class CvMatcherController : ControllerBase
}
}
/// <summary>
/// Validates a one-time job-search token and kicks off the background job search.
/// Returns a self-contained HTML page intended to be opened directly in the browser via the link in the match email.
/// </summary>
/// <param name="t">The one-time UUID token from the job-search link query string.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with an HTML page indicating whether the search was started, the token was already used, expired, or invalid.
/// Always returns 200 — error states are communicated via the HTML page content, not the HTTP status code.
/// </returns>
[HttpGet("job-search/start")]
[SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a self-contained HTML confirmation page.")]
[SwaggerResponse(StatusCodes.Status200OK, "HTML page returned for all token states (started, already used, expired, invalid)")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> StartJobSearch([FromQuery] string t, CancellationToken ct)
{
try
{
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
var result = await _jobSearchApi.StartSearchAsync(t, new StartJobSearchRequest { ClientIpAddress = userIp }, ct);
var lang = "en";
var (title, message) = result.Status switch
{
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(_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);
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 async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
{
try
@@ -196,6 +305,9 @@ public sealed class CvMatcherController : ControllerBase
return Path.Combine(GetFileStoragePath(), $"{safeId}.pdf");
}
private static string NormalizeLanguage(string? language) =>
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
private string GetFileStoragePath()
{
var fileStoragePath = _fileStorageSettings.Path;
+36 -49
View File
@@ -6,7 +6,8 @@ using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses;
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,11 +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
{
// Use default file name from settings if not provided
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;
@@ -80,44 +102,30 @@ namespace Api.Controllers
_logger.LogInformation("Using default file name from settings: {FileName}", fileName);
}
// Get the file storage path (relative to solution folder)
var fileStoragePath = _fileStorageSettings.Path;
// If path is not absolute, make it relative to the solution root
if (!Path.IsPathRooted(fileStoragePath))
{
var solutionRoot = Directory.GetCurrentDirectory();
// Go up from api folder to solution root if needed
if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase))
{
solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot;
}
fileStoragePath = Path.Combine(solutionRoot, fileStoragePath);
}
// Sanitize fileName to prevent directory traversal attacks
var sanitizedFileName = Path.GetFileName(fileName);
var filePath = Path.Combine(fileStoragePath, sanitizedFileName);
// Verify file exists
if (!System.IO.File.Exists(filePath))
{
_logger.LogWarning("File not found: {FilePath}", filePath);
return NotFound(new ErrorResponse { Error = "File not found", Code = "file_not_found" });
}
var fileInfo = new FileInfo(filePath);
var fileLength = fileInfo.Length;
var fileLength = new FileInfo(filePath).Length;
// Determine content type
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
{
contentType = "application/octet-stream";
}
// Send email notification asynchronously (fire and forget with error handling)
// This is done before streaming to ensure notification is sent for both full and range downloads
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
_ = Task.Run(async () =>
{
try
@@ -130,19 +138,13 @@ namespace Api.Controllers
}
});
// Check if this is a range request
var rangeHeader = Request.Headers[HeaderNames.Range].ToString();
if (!string.IsNullOrEmpty(rangeHeader))
{
return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName);
}
// Full file download
_logger.LogInformation("Starting full file download: {FileName} ({FileSize} bytes)", sanitizedFileName, fileLength);
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString());
@@ -167,34 +169,25 @@ namespace Api.Controllers
{
try
{
// Parse range header (format: "bytes=start-end")
var range = rangeHeader.Replace("bytes=", "").Split('-');
long startByte = 0;
long endByte = fileLength - 1;
if (!string.IsNullOrEmpty(range[0]))
{
startByte = long.Parse(range[0]);
}
if (range.Length > 1 && !string.IsNullOrEmpty(range[1]))
{
endByte = long.Parse(range[1]);
}
// Validate range
if (startByte > endByte || startByte >= fileLength)
{
_logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength);
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
}
// Adjust end byte if it exceeds file length
if (endByte >= fileLength)
{
endByte = fileLength - 1;
}
var contentLength = endByte - startByte + 1;
@@ -202,20 +195,16 @@ namespace Api.Controllers
"Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)",
fileName, startByte, endByte, fileLength, contentLength);
// Open file stream and seek to start position
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
stream.Seek(startByte, SeekOrigin.Begin);
// Set response headers for partial content
Response.StatusCode = StatusCodes.Status206PartialContent;
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}");
Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString());
Response.ContentType = contentType;
// Stream the requested range
await StreamRangeAsync(stream, Response.Body, contentLength);
await stream.DisposeAsync();
return new EmptyResult();
@@ -241,9 +230,7 @@ namespace Api.Controllers
var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration));
if (bytesRead == 0)
{
break; // End of stream
}
break;
await destination.WriteAsync(buffer.AsMemory(0, bytesRead));
totalBytesRead += bytesRead;
+16
View File
@@ -1,5 +1,7 @@
using System.Reflection;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using StartupHelpers;
using Swashbuckle.AspNetCore.Annotations;
namespace Api.Controllers
@@ -14,6 +16,20 @@ namespace Api.Controllers
[EnableCors("FrontendOnly")]
public sealed class HealthController : ControllerBase
{
/// <summary>
/// Returns the deployed API version baked into the assembly at build time.
/// The version format is <c>1.0.0-build.{yyyyMMddHHmmss}</c> as defined in <c>api.csproj</c>.
/// Used by the web frontend to display the running build in the page footer.
/// </summary>
/// <returns>200 OK with JSON payload: <c>{ "version": "1.0.0-build.20250522103045" }</c>.</returns>
// GET api/health/version
[HttpGet("version")]
[SwaggerOperation(Summary = "API version", Description = "Returns the deployed API assembly version.")]
[SwaggerResponse(StatusCodes.Status200OK, "Version returned")]
[ProducesResponseType(StatusCodes.Status200OK)]
public IActionResult Version() =>
Ok(new { version = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()) });
/// <summary>
/// Liveness probe.
/// Indicates whether the process is running. Used by orchestration systems to confirm the process is alive.
+12 -3
View File
@@ -2,18 +2,27 @@ 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/shared-models/shared-models.csproj Apis/shared-models/
COPY Apis/api-models/api-models.csproj Apis/api-models/
COPY Apis/email-data/email-data.csproj Apis/email-data/
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
COPY Apis/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/
RUN dotnet restore Apis/api/api.csproj
COPY Apis/api/ Apis/api/
COPY Apis/shared-models/ Apis/shared-models/
COPY Apis/api-models/ Apis/api-models/
COPY Apis/email-data/ Apis/email-data/
COPY Apis/email-api-models/ Apis/email-api-models/
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
COPY Apis/common/ Apis/common/
COPY Apis/myai-data/ Apis/myai-data/
COPY Apis/shared-data/ Apis/shared-data/
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
@@ -25,4 +34,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "api.dll"]
ENTRYPOINT ["dotnet", "api.dll"]
+73 -17
View File
@@ -1,9 +1,19 @@
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;
using MyAi.Data.Services;
using Refit;
using Serilog;
using Common.Settings;
using StartupHelpers;
StartupExtensions.LoadDotEnvFile();
@@ -25,30 +35,69 @@ 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 =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsAssembly("myai-data");
sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName);
});
});
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
builder.Services.AddDbContext<EmailDbContext>(options =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName);
sql.MigrationsAssembly("email-data");
});
});
builder.Services.AddScoped<IEmailTemplateRepository, EfEmailTemplateRepository>();
builder.Services.AddSingleton<IEmailTemplateService, EmailTemplateService>();
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
builder.Services.AddSingleton<IEmailSender, EmailApiEmailSender>();
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
.ConfigureHttpClient((sp, client) =>
{
var config = sp.GetRequiredService<IConfiguration>();
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(baseUrl))
{
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
}
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);
}
var key = config["CvMatcherApi: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>();
var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty;
if (!string.IsNullOrWhiteSpace(baseUrl))
client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/");
var key = config["CvMatcherApi:InternalApiKey"];
if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key"))
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
}
builder.Services.AddRefitClient<IEmailApiClient>()
.ConfigureHttpClient(ConfigureEmailApiClient);
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.IJobSearchApi>()
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "API");
builder.Services.ConfigureCaddyForwardedHeaders();
@@ -70,6 +119,13 @@ try
app.UseRateLimiter();
app.MapControllers();
Log.Information("Running EF Core migrations if any");
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
db.Database.Migrate();
}
Log.Information("{Service} startup complete. Listening for requests...", ServiceName);
app.Run();
}
@@ -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);
}
}
+54 -1
View File
@@ -1,12 +1,65 @@
using Models.Requests;
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 (0100).</param>
/// <param name="jobLabel">Human-readable job title or label.</param>
/// <param name="language">Two-letter language code (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
/// <returns>Rendered subject string.</returns>
string BuildMatchEmailSubject(int score, string? jobLabel, string language);
/// <summary>
/// Builds the full HTML body for a CV match email, including an optional job-search footer link.
/// </summary>
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
/// <param name="result">Structured match response from the CV matcher engine.</param>
/// <param name="jobLabel">Human-readable job title or label.</param>
/// <param name="language">Two-letter language code.</param>
/// <param name="jobSearchLink">Optional one-click job-search URL to append as a footer CTA.</param>
/// <param name="expiryDays">Number of days until the job-search link expires (shown in the footer copy).</param>
/// <returns>Rendered HTML body string.</returns>
string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7);
}
}
+242
View File
@@ -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"));
}
+4
View File
@@ -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");
-239
View File
@@ -1,239 +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;
namespace Api.Services
{
public sealed class SmtpEmailSender : IEmailSender
{
private readonly SmtpSettings _smtp;
private readonly ContactSettings _contact;
private readonly SubscribeSettings _subscribe;
private readonly FileStorageSettings _fileStorage;
private readonly ILogger<SmtpEmailSender> _log;
private readonly string _environmentName;
public SmtpEmailSender(IOptions<SmtpSettings> smtp,
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
ILogger<SmtpEmailSender> log)
{
_smtp = smtp.Value;
_contact = contact.Value;
_subscribe = subscribe.Value;
_fileStorage = fileStorage.Value;
_log = log;
// Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development"
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
}
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since contact requests are important to process.
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
{
_log.LogDebug("Contact email skipped - ToEmail not configured");
throw new InvalidOperationException("Contact email recipient is not configured.");
}
_log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}",
req.Email, _contact.ToEmail);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_contact.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_contact.SubjectPrefix} [{_environmentName}] {req.Subject}".Trim();
var body =
$@"New contact form submission:
Name: {req.Name}
Email: {req.Email}
Subject: {req.Subject}
Message:
{req.Message}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "contact email", ct);
_log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email);
}
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
{
// Throw error if ToEmail is not configured, since subscription requests are important to process.
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
{
_log.LogDebug("Subscription email skipped - ToEmail not configured");
throw new InvalidOperationException("Subscription email recipient is not configured.");
}
_log.LogInformation("Processing subscription request for {Email}", req.Email);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_subscribe.ToEmail));
msg.ReplyTo.Add(MailboxAddress.Parse(req.Email));
msg.Subject = $"{_subscribe.SubjectPrefix} [{_environmentName}]".Trim();
var body =
$@"New subscription request:
Email: {req.Email}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "subscription email", ct);
_log.LogInformation("Subscription email sent successfully for {Email}", req.Email);
}
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
{
// Skip sending if ToEmail is not configured
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
{
_log.LogDebug("File download notification skipped - ToEmail not configured");
return;
}
_log.LogInformation("Preparing file download notification for {FileName}", fileName);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(_fileStorage.ToEmail));
msg.Subject = $"{_fileStorage.SubjectPrefix} [{_environmentName}] {fileName}".Trim();
var body =
$@"File download notification:
File: {fileName}
Downloaded at: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
IP Address: {userIp ?? "Unknown"}
";
msg.Body = new TextPart("plain") { Text = body };
await SendEmailAsync(msg, "file download notification email", ct);
_log.LogInformation("File download notification sent successfully for {FileName}", fileName);
}
/// <summary>
/// Connects to the SMTP server and authenticates if credentials are configured.
/// </summary>
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
{
// If you're in enterprise environments, you may need to tweak certificate validation.
// Don't disable it casually.
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
_log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}",
_smtp.Host, _smtp.Port, tls);
await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct);
if (!string.IsNullOrWhiteSpace(_smtp.Username))
{
_log.LogDebug("Authenticating with SMTP server as {Username}", _smtp.Username);
await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct);
}
}
/// <summary>
/// Sends an email message using SMTP.
/// </summary>
/// <param name="message">The email message to send.</param>
/// <param name="messageType">Description of the message type for logging purposes.</param>
/// <param name="ct">Cancellation token.</param>
private async Task SendEmailAsync(MimeMessage message, string messageType, CancellationToken ct)
{
using var client = new SmtpClient();
await ConnectAndAuthenticateAsync(client, ct);
_log.LogDebug("Sending {MessageType} message", messageType);
await client.SendAsync(message, ct);
await client.DisconnectAsync(true, ct);
}
public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct)
{
var recipients = new List<string>();
if (!string.IsNullOrWhiteSpace(explicitTo))
{
recipients.Add(explicitTo);
}
if (!string.IsNullOrWhiteSpace(_contact.ToEmail) &&
!recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase)))
{
recipients.Add(_contact.ToEmail);
}
if (recipients.Count == 0)
{
_log.LogDebug("Match email skipped - no recipients configured (user email and Contact:ToEmail missing)");
return;
}
foreach (var recipient in recipients)
{
_log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient);
var msg = new MimeMessage();
msg.From.Add(MailboxAddress.Parse(_smtp.Username));
msg.To.Add(MailboxAddress.Parse(recipient));
msg.Subject = $"[{_environmentName}] {subject}".Trim();
var builder = new BodyBuilder
{
TextBody = body
};
if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath))
{
builder.Attachments.Add(attachmentPath);
}
msg.Body = builder.ToMessageBody();
await SendEmailAsync(msg, "cv match email", ct);
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
}
}
public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel) => $@"CV Matcher result
CV Document ID: {cvDocumentId}
Job: {jobLabel ?? "N/A"}
Job URL: {result.JobUrl ?? "N/A"}
Score: {result.Score}%
Summary:
{result.Summary}
Strengths:
- {string.Join("\n- ", result.Strengths)}
Gaps:
- {string.Join("\n- ", result.Gaps)}
Recommendations:
- {string.Join("\n- ", result.Recommendations)}";
public static string BuildMatchEmailSubject(int score, string? jobLabel)
=> $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}";
}
}
+16 -13
View File
@@ -16,18 +16,18 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="DotNetEnv" Version="3.2.0" />
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" />
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
<!-- 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" />
<PackageReference Include="Serilog.Sinks.Email" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
<PackageReference Include="Refit.HttpClientFactory" />
</ItemGroup>
<ItemGroup>
@@ -36,9 +36,12 @@
<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="..\shared-models\shared-models.csproj" />
<ProjectReference Include="..\common\common.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
<ProjectReference Include="..\myai-data\myai-data.csproj" />
</ItemGroup>
</Project>
+5 -21
View File
@@ -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,7 +1,7 @@
using Microsoft.AspNetCore.Http;
using System.ComponentModel.DataAnnotations;
namespace Shared.Models.Requests
namespace Common.Requests
{
public class UploadFileRequest
{
+16
View File
@@ -0,0 +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; }
}
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings
namespace Common.Settings
{
public class AiSettings
{
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings
namespace Common.Settings
{
public class DatabaseSettings
{
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings
namespace Common.Settings
{
public class InternalApiSettings
{
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings
namespace Common.Settings
{
public class OllamaSettings
{
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings
namespace Common.Settings
{
public class OpenAiSettings
{
@@ -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;
}
@@ -1,4 +1,4 @@
namespace Shared.Models.Settings
namespace Common.Settings
{
public class RateLimitingSettings
{
@@ -1,14 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Shared.Models</RootNamespace>
<AssemblyName>common</AssemblyName>
<RootNamespace>Common</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
@@ -0,0 +1,12 @@
namespace CvMatcher.Models.Requests;
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; }
/// <summary>Client IP address forwarded by the api layer at CV match time. Null when not available.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -7,5 +7,9 @@
public string? JobDescription { get; set; }
public bool GdprConsent { get; set; }
public string? Email { get; set; }
/// <summary>ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en".</summary>
public string? Language { get; set; }
/// <summary>Client IP address forwarded by the api layer. Null when called from a background job.</summary>
public string? ClientIpAddress { get; set; }
}
}
@@ -0,0 +1,11 @@
namespace CvMatcher.Models.Requests;
/// <summary>
/// Request body sent by <c>api</c> when activating a one-time job-search link.
/// Carries the caller's IP address so it can be persisted on the session for auditing.
/// </summary>
public sealed class StartJobSearchRequest
{
/// <summary>Client IP address forwarded by the api layer. Null when not available.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -0,0 +1,10 @@
namespace CvMatcher.Models.Responses;
public sealed class CreateJobSearchTokenResponse
{
/// <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; }
@@ -0,0 +1,14 @@
namespace CvMatcher.Models.Responses;
public sealed class StartJobSearchResponse
{
public string Status { get; set; } = string.Empty;
}
public static class StartJobSearchStatus
{
public const string Started = "Started";
public const string AlreadyUsed = "AlreadyUsed";
public const string Expired = "Expired";
public const string NotFound = "NotFound";
}
@@ -1,8 +1,8 @@
using Shared.Models.Settings;
using Common.Settings;
namespace CvMatcher.Models.Settings;
public sealed class AiSettings : Shared.Models.Settings.AiSettings
public sealed class AiSettings : Common.Settings.AiSettings
{
public OpenAiSettings OpenAI { get; set; } = new();
public OllamaSettings Ollama { get; set; } = new();
@@ -0,0 +1,29 @@
namespace CvMatcher.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;
}
/// <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;
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;
/// <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;
}
@@ -8,7 +8,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\shared-models\shared-models.csproj" />
<ProjectReference Include="..\common\common.csproj" />
</ItemGroup>
</Project>
+60
View File
@@ -0,0 +1,60 @@
# cv-matcher-api — Internal CV Match Engine
Internal port 8082. Only reachable from `api` and `cv-search-job` via `X-Internal-Api-Key`.
## Responsibilities
- Indexes CV PDFs into the RAG system via `rag-api`
- Matches a CV against a job posting URL (scrapes job HTML, scores pair with LLM)
- Manages job search tokens and sessions for the one-click job search feature
- Owns two EF DbContexts: `CvMatcherDbContext` (schema `cvMatcher`) and `CvSearchDbContext` (schema `cvSearch`)
- Runs EF migrations for both contexts on startup
## Key routes
| Method | Route | Description |
|--------|-------|-------------|
| POST | `/api/cv/upload` | Index CV PDF into RAG |
| POST | `/api/cv/match-job` | Score CV against a job URL (LLM call) |
| POST | `/api/cv/find-jobs` | Find matching jobs from the RAG index |
| POST | `/api/cv/job-search/token` | Create a job search token (called by api after a match) |
| POST | `/api/cv/job-search/token/{tokenId}/start` | Validate token, create Pending session (called by api on link click) |
| GET | `/api/health` | Health check |
## Core services
- `CvMatcherService` — orchestrates upload + match; calls `IRagApiClient` and `IMatcherAiClient`
- `JobTextExtractor` — fetches a job page URL and extracts plain text
- `JobTokenService` — creates tokens; validates + starts job search sessions; extracts CV keywords using simple heuristics (first 5 meaningful non-empty lines of CV text, split into words)
## AI providers
Configured under `Ai:Provider` (`OpenAI` or `Ollama`). Both providers implement `IMatcherAiClient`.
Default model: `gpt-4o-mini`. Timeout: 90 s.
## Database contexts
Both contexts use the same SQL Server connection string (from `Database:*` settings).
- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-data` assembly (`Apis/cv-matcher-data/`)
- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-data` assembly (`Apis/cv-search-data/`)
## Keyword extraction (JobTokenService.ExtractKeywords)
No LLM call. Takes the first 5 non-empty lines of CV text that are:
- Longer than 5 characters
- Not purely numeric or contact-line patterns
Splits into words, strips punctuation, deduplicates, returns up to 10 comma-separated keywords.
These keywords are stored in `JobSearchSessionEntity.Keywords` and used by `cv-search-job` for scraping.
## Settings
| Section | Notes |
|---------|-------|
| `Database` | Shared SQL Server connection |
| `RagApi` | BaseUrl + InternalApiKey for rag-api |
| `Ai` | Provider, model, timeout |
| `Matcher` | TopK, DeepScoreTopN, MaxJobTextChars |
| `JobSearch` | TokenExpiryDays, providers list (stored in session JSON) |
| `InternalApi` | ApiKey used by UseInternalApiKeyProtection middleware |
@@ -1,6 +1,6 @@
using Microsoft.Extensions.Options;
using CvMatcher.Models.Settings;
using Api.Data.Repositories.Contracts;
using CvMatcher.Data.Repositories.Contracts;
using Api.Clients.Ai.Contracts;
using CommonHelpers;
@@ -3,7 +3,7 @@ using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Api.Clients.Ai.Contracts;
using Api.Data.Repositories.Contracts;
using CvMatcher.Data.Repositories.Contracts;
using CommonHelpers;
using CvMatcher.Models.Settings;
using Microsoft.Extensions.Options;
+45 -11
View File
@@ -2,12 +2,16 @@ using CvMatcher.Models.Requests;
using Api.Services.Contracts;
using Microsoft.AspNetCore.Mvc;
using CvMatcher.Models.Responses;
using Shared.Models.Requests;
using Common.Requests;
using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses;
using Common.Responses;
namespace Api.Controllers;
/// <summary>
/// Internal endpoints for CV indexing and job-matching operations.
/// Routes are prefixed with <c>api/cv</c>. Protected by the internal API key middleware — not reachable from the public internet.
/// </summary>
[ApiController]
[Route("api/cv")]
public sealed class CvController : ControllerBase
@@ -21,11 +25,21 @@ public sealed class CvController : ControllerBase
_logger = logger;
}
/// <summary>
/// Uploads and indexes a CV PDF into the RAG vector store.
/// Returns from cache immediately if an identical document was previously indexed.
/// </summary>
/// <param name="request">Multipart form containing the CV PDF file.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with a <see cref="CvUploadResponse"/> containing the document ID and whether it was a cache hit;
/// 400 Bad Request if the file is missing or the request is otherwise invalid.
/// </returns>
[HttpPost("upload")]
[RequestSizeLimit(10 * 1024 * 1024)]
[SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it for matching.")]
[SwaggerResponse(StatusCodes.Status200OK, "CV uploaded and indexed successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid upload request")]
[SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it into the RAG vector store. Returns from cache if the same document was previously uploaded.")]
[SwaggerResponse(StatusCodes.Status200OK, "CV indexed successfully", typeof(CvUploadResponse))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "File missing or request invalid", typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<CvUploadResponse>> Upload([FromForm] UploadFileRequest request, CancellationToken ct)
@@ -45,10 +59,19 @@ public sealed class CvController : ControllerBase
}
}
/// <summary>
/// Returns the top matching job documents for a previously indexed CV using semantic vector search.
/// </summary>
/// <param name="request">The request containing the CV document ID and the maximum number of results to return.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with a <see cref="FindJobsResponse"/> containing the ranked list of matching jobs;
/// 400 Bad Request if the CV document ID is missing or invalid.
/// </returns>
[HttpPost("find-jobs")]
[SwaggerOperation(Summary = "Find matching jobs", Description = "Finds top matching jobs for a previously uploaded CV document.")]
[SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid find jobs request")]
[SwaggerOperation(Summary = "Find matching jobs", Description = "Performs semantic search over indexed job documents to find the best matches for a given CV.")]
[SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned", typeof(FindJobsResponse))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "CV document ID missing or invalid", typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<FindJobsResponse>> FindJobs([FromBody] FindJobsRequest request, CancellationToken ct)
@@ -67,10 +90,21 @@ public sealed class CvController : ControllerBase
}
}
/// <summary>
/// Scores a CV against a single job using LLM analysis.
/// Fetches and extracts job text from the provided URL if no inline description is supplied,
/// then runs a deep semantic match and returns a score with strengths and gaps.
/// </summary>
/// <param name="request">The match request: CV document ID plus either a job URL or an inline job description.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with a <see cref="JobMatchResponse"/> containing the score (0100), strengths, gaps, and cache status;
/// 400 Bad Request if required fields are missing or the request is invalid.
/// </returns>
[HttpPost("match-job")]
[SwaggerOperation(Summary = "Match CV to one job", Description = "Computes detailed match analysis between a CV and a single job description or URL.")]
[SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully")]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid match job request")]
[SwaggerOperation(Summary = "Match CV to one job", Description = "Scores a CV against a job URL or description using LLM analysis and returns a match score with strengths and gaps.")]
[SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully", typeof(JobMatchResponse))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "Required fields missing or request invalid", typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<JobMatchResponse>> MatchJob([FromBody] MatchJobRequest request, CancellationToken ct)
@@ -0,0 +1,97 @@
using Api.Services.Contracts;
using CvMatcher.Models.Requests;
using CvMatcher.Models.Responses;
using Microsoft.AspNetCore.Mvc;
using Common.Responses;
using Swashbuckle.AspNetCore.Annotations;
namespace Api.Controllers;
/// <summary>
/// Internal endpoints for managing one-click job-search tokens and sessions.
/// Routes are prefixed with <c>api/cv/job-search</c>. Protected by the internal API key middleware — not reachable from the public internet.
/// </summary>
[ApiController]
[Route("api/cv/job-search")]
public sealed class JobSearchController : ControllerBase
{
private readonly IJobTokenService _tokenService;
private readonly ILogger<JobSearchController> _logger;
public JobSearchController(IJobTokenService tokenService, ILogger<JobSearchController> logger)
{
_tokenService = tokenService;
_logger = logger;
}
/// <summary>
/// Creates a one-time job-search token linked to a CV document and email address.
/// Called by <c>api</c> immediately after a successful CV match when an email is provided.
/// The token is embedded in the job-search link sent to the user's email.
/// </summary>
/// <param name="request">The CV document ID and the recipient email address.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with a <see cref="CreateJobSearchTokenResponse"/> containing the generated token ID;
/// 400 Bad Request if <c>CvDocumentId</c> or <c>Email</c> is missing;
/// 500 Internal Server Error if token creation fails.
/// </returns>
[HttpPost("token")]
[SwaggerOperation(Summary = "Create job search token", Description = "Creates a one-time token that lets the user start a background job search by clicking the link in their match email.")]
[SwaggerResponse(StatusCodes.Status200OK, "Token created successfully", typeof(CreateJobSearchTokenResponse))]
[SwaggerResponse(StatusCodes.Status400BadRequest, "CvDocumentId or Email missing", typeof(ErrorResponse))]
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Token creation failed", typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<CreateJobSearchTokenResponse>> CreateToken(
[FromBody] CreateJobSearchTokenRequest request,
CancellationToken ct)
{
try
{
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" });
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, request.ClientIpAddress, ct);
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create job search token.");
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to create token.", Code = "token_create_failed" });
}
}
/// <summary>
/// Validates the one-time token, marks it as used, and enqueues a <c>JobSearchSession</c> with status <c>Pending</c>.
/// Called by <c>api</c> when the user clicks the job-search link in their match email.
/// The <c>cv-search-job</c> worker picks up the pending session and runs the search.
/// </summary>
/// <param name="tokenId">The UUID token extracted from the email link.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// 200 OK with a <see cref="StartJobSearchResponse"/> whose <c>Status</c> is one of
/// <c>Started</c>, <c>AlreadyUsed</c>, or <c>Expired</c>;
/// 500 Internal Server Error if the session cannot be created.
/// </returns>
[HttpPost("token/{tokenId}/start")]
[SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and creates a Pending job search session for the cv-search-job worker to process.")]
[SwaggerResponse(StatusCodes.Status200OK, "Search status returned (Started, AlreadyUsed, or Expired)", typeof(StartJobSearchResponse))]
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Session creation failed", typeof(ErrorResponse))]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, [FromBody] StartJobSearchRequest? request, CancellationToken ct)
{
try
{
var status = await _tokenService.TriggerStartAsync(tokenId, request?.ClientIpAddress, ct);
return Ok(new StartJobSearchResponse { Status = status });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to start job search for token {TokenId}.", tokenId);
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to start search.", Code = "start_failed" });
}
}
}
@@ -1,11 +0,0 @@
namespace Api.Data.Entities;
public sealed class CvMatchResultEntity
{
public string Id { get; set; } = string.Empty;
public string CvDocumentId { get; set; } = string.Empty;
public string JobDocumentId { get; set; } = string.Empty;
public string ResultJson { get; set; } = string.Empty;
public int Score { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
+14 -3
View File
@@ -2,17 +2,28 @@ 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/shared-models/shared-models.csproj Apis/shared-models/
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/
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj
COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/
COPY Apis/shared-models/ Apis/shared-models/
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/
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
@@ -25,4 +36,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "cv-matcher-api.dll"]
ENTRYPOINT ["dotnet", "cv-matcher-api.dll"]
@@ -1,70 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Api.Migrations
{
/// <inheritdoc />
public partial class InitialCvMatcherSchema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "cvMatcher");
migrationBuilder.CreateTable(
name: "ChatCache",
schema: "cvMatcher",
columns: table => new
{
CacheKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
Model = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
Temperature = table.Column<decimal>(type: "decimal(4,2)", nullable: false),
ResponseText = table.Column<string>(type: "nvarchar(max)", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_ChatCache", x => x.CacheKey);
});
migrationBuilder.CreateTable(
name: "Results",
schema: "cvMatcher",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
JobDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Score = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
},
constraints: table =>
{
table.PrimaryKey("PK_Results", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_Results_CvDocumentId_JobDocumentId",
schema: "cvMatcher",
table: "Results",
columns: new[] { "CvDocumentId", "JobDocumentId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChatCache",
schema: "cvMatcher");
migrationBuilder.DropTable(
name: "Results",
schema: "cvMatcher");
}
}
}
+36 -5
View File
@@ -2,16 +2,18 @@ using Api.Clients.Ai;
using Api.Clients.Ai.Contracts;
using Api.Clients.Api;
using Api.Clients.Api.Contracts;
using Api.Data;
using Api.Data.Repositories;
using Api.Data.Repositories.Contracts;
using CvMatcher.Data;
using CvMatcher.Data.Repositories;
using CvMatcher.Data.Repositories.Contracts;
using Api.Services;
using Api.Services.Contracts;
using CvMatcher.Models.Settings;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Refit;
using Serilog;
using Shared.Models.Settings;
using Common.Settings;
using PageFetcher.Models;
using StartupHelpers;
using System.Reflection;
@@ -34,6 +36,17 @@ try
builder.Services.Configure<InternalApiSettings>(builder.Configuration.GetSection("InternalApi"));
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) =>
@@ -48,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 =>
{
@@ -58,11 +71,24 @@ try
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsHistoryTable(CvMatcherDbContext.MigrationTableName, CvMatcherDbContext.SchemaName);
sql.MigrationsAssembly("cv-matcher-data");
});
});
builder.Services.AddDbContext<CvSearchDbContext>(options =>
{
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
options.UseSqlServer(connectionString, sql =>
{
sql.MigrationsAssembly("cv-search-data");
sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName);
});
});
builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>();
builder.Services.AddScoped<IAiPromptsRepository, EfAiPromptsRepository>();
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
builder.Services.AddScoped<IJobTokenService, JobTokenService>();
builder.Services.AddControllers();
builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName);
@@ -90,6 +116,11 @@ try
var db = scope.ServiceProvider.GetRequiredService<CvMatcherDbContext>();
db.Database.Migrate();
}
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
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);
}
@@ -0,0 +1,35 @@
namespace Api.Services.Contracts;
/// <summary>
/// Manages one-time job search tokens and the sessions they trigger.
/// </summary>
public interface IJobTokenService
{
/// <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, string? clientIpAddress, CancellationToken ct);
/// <summary>
/// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session.
/// </summary>
/// <param name="tokenId">The token ID from the one-click link.</param>
/// <param name="clientIpAddress">Client IP address forwarded by the api layer. Null when not available.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>
/// One of the <c>StartJobSearchStatus</c> string constants:
/// <c>Started</c>, <c>AlreadyUsed</c>, <c>Expired</c>, or <c>NotFound</c>.
/// </returns>
Task<string> TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct);
}
@@ -1,7 +1,7 @@
using System.Text.Json;
using Api.Clients.Ai.Contracts;
using Api.Clients.Api.Contracts;
using Api.Data.Repositories.Contracts;
using CvMatcher.Data.Repositories.Contracts;
using CvMatcher.Models.Requests;
using CvMatcher.Models.Responses;
using CvMatcher.Models.Settings;
@@ -10,12 +10,16 @@ using Microsoft.Extensions.Options;
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;
public CvMatcherService(
@@ -23,15 +27,18 @@ public sealed class CvMatcherService : ICvMatcherService
IJobTextExtractor jobTextExtractor,
IMatcherAiClient ai,
IMatcherRepository repository,
IAiPromptsRepository aiPrompts,
IOptions<MatcherSettings> options)
{
_rag = rag;
_jobTextExtractor = jobTextExtractor;
_ai = ai;
_repository = repository;
_aiPrompts = aiPrompts;
_settings = options.Value;
}
/// <inheritdoc />
public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct)
{
var response = await _rag.IndexCvPdfAsync(file, ct);
@@ -48,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.");
@@ -69,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, 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.");
@@ -98,23 +107,27 @@ 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, 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, 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, ct);
var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct);
if (cached is not null) return cached;
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);
const string systemPrompt = """
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.
JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]}
""";
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:
@@ -132,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, 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
@@ -163,38 +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();
/// <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;
@@ -0,0 +1,137 @@
using System.Text.Json;
using Api.Services.Contracts;
using CvMatcher.Models.Responses;
using CvSearch.Data;
using CvSearch.Data.Entities;
using CvMatcher.Models.Settings;
using Microsoft.EntityFrameworkCore;
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 JobSearchSettings _settings;
private readonly ILogger<JobTokenService> _logger;
public JobTokenService(
CvSearchDbContext db,
IOptions<JobSearchSettings> settings,
ILogger<JobTokenService> logger)
{
_db = db;
_settings = settings.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, string? clientIpAddress, CancellationToken ct)
{
var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct);
if (!hasEnabledProviders)
{
_logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders");
return null;
}
var token = new JobSearchTokenEntity
{
Id = Guid.NewGuid().ToString("N"),
CvDocumentId = cvDocumentId,
Email = email,
Language = language,
Keywords = string.Join(",", keywords),
Location = location,
ClientIpAddress = clientIpAddress,
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
Used = false,
CreatedAt = DateTime.UtcNow
};
_db.JobSearchTokens.Add(token);
await _db.SaveChangesAsync(ct);
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}, Location={Location}", token.Id, cvDocumentId, token.Keywords, token.Location);
return token.Id;
}
/// <inheritdoc />
public async Task<string> TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct)
{
var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct);
if (token is null) return StartJobSearchStatus.NotFound;
if (token.Used) return StartJobSearchStatus.AlreadyUsed;
if (token.ExpiresAt <= DateTime.UtcNow) return StartJobSearchStatus.Expired;
token.Used = true;
await _db.SaveChangesAsync(ct);
var keywords = token.Keywords;
var enabledProviders = await _db.JobProviders
.Where(p => p.Enabled)
.OrderBy(p => p.DisplayOrder)
.ToListAsync(ct);
var providerConfigJson = JsonSerializer.Serialize(
enabledProviders.Select(ToConfig).ToList(),
new JsonSerializerOptions(JsonSerializerDefaults.Web));
var session = new JobSearchSessionEntity
{
Id = Guid.NewGuid().ToString("N"),
TokenId = token.Id,
CvDocumentId = token.CvDocumentId,
Email = token.Email,
Language = token.Language,
Status = JobSearchStatus.Pending,
Keywords = keywords,
Location = token.Location,
ClientIpAddress = clientIpAddress,
ProviderConfigJson = providerConfigJson,
CreatedAt = DateTime.UtcNow
};
_db.JobSearchSessions.Add(session);
await _db.SaveChangesAsync(ct);
_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 JobProviderConfig ToConfig(JobProviderEntity entity)
{
List<string> keywords;
try
{
keywords = JsonSerializer.Deserialize<List<string>>(entity.InitialKeywordsJson,
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
}
catch
{
keywords = [];
}
return new JobProviderConfig
{
Name = entity.Name,
Enabled = entity.Enabled,
SearchUrlTemplate = entity.SearchUrlTemplate,
JobLinkContains = entity.JobLinkContains,
InitialKeywords = keywords,
MaxResults = entity.MaxResults,
RequireKeywordInAnchor = entity.RequireKeywordInAnchor
};
}
}
+8 -21
View File
@@ -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": [
@@ -106,5 +86,12 @@
"TopK": 10,
"DeepScoreTopN": 5,
"MaxJobTextChars": 60000
},
"JobSearch": {
"Enabled": true,
"JobSearchLinkBaseUrl": "https://myai.ro",
"TokenExpiryDays": 7,
"MinMatchScore": 15,
"MaxJobsToMatch": 15
}
}
+19 -16
View File
@@ -58,28 +58,31 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="DotNetEnv" Version="3.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MailKit" Version="4.16.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" />
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
<PackageReference Include="MailKit" />
<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" />
<PackageReference Include="Refit.HttpClientFactory" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
<ProjectReference Include="..\shared-models\shared-models.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
</ItemGroup>
<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" />
</ItemGroup>
</Project>
@@ -1,13 +1,12 @@
using Api.Data.Entities;
using CvMatcher.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace Api.Data;
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)
{
@@ -15,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)
{
@@ -29,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 =>
@@ -42,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()");
});
}
}
@@ -0,0 +1,10 @@
namespace CvMatcher.Data.Entities;
public sealed class AiPromptEntity
{
public string Key { get; set; } = string.Empty;
public string Language { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public DateTime UpdatedAt { get; set; }
}
@@ -0,0 +1,14 @@
using Shared.Data.Entities;
namespace CvMatcher.Data.Entities;
public sealed class CvMatchResultEntity : BaseEntity
{
public string CvDocumentId { get; set; } = string.Empty;
public string JobDocumentId { get; set; } = string.Empty;
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; }
}
@@ -1,5 +1,6 @@
namespace Api.Data.Entities;
namespace CvMatcher.Data.Entities;
// CacheKey PK — BaseEntity not applicable
public sealed class CvMatcherChatCacheEntity
{
public string CacheKey { get; set; } = string.Empty;
@@ -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,6 +1,6 @@
// <auto-generated />
using System;
using Api.Data;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Api.Migrations
namespace CvMatcher.Data.Migrations
{
[DbContext(typeof(CvMatcherDbContext))]
[Migration("20260507140442_InitialCvMatcherSchema")]
partial class InitialCvMatcherSchema
[Migration("20260601133028_InitialSchema")]
partial class InitialSchema
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -26,7 +26,38 @@ namespace Api.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b =>
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)
@@ -47,6 +78,10 @@ namespace Api.Migrations
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
@@ -56,13 +91,13 @@ namespace Api.Migrations
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId")
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
.IsUnique();
b.ToTable("Results", "cvMatcher");
});
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
{
b.Property<string>("CacheKey")
.HasMaxLength(64)
@@ -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);
}
}
}
@@ -0,0 +1,130 @@
// <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("20260608124331_ImproveKeywordsAndAddLocation")]
partial class ImproveKeywordsAndAddLocation
{
/// <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<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
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,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."
]);
}
}
}
@@ -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,6 +1,6 @@
// <auto-generated />
using System;
using Api.Data;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace Api.Migrations
namespace CvMatcher.Data.Migrations
{
[DbContext(typeof(CvMatcherDbContext))]
partial class CvMatcherDbContextModelSnapshot : ModelSnapshot
@@ -23,12 +23,47 @@ namespace Api.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b =>
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,11 +74,19 @@ namespace Api.Migrations
.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)");
@@ -53,13 +96,13 @@ namespace Api.Migrations
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId")
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
.IsUnique();
b.ToTable("Results", "cvMatcher");
});
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
{
b.Property<string>("CacheKey")
.HasMaxLength(64)
@@ -0,0 +1,6 @@
namespace CvMatcher.Data.Repositories.Contracts;
public interface IAiPromptsRepository
{
Task<string?> GetAsync(string key, string language, CancellationToken ct);
}
@@ -1,12 +1,12 @@
using CvMatcher.Models.Responses;
namespace Api.Data.Repositories.Contracts;
namespace CvMatcher.Data.Repositories.Contracts;
public interface IMatcherRepository
{
Task InitializeAsync(CancellationToken ct);
Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct);
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct);
Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct);
Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct);
Task<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);
}
}
@@ -1,11 +1,12 @@
using System.Text.Json;
using Api.Data;
using Api.Data.Entities;
using Api.Data.Repositories.Contracts;
using CvMatcher.Data;
using CvMatcher.Data.Entities;
using CvMatcher.Data.Repositories.Contracts;
using CvMatcher.Models.Responses;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Api.Data.Repositories;
namespace CvMatcher.Data.Repositories;
public sealed class EfMatcherRepository : IMatcherRepository
{
@@ -24,11 +25,11 @@ public sealed class EfMatcherRepository : IMatcherRepository
//await _db.Database.EnsureCreatedAsync(ct);
}
public async Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct)
public async Task<JobMatchResponse?> GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct)
{
var json = await _db.CvMatchResults
.AsNoTracking()
.Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId)
.Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language)
.Select(x => x.ResultJson)
.FirstOrDefaultAsync(ct);
@@ -39,25 +40,41 @@ public sealed class EfMatcherRepository : IMatcherRepository
return result;
}
public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, 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 => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language,
ct);
if (exists) return;
_db.CvMatchResults.Add(new CvMatchResultEntity
try
{
Id = Guid.NewGuid().ToString("N"),
CvDocumentId = cvDocumentId,
JobDocumentId = jobDocumentId,
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)
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>cv-matcher-data</AssemblyName>
<RootNamespace>CvMatcher.Data</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\shared-data\shared-data.csproj" />
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,88 @@
using CvSearch.Data.Entities;
using Microsoft.EntityFrameworkCore;
namespace CvSearch.Data;
public sealed class CvSearchDbContext : DbContext
{
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)
{
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.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty);
entity.Property(x => x.Used).HasDefaultValue(false);
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
});
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.ClientIpAddress).HasMaxLength(45);
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.Email).HasMaxLength(256);
entity.Property(x => x.ClientIpAddress).HasMaxLength(45);
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;
}
@@ -0,0 +1,18 @@
using Shared.Data.Entities;
namespace CvSearch.Data.Entities;
public sealed class JobSearchResultEntity : BaseEntity
{
public string SessionId { get; set; } = string.Empty;
public string ProviderName { get; set; } = string.Empty;
public string JobUrl { get; set; } = string.Empty;
public string JobTitle { get; set; } = string.Empty;
public string JobText { get; set; } = string.Empty;
public int Score { get; set; }
public string ResultJson { get; set; } = string.Empty;
/// <summary>Email address of the user who triggered the search. Copied from the parent session.</summary>
public string? Email { get; set; }
/// <summary>Client IP address at link-click time. Copied from the parent session.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -0,0 +1,25 @@
using Shared.Data.Entities;
namespace CvSearch.Data.Entities;
public sealed class JobSearchSessionEntity : BaseEntity
{
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? Location { get; set; }
/// <summary>Client IP address captured when the user clicked the one-time job-search link. Null for sessions created before this field was added.</summary>
public string? ClientIpAddress { get; set; }
public string? ProviderConfigJson { get; set; }
public string Language { get; set; } = "en";
}
public static class JobSearchStatus
{
public const string Pending = "Pending";
public const string Processing = "Processing";
public const string Done = "Done";
public const string Failed = "Failed";
}
@@ -0,0 +1,16 @@
using Shared.Data.Entities;
namespace CvSearch.Data.Entities;
public sealed class JobSearchTokenEntity : BaseEntity
{
public string CvDocumentId { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Language { get; set; } = "en";
public DateTime ExpiresAt { get; set; }
public bool Used { get; set; }
public string Keywords { get; set; } = string.Empty;
public string? Location { get; set; }
/// <summary>Client IP address captured when the user submitted the CV match request. Null for tokens created before this field was added.</summary>
public string? ClientIpAddress { get; set; }
}
@@ -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";
}
@@ -0,0 +1,160 @@
// <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("20260522093356_AddJobSearchTables")]
partial class AddJobSearchTables
{
/// <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.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>("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<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,103 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddJobSearchTables : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: MigrationConstants.SchemaName);
migrationBuilder.CreateTable(
name: "JobSearchResults",
schema: MigrationConstants.SchemaName,
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: MigrationConstants.SchemaName,
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: 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),
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: MigrationConstants.SchemaName,
table: "JobSearchResults",
column: "SessionId");
migrationBuilder.CreateIndex(
name: "IX_JobSearchSessions_Status",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions",
column: "Status");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JobSearchResults",
schema: MigrationConstants.SchemaName);
migrationBuilder.DropTable(
name: "JobSearchSessions",
schema: MigrationConstants.SchemaName);
migrationBuilder.DropTable(
name: "JobSearchTokens",
schema: MigrationConstants.SchemaName);
}
}
}
@@ -0,0 +1,174 @@
// <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("20260524145702_AddLanguageToJobSearchEntities")]
partial class AddLanguageToJobSearchEntities
{
/// <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.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
using CvSearch.Data;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddLanguageToJobSearchEntities : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
migrationBuilder.AddColumn<string>(
name: "Language",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions",
type: "nvarchar(8)",
maxLength: 8,
nullable: false,
defaultValue: "en");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
schema: MigrationConstants.SchemaName,
table: "JobSearchTokens");
migrationBuilder.DropColumn(
name: "Language",
schema: MigrationConstants.SchemaName,
table: "JobSearchSessions");
}
}
}
@@ -0,0 +1,222 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260529084440_AddJobProviders")]
partial class AddJobProviders
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -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);
}
}
}
@@ -0,0 +1,229 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260529130000_AddKeywordsToJobSearchTokens")]
partial class AddKeywordsToJobSearchTokens
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -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");
}
}
}
@@ -0,0 +1,229 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260529160000_FixBestJobsLinkFilter")]
partial class FixBestJobsLinkFilter
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -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/");
}
}
}
@@ -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");
}
}
}
@@ -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");
}
}
}
@@ -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}");
}
}
}
@@ -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);
}
}
}

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