Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99e5cfb76b | |||
| 91b2baa445 | |||
| 0f64cb8d99 | |||
| b67e926c5f | |||
| f7d856147e | |||
| 8679bd1efd | |||
| 1bcf95d8d4 | |||
| 73f67d1342 | |||
| 650505c08d | |||
| 4066ab5f3f | |||
| 7a316b4a45 | |||
| 808a4901d9 | |||
| b5b654532c | |||
| 2838885e22 | |||
| 8f90a4cfda | |||
| 978dd3a069 | |||
| f9530b168f | |||
| 9cb38e5bc8 | |||
| d4c05d7d44 | |||
| e3e088a365 | |||
| b114156e9c | |||
| 64e003a639 | |||
| 7ea59d0940 | |||
| 823cbecb84 | |||
| bf9b35eda2 | |||
| dc3051f447 | |||
| bd1d4cf792 | |||
| 0bc860b1a7 | |||
| 070aa329fe | |||
| 87de7d3f77 | |||
| 8b143dcb12 | |||
| 6bb00163ae | |||
| a04e35bd82 | |||
| 06bec9b0ae | |||
| e38f40732f | |||
| 209325ace5 | |||
| 5ae65642c4 | |||
| 9cf3db089d | |||
| e5b6f19c1a | |||
| 9bedf57f39 | |||
| b78ede23cf | |||
| a467fac35d | |||
| 25731868ee | |||
| c675954f8a | |||
| c8d1a21736 | |||
| d0d45bd2d3 | |||
| 7c09f5a871 |
@@ -181,11 +181,14 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var tokenResp = await _jobSearchApi.CreateTokenAsync(
|
var tokenResp = await _jobSearchApi.CreateTokenAsync(
|
||||||
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language },
|
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location },
|
||||||
ct);
|
ct);
|
||||||
|
if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
|
||||||
|
{
|
||||||
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
|
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
|
||||||
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
|
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Could not create job search token. Email link will be omitted.");
|
_logger.LogWarning(ex, "Could not create job search token. Email link will be omitted.");
|
||||||
@@ -243,38 +246,24 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
{
|
{
|
||||||
var result = await _jobSearchApi.StartSearchAsync(t, ct);
|
var result = await _jobSearchApi.StartSearchAsync(t, ct);
|
||||||
var lang = "en";
|
var lang = "en";
|
||||||
var html = result.Status switch
|
var (title, message) = result.Status switch
|
||||||
{
|
{
|
||||||
StartJobSearchStatus.Started =>
|
StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
|
||||||
HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
|
StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
|
||||||
StartJobSearchStatus.AlreadyUsed =>
|
StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
|
||||||
HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
|
_ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
|
||||||
StartJobSearchStatus.Expired =>
|
|
||||||
HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
|
|
||||||
_ =>
|
|
||||||
HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
|
|
||||||
};
|
};
|
||||||
return Content(html, "text/html");
|
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Job search start failed for token {Token}.", t);
|
_logger.LogError(ex, "Job search start failed for token {Token}.", t);
|
||||||
return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html");
|
var title = _templates.Get("html.job-search.error.title", "en");
|
||||||
|
var message = _templates.Get("html.job-search.error.message", "en");
|
||||||
|
return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string HtmlPage(string title, string message) => $$"""
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head><meta charset="utf-8"><title>{{title}} - MyAi.ro</title>
|
|
||||||
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}
|
|
||||||
.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}
|
|
||||||
h1{font-size:1.4rem;margin-bottom:.5rem}p{color:#555}</style>
|
|
||||||
</head>
|
|
||||||
<body><div class="card"><h1>{{title}}</h1><p>{{message}}</p></div></body>
|
|
||||||
</html>
|
|
||||||
""";
|
|
||||||
|
|
||||||
private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
|
private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Microsoft.Extensions.Options;
|
|||||||
using Microsoft.Net.Http.Headers;
|
using Microsoft.Net.Http.Headers;
|
||||||
using Swashbuckle.AspNetCore.Annotations;
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
using Common.Responses;
|
using Common.Responses;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace Api.Controllers
|
namespace Api.Controllers
|
||||||
{
|
{
|
||||||
@@ -17,38 +18,44 @@ namespace Api.Controllers
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
[EnableCors("FrontendOnly")]
|
[EnableCors("FrontendOnly")]
|
||||||
|
[EnableRateLimiting("download")]
|
||||||
public sealed class FileDownloadController : ControllerBase
|
public sealed class FileDownloadController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILogger<FileDownloadController> _logger;
|
private readonly ILogger<FileDownloadController> _logger;
|
||||||
private readonly FileStorageSettings _fileStorageSettings;
|
private readonly FileStorageSettings _fileStorageSettings;
|
||||||
private readonly IContentTypeProvider _contentTypeProvider;
|
private readonly IContentTypeProvider _contentTypeProvider;
|
||||||
private readonly IEmailSender _emailSender;
|
private readonly IEmailSender _emailSender;
|
||||||
private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance
|
private readonly ICaptchaVerifier _captcha;
|
||||||
|
private const int BufferSize = 81920;
|
||||||
|
|
||||||
public FileDownloadController(
|
public FileDownloadController(
|
||||||
ILogger<FileDownloadController> logger,
|
ILogger<FileDownloadController> logger,
|
||||||
IOptions<FileStorageSettings> fileStorageSettings,
|
IOptions<FileStorageSettings> fileStorageSettings,
|
||||||
IContentTypeProvider contentTypeProvider,
|
IContentTypeProvider contentTypeProvider,
|
||||||
IEmailSender emailSender)
|
IEmailSender emailSender,
|
||||||
|
ICaptchaVerifier captcha)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_fileStorageSettings = fileStorageSettings.Value;
|
_fileStorageSettings = fileStorageSettings.Value;
|
||||||
_contentTypeProvider = contentTypeProvider;
|
_contentTypeProvider = contentTypeProvider;
|
||||||
_emailSender = emailSender;
|
_emailSender = emailSender;
|
||||||
|
_captcha = captcha;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads a file with support for resume (range requests) and chunked transfer.
|
/// Downloads a file with support for resume (range requests) and chunked transfer.
|
||||||
/// Supports HTTP 206 Partial Content for efficient downloads and resume capability.
|
/// Supports HTTP 206 Partial Content for efficient downloads and resume capability.
|
||||||
|
/// Requires a valid reCAPTCHA v3 token on the initial (non-range) request.
|
||||||
/// Sends email notification when download starts.
|
/// Sends email notification when download starts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided)</param>
|
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided).</param>
|
||||||
|
/// <param name="captchaToken">reCAPTCHA v3 token — required on the initial download request; omit on subsequent range requests.</param>
|
||||||
/// <returns>File stream with appropriate headers for resumable downloads</returns>
|
/// <returns>File stream with appropriate headers for resumable downloads</returns>
|
||||||
[HttpGet("{fileName?}")]
|
[HttpGet("{fileName?}")]
|
||||||
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")]
|
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file. Requires a reCAPTCHA v3 token on the initial request. Range requests for resume do not require a token.")]
|
||||||
[SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")]
|
[SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")]
|
||||||
[SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")]
|
[SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")]
|
||||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "No file name provided and no default configured")]
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Missing/invalid captcha token, no file name, or no default configured")]
|
||||||
[SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")]
|
[SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")]
|
||||||
[SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")]
|
[SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")]
|
||||||
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")]
|
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")]
|
||||||
@@ -58,10 +65,30 @@ namespace Api.Controllers
|
|||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
|
||||||
[ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)]
|
[ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
|
||||||
public async Task<IActionResult> DownloadFile(string? fileName = null)
|
public async Task<IActionResult> DownloadFile(string? fileName = null, [FromQuery] string? captchaToken = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
|
|
||||||
|
// Captcha required on the initial (full) download only — range requests are resume continuations.
|
||||||
|
var isRangeRequest = !string.IsNullOrEmpty(Request.Headers[HeaderNames.Range].ToString());
|
||||||
|
if (!isRangeRequest)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(captchaToken))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Download attempt without captcha token from IP={IP}", HttpContext.Connection.RemoteIpAddress);
|
||||||
|
return BadRequest(new ErrorResponse { Error = "Captcha token is required.", Code = "captcha_token_missing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var verdict = await _captcha.VerifyAsync(captchaToken, userIp, "file_download", CancellationToken.None);
|
||||||
|
if (!verdict.Success)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Download blocked by captcha. IP={IP} Score={Score}", userIp, verdict.Score);
|
||||||
|
return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
{
|
{
|
||||||
fileName = _fileStorageSettings.DefaultFileName;
|
fileName = _fileStorageSettings.DefaultFileName;
|
||||||
@@ -99,7 +126,6 @@ namespace Api.Controllers
|
|||||||
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
|
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
|
||||||
contentType = "application/octet-stream";
|
contentType = "application/octet-stream";
|
||||||
|
|
||||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
+2
-2
@@ -51,12 +51,12 @@ try
|
|||||||
});
|
});
|
||||||
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
||||||
|
|
||||||
builder.Services.AddDbContext<EmailApiDbContext>(options =>
|
builder.Services.AddDbContext<EmailDbContext>(options =>
|
||||||
{
|
{
|
||||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||||
options.UseSqlServer(connectionString, sql =>
|
options.UseSqlServer(connectionString, sql =>
|
||||||
{
|
{
|
||||||
sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName);
|
sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName);
|
||||||
sql.MigrationsAssembly("email-data");
|
sql.MigrationsAssembly("email-data");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using EmailApi.Models.Requests;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Models.Requests;
|
using Models.Requests;
|
||||||
using Models.Settings;
|
using Models.Settings;
|
||||||
|
using System.Net;
|
||||||
|
|
||||||
namespace Api.Services;
|
namespace Api.Services;
|
||||||
|
|
||||||
@@ -194,31 +195,35 @@ public sealed class EmailApiEmailSender : IEmailSender
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
||||||
{
|
{
|
||||||
|
// Build HTML lists for strengths, gaps, and recommendations
|
||||||
var strengths = result.Strengths?.Count > 0
|
var strengths = result.Strengths?.Count > 0
|
||||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||||
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
|
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
|
||||||
: "<p style=\"color:#6c757d\">—</p>";
|
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||||
|
|
||||||
var gaps = result.Gaps?.Count > 0
|
var gaps = result.Gaps?.Count > 0
|
||||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||||
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
|
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
|
||||||
: "<p style=\"color:#6c757d\">—</p>";
|
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||||
|
|
||||||
var recommendations = result.Recommendations?.Count > 0
|
var recommendations = result.Recommendations?.Count > 0
|
||||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8;margin:0\">" +
|
||||||
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
|
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
|
||||||
: "<p style=\"color:#6c757d\">—</p>";
|
: "<p style=\"color:#6c757d;margin:0\">—</p>";
|
||||||
|
|
||||||
|
// 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,
|
var body = _emailTemplates.Render("email.match.body", language,
|
||||||
("cvDocumentId", cvDocumentId),
|
("cvDocumentId", cvDocumentId),
|
||||||
("jobLabel", jobLabel ?? "N/A"),
|
("jobLabel", jobLabel ?? "N/A"),
|
||||||
("jobUrl", result.JobUrl ?? "N/A"),
|
("jobUrl", result.JobUrl ?? "N/A"),
|
||||||
("score", result.Score.ToString()),
|
("score", result.Score.ToString()),
|
||||||
("summary", result.Summary ?? string.Empty),
|
("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)),
|
||||||
("strengths", strengths),
|
("strengths", strengths),
|
||||||
("gaps", gaps),
|
("gaps", gaps),
|
||||||
("recommendations", recommendations));
|
("recommendations", recommendations));
|
||||||
|
|
||||||
|
// Append the job search footer if link is provided
|
||||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||||
{
|
{
|
||||||
body += _emailTemplates.Render("email.match.job-search-footer", language,
|
body += _emailTemplates.Render("email.match.job-search-footer", language,
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"Serilog": {
|
"Serilog": {
|
||||||
"Using": [
|
"Using": [
|
||||||
"Serilog.Sinks.Console",
|
"Serilog.Sinks.Console",
|
||||||
"Serilog.Sinks.File",
|
"Serilog.Sinks.File"
|
||||||
"Serilog.Sinks.Email"
|
|
||||||
],
|
],
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
@@ -30,25 +29,6 @@
|
|||||||
"retainedFileCountLimit": 30,
|
"retainedFileCountLimit": 30,
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Email",
|
|
||||||
"Args": {
|
|
||||||
"restrictedToMinimumLevel": "Error",
|
|
||||||
"fromEmail": "",
|
|
||||||
"toEmail": "",
|
|
||||||
"mailServer": "",
|
|
||||||
"networkCredential": {
|
|
||||||
"userName": "",
|
|
||||||
"password": ""
|
|
||||||
},
|
|
||||||
"port": 587,
|
|
||||||
"enableSsl": true,
|
|
||||||
"emailSubject": "[mihes.ro API] Error Alert",
|
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
|
||||||
"batchPostingLimit": 10,
|
|
||||||
"period": "0.00:05:00"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Enrich": [
|
"Enrich": [
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ public sealed class CreateJobSearchTokenRequest
|
|||||||
public string CvDocumentId { get; set; } = string.Empty;
|
public string CvDocumentId { get; set; } = string.Empty;
|
||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
public string Language { get; set; } = "en";
|
public string Language { get; set; } = "en";
|
||||||
|
public List<string> Keywords { get; set; } = [];
|
||||||
|
public string? Location { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,9 @@ namespace CvMatcher.Models.Responses;
|
|||||||
|
|
||||||
public sealed class CreateJobSearchTokenResponse
|
public sealed class CreateJobSearchTokenResponse
|
||||||
{
|
{
|
||||||
public string TokenId { get; set; } = string.Empty;
|
/// <summary>
|
||||||
|
/// The generated token ID, or <c>null</c> when no job providers are currently enabled.
|
||||||
|
/// Callers must check for null before building the job-search link.
|
||||||
|
/// </summary>
|
||||||
|
public string? TokenId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
public List<string> Gaps { get; set; } = [];
|
public List<string> Gaps { get; set; } = [];
|
||||||
public List<string> Recommendations { get; set; } = [];
|
public List<string> Recommendations { get; set; } = [];
|
||||||
public List<string> Evidence { get; set; } = [];
|
public List<string> Evidence { get; set; } = [];
|
||||||
|
public List<string> Keywords { get; set; } = [];
|
||||||
|
public string? Location { get; set; }
|
||||||
public bool Cached { get; set; }
|
public bool Cached { get; set; }
|
||||||
public string? JobDocumentId { get; set; }
|
public string? JobDocumentId { get; set; }
|
||||||
public string? JobUrl { get; set; }
|
public string? JobUrl { get; set; }
|
||||||
|
|||||||
@@ -7,9 +7,12 @@ public sealed class JobSearchSettings
|
|||||||
public int TokenExpiryDays { get; set; } = 7;
|
public int TokenExpiryDays { get; set; } = 7;
|
||||||
public int MinMatchScore { get; set; } = 15;
|
public int MinMatchScore { get; set; } = 15;
|
||||||
public int MaxJobsToMatch { get; set; } = 15;
|
public int MaxJobsToMatch { get; set; } = 15;
|
||||||
public List<JobProviderConfig> Providers { get; set; } = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runtime DTO for a job provider. Populated from <c>cvSearch.JobProviders</c> at session-creation
|
||||||
|
/// time and snapshotted to <c>JobSearchSessionEntity.ProviderConfigJson</c>.
|
||||||
|
/// </summary>
|
||||||
public sealed class JobProviderConfig
|
public sealed class JobProviderConfig
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
@@ -18,4 +21,11 @@ public sealed class JobProviderConfig
|
|||||||
public string JobLinkContains { get; set; } = string.Empty;
|
public string JobLinkContains { get; set; } = string.Empty;
|
||||||
public List<string> InitialKeywords { get; set; } = [];
|
public List<string> InitialKeywords { get; set; } = [];
|
||||||
public int MaxResults { get; set; } = 20;
|
public int MaxResults { get; set; } = 20;
|
||||||
|
/// <summary>When true the scraper uses a headless Chromium browser to render JS-heavy pages.</summary>
|
||||||
|
public bool UseHeadlessBrowser { 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.
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireKeywordInAnchor { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public sealed class JobSearchController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
|
if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email))
|
||||||
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" });
|
return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" });
|
||||||
|
|
||||||
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, ct);
|
var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, ct);
|
||||||
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
|
return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -12,9 +12,14 @@ public interface IJobTokenService
|
|||||||
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
|
/// <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="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="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>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
/// <returns>The generated token ID, to be embedded in the one-click job search link.</returns>
|
/// <returns>
|
||||||
Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct);
|
/// The generated token ID to embed in the one-click job search link,
|
||||||
|
/// or <c>null</c> when no job providers are currently enabled (link should be suppressed).
|
||||||
|
/// </returns>
|
||||||
|
Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, CancellationToken ct);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session.
|
/// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session.
|
||||||
|
|||||||
@@ -123,11 +123,11 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
var cvText = Limit(cv.Text, 18000);
|
var cvText = Limit(cv.Text, 18000);
|
||||||
var jobText = Limit(job.Text, 14000);
|
var jobText = Limit(job.Text, 14000);
|
||||||
var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000);
|
var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000);
|
||||||
var languageName = LanguageName(language);
|
|
||||||
|
|
||||||
var promptTemplate = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", "*", ct)
|
var systemPrompt = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", language, ct)
|
||||||
?? "You are a strict CV-to-job matching engine. Return JSON only.";
|
?? throw new InvalidOperationException(
|
||||||
var systemPrompt = promptTemplate.Replace("{{languageName}}", languageName, StringComparison.OrdinalIgnoreCase);
|
$"AI prompt not found: key='ai.cv-match.system-prompt', language='{language}'. " +
|
||||||
|
$"This is a configuration error. Ensure the cvMatcher.AiPrompts table is properly seeded with language-specific prompts.");
|
||||||
|
|
||||||
var userPrompt = $"""
|
var userPrompt = $"""
|
||||||
CV:
|
CV:
|
||||||
@@ -195,14 +195,6 @@ public sealed class CvMatcherService : ICvMatcherService
|
|||||||
private static string NormalizeLanguage(string? language) =>
|
private static string NormalizeLanguage(string? language) =>
|
||||||
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
|
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
|
||||||
|
|
||||||
/// <summary>Maps a language code to its full English name for use in the LLM system prompt.</summary>
|
|
||||||
private static string LanguageName(string language) => language switch
|
|
||||||
{
|
|
||||||
"ro" => "Romanian",
|
|
||||||
"en" => "English",
|
|
||||||
_ => "English"
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Truncates <paramref name="value"/> to at most <paramref name="max"/> characters.</summary>
|
/// <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 Limit(string value, int max) => value.Length <= max ? value : value[..max];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
|
||||||
using Api.Clients.Api.Contracts;
|
|
||||||
using Api.Services.Contracts;
|
using Api.Services.Contracts;
|
||||||
using CvMatcher.Models.Responses;
|
using CvMatcher.Models.Responses;
|
||||||
using CvSearch.Data;
|
using CvSearch.Data;
|
||||||
@@ -13,35 +11,46 @@ namespace Api.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates and validates one-time job search tokens, and creates the corresponding search sessions.
|
/// 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>
|
/// </summary>
|
||||||
public sealed class JobTokenService : IJobTokenService
|
public sealed class JobTokenService : IJobTokenService
|
||||||
{
|
{
|
||||||
private readonly CvSearchDbContext _db;
|
private readonly CvSearchDbContext _db;
|
||||||
private readonly IRagApiClient _rag;
|
|
||||||
private readonly JobSearchSettings _settings;
|
private readonly JobSearchSettings _settings;
|
||||||
private readonly ILogger<JobTokenService> _logger;
|
private readonly ILogger<JobTokenService> _logger;
|
||||||
|
|
||||||
public JobTokenService(
|
public JobTokenService(
|
||||||
CvSearchDbContext db,
|
CvSearchDbContext db,
|
||||||
IRagApiClient rag,
|
|
||||||
IOptions<JobSearchSettings> settings,
|
IOptions<JobSearchSettings> settings,
|
||||||
ILogger<JobTokenService> logger)
|
ILogger<JobTokenService> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_rag = rag;
|
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct)
|
public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, string? location, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct);
|
||||||
|
if (!hasEnabledProviders)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var token = new JobSearchTokenEntity
|
var token = new JobSearchTokenEntity
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
CvDocumentId = cvDocumentId,
|
CvDocumentId = cvDocumentId,
|
||||||
Email = email,
|
Email = email,
|
||||||
Language = language,
|
Language = language,
|
||||||
|
Keywords = string.Join(",", keywords),
|
||||||
|
Location = location,
|
||||||
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
|
ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays),
|
||||||
Used = false,
|
Used = false,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
@@ -49,7 +58,7 @@ public sealed class JobTokenService : IJobTokenService
|
|||||||
|
|
||||||
_db.JobSearchTokens.Add(token);
|
_db.JobSearchTokens.Add(token);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}", token.Id, cvDocumentId);
|
_logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}, Location={Location}", token.Id, cvDocumentId, token.Keywords, token.Location);
|
||||||
return token.Id;
|
return token.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,11 +73,15 @@ public sealed class JobTokenService : IJobTokenService
|
|||||||
token.Used = true;
|
token.Used = true;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var cv = await _rag.GetDocumentAsync(token.CvDocumentId, ct);
|
var keywords = token.Keywords;
|
||||||
var keywords = cv is not null ? ExtractKeywords(cv.Text) : string.Empty;
|
|
||||||
|
var enabledProviders = await _db.JobProviders
|
||||||
|
.Where(p => p.Enabled)
|
||||||
|
.OrderBy(p => p.DisplayOrder)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var providerConfigJson = JsonSerializer.Serialize(
|
var providerConfigJson = JsonSerializer.Serialize(
|
||||||
_settings.Providers.Where(p => p.Enabled).ToList(),
|
enabledProviders.Select(ToConfig).ToList(),
|
||||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||||
|
|
||||||
var session = new JobSearchSessionEntity
|
var session = new JobSearchSessionEntity
|
||||||
@@ -80,40 +93,44 @@ public sealed class JobTokenService : IJobTokenService
|
|||||||
Language = token.Language,
|
Language = token.Language,
|
||||||
Status = JobSearchStatus.Pending,
|
Status = JobSearchStatus.Pending,
|
||||||
Keywords = keywords,
|
Keywords = keywords,
|
||||||
|
Location = token.Location,
|
||||||
ProviderConfigJson = providerConfigJson,
|
ProviderConfigJson = providerConfigJson,
|
||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
_db.JobSearchSessions.Add(session);
|
_db.JobSearchSessions.Add(session);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
_logger.LogInformation("Job search session created. SessionId={SessionId}, Keywords={Keywords}", session.Id, keywords);
|
_logger.LogInformation(
|
||||||
|
"Job search session created. SessionId={SessionId}, Keywords={Keywords}, Providers={Providers}",
|
||||||
|
session.Id, keywords, string.Join(", ", enabledProviders.Select(p => p.Name)));
|
||||||
|
|
||||||
return StartJobSearchStatus.Started;
|
return StartJobSearchStatus.Started;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private static JobProviderConfig ToConfig(JobProviderEntity entity)
|
||||||
/// Extracts up to 10 meaningful keywords from the CV text using simple heuristics (no LLM).
|
|
||||||
/// Takes the first 5 usable lines, splits them into words, strips punctuation, and deduplicates.
|
|
||||||
/// </summary>
|
|
||||||
private static string ExtractKeywords(string cvText)
|
|
||||||
{
|
{
|
||||||
var lines = cvText
|
List<string> keywords;
|
||||||
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
|
try
|
||||||
.Select(l => l.Trim())
|
{
|
||||||
.Where(l => l.Length > 5 && l.Length < 200)
|
keywords = JsonSerializer.Deserialize<List<string>>(entity.InitialKeywordsJson,
|
||||||
// Skip lines that are purely digits, spaces, and phone/contact punctuation (phone numbers, emails, etc.)
|
new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? [];
|
||||||
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
|
|
||||||
.Take(5)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var words = lines
|
|
||||||
.SelectMany(l => l.Split(' ', StringSplitOptions.RemoveEmptyEntries))
|
|
||||||
.Select(w => Regex.Replace(w, @"[^\w\-]", ""))
|
|
||||||
.Where(w => w.Length > 2)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Take(10)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return string.Join(",", words);
|
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
keywords = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JobProviderConfig
|
||||||
|
{
|
||||||
|
Name = entity.Name,
|
||||||
|
Enabled = entity.Enabled,
|
||||||
|
SearchUrlTemplate = entity.SearchUrlTemplate,
|
||||||
|
JobLinkContains = entity.JobLinkContains,
|
||||||
|
InitialKeywords = keywords,
|
||||||
|
MaxResults = entity.MaxResults,
|
||||||
|
UseHeadlessBrowser = entity.UseHeadlessBrowser,
|
||||||
|
RequireKeywordInAnchor = entity.RequireKeywordInAnchor
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"Serilog": {
|
"Serilog": {
|
||||||
"Using": [
|
"Using": [
|
||||||
"Serilog.Sinks.Console",
|
"Serilog.Sinks.Console",
|
||||||
"Serilog.Sinks.File",
|
"Serilog.Sinks.File"
|
||||||
"Serilog.Sinks.Email"
|
|
||||||
],
|
],
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
@@ -30,25 +29,6 @@
|
|||||||
"retainedFileCountLimit": 30,
|
"retainedFileCountLimit": 30,
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Email",
|
|
||||||
"Args": {
|
|
||||||
"restrictedToMinimumLevel": "Error",
|
|
||||||
"fromEmail": "",
|
|
||||||
"toEmail": "",
|
|
||||||
"mailServer": "",
|
|
||||||
"networkCredential": {
|
|
||||||
"userName": "",
|
|
||||||
"password": ""
|
|
||||||
},
|
|
||||||
"port": 587,
|
|
||||||
"enableSsl": true,
|
|
||||||
"emailSubject": "[mihes.ro API] Error Alert",
|
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
|
||||||
"batchPostingLimit": 10,
|
|
||||||
"period": "0.00:05:00"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Enrich": [
|
"Enrich": [
|
||||||
@@ -112,32 +92,6 @@
|
|||||||
"JobSearchLinkBaseUrl": "https://myai.ro",
|
"JobSearchLinkBaseUrl": "https://myai.ro",
|
||||||
"TokenExpiryDays": 7,
|
"TokenExpiryDays": 7,
|
||||||
"MinMatchScore": 15,
|
"MinMatchScore": 15,
|
||||||
"MaxJobsToMatch": 15,
|
"MaxJobsToMatch": 15
|
||||||
"Providers": [
|
|
||||||
{
|
|
||||||
"Name": "ejobs.ro",
|
|
||||||
"Enabled": false,
|
|
||||||
"SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/",
|
|
||||||
"JobLinkContains": "/user/locuri-de-munca/job/",
|
|
||||||
"InitialKeywords": [],
|
|
||||||
"MaxResults": 20
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "bestjobs.eu",
|
|
||||||
"Enabled": false,
|
|
||||||
"SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}",
|
|
||||||
"JobLinkContains": "/ro/locuri-de-munca/",
|
|
||||||
"InitialKeywords": [],
|
|
||||||
"MaxResults": 20
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "linkedin.com",
|
|
||||||
"Enabled": false,
|
|
||||||
"SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania",
|
|
||||||
"JobLinkContains": "/jobs/view/",
|
|
||||||
"InitialKeywords": [],
|
|
||||||
"MaxResults": 20
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public sealed class CvMatcherDbContext : DbContext
|
|||||||
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
|
entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired();
|
||||||
entity.Property(x => x.ResultJson).IsRequired();
|
entity.Property(x => x.ResultJson).IsRequired();
|
||||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique();
|
entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique();
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<CvMatcherChatCacheEntity>(entity =>
|
modelBuilder.Entity<CvMatcherChatCacheEntity>(entity =>
|
||||||
|
|||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using CvMatcher.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace CvMatcher.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(CvMatcherDbContext))]
|
|
||||||
[Migration("20260507140442_InitialCvMatcherSchema")]
|
|
||||||
partial class InitialCvMatcherSchema
|
|
||||||
{
|
|
||||||
/// <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.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>("ResultJson")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<int>("Score")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("Results", "cvMatcher");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("CacheKey")
|
|
||||||
.HasMaxLength(64)
|
|
||||||
.HasColumnType("nvarchar(64)");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("datetime2")
|
|
||||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
|
||||||
|
|
||||||
b.Property<string>("Model")
|
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(120)
|
|
||||||
.HasColumnType("nvarchar(120)");
|
|
||||||
|
|
||||||
b.Property<string>("ResponseText")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<decimal>("Temperature")
|
|
||||||
.HasColumnType("decimal(4,2)");
|
|
||||||
|
|
||||||
b.HasKey("CacheKey");
|
|
||||||
|
|
||||||
b.ToTable("ChatCache", "cvMatcher");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using CvMatcher.Data;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace CvMatcher.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class InitialCvMatcherSchema : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.EnsureSchema(
|
|
||||||
name: MigrationConstants.SchemaName);
|
|
||||||
|
|
||||||
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),
|
|
||||||
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: MigrationConstants.SchemaName,
|
|
||||||
table: "Results",
|
|
||||||
columns: new[] { "CvDocumentId", "JobDocumentId" },
|
|
||||||
unique: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "ChatCache",
|
|
||||||
schema: MigrationConstants.SchemaName);
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Results",
|
|
||||||
schema: MigrationConstants.SchemaName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using CvMatcher.Data;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace CvMatcher.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddAiPrompts : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
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.InsertData(
|
|
||||||
schema: MigrationConstants.SchemaName,
|
|
||||||
table: "AiPrompts",
|
|
||||||
columns: ["Key", "Language", "Value", "Description"],
|
|
||||||
values: new object[]
|
|
||||||
{
|
|
||||||
"ai.cv-match.system-prompt",
|
|
||||||
"*",
|
|
||||||
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\nPenalize missing required skills. Do not invent experience. Use concise business language.\nRespond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\nJSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}",
|
|
||||||
"System prompt template for the CV-to-job LLM matching call. {{languageName}} is substituted at runtime."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(name: "AiPrompts", schema: MigrationConstants.SchemaName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-5
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvMatcher.Data;
|
using CvMatcher.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
namespace CvMatcher.Data.Migrations
|
namespace CvMatcher.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvMatcherDbContext))]
|
[DbContext(typeof(CvMatcherDbContext))]
|
||||||
[Migration("20260528110000_AddAiPrompts")]
|
[Migration("20260601133028_InitialSchema")]
|
||||||
partial class AddAiPrompts
|
partial class InitialSchema
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -80,7 +80,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
b.Property<string>("ResultJson")
|
b.Property<string>("ResultJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -91,7 +91,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Results", "cvMatcher");
|
b.ToTable("Results", "cvMatcher");
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using System;
|
||||||
|
using CvMatcher.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvMatcher.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialSchema : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.EnsureSchema(
|
||||||
|
name: MigrationConstants.SchemaName);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AiPrompts",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
|
||||||
|
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language });
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ChatCache",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
CacheKey = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Model = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
|
||||||
|
Temperature = table.Column<decimal>(type: "decimal(4,2)", nullable: false),
|
||||||
|
ResponseText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ChatCache", x => x.CacheKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Results",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
JobDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||||
|
Language = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||||
|
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Score = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Results", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Results_CvDocumentId_JobDocumentId_Language",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "Results",
|
||||||
|
columns: new[] { "CvDocumentId", "JobDocumentId", "Language" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
Seed(migrationBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Seed(MigrationBuilder m)
|
||||||
|
{
|
||||||
|
void Row(string key, string lang, string value, string description = "")
|
||||||
|
=> m.InsertData("AiPrompts", ["Key", "Language", "Value", "Description"], [key, lang, value, description], MigrationConstants.SchemaName);
|
||||||
|
|
||||||
|
// AI system prompt for CV matching — English
|
||||||
|
Row("ai.cv-match.system-prompt", "en",
|
||||||
|
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}",
|
||||||
|
"System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job.");
|
||||||
|
|
||||||
|
// AI system prompt for CV matching — Romanian
|
||||||
|
Row("ai.cv-match.system-prompt", "ro",
|
||||||
|
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}",
|
||||||
|
"System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AiPrompts",
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ChatCache",
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Results",
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
-5
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvMatcher.Data;
|
using CvMatcher.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -12,8 +12,8 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
namespace CvMatcher.Data.Migrations
|
namespace CvMatcher.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvMatcherDbContext))]
|
[DbContext(typeof(CvMatcherDbContext))]
|
||||||
[Migration("20260524140335_AddLanguageToCvMatchResult")]
|
[Migration("20260608124331_ImproveKeywordsAndAddLocation")]
|
||||||
partial class AddLanguageToCvMatchResult
|
partial class ImproveKeywordsAndAddLocation
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -26,6 +26,37 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Key", "Language");
|
||||||
|
|
||||||
|
b.ToTable("AiPrompts", "cvMatcher");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -49,7 +80,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
b.Property<string>("ResultJson")
|
b.Property<string>("ResultJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -60,7 +91,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Results", "cvMatcher");
|
b.ToTable("Results", "cvMatcher");
|
||||||
@@ -0,0 +1,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."
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvMatcher.Data;
|
using CvMatcher.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -77,7 +77,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
b.Property<string>("ResultJson")
|
b.Property<string>("ResultJson")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
@@ -88,7 +88,7 @@ namespace CvMatcher.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("Results", "cvMatcher");
|
b.ToTable("Results", "cvMatcher");
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
|||||||
|
|
||||||
if (exists) return;
|
if (exists) return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
_db.CvMatchResults.Add(new CvMatchResultEntity
|
_db.CvMatchResults.Add(new CvMatchResultEntity
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid().ToString("N"),
|
Id = Guid.NewGuid().ToString("N"),
|
||||||
@@ -61,6 +63,17 @@ public sealed class EfMatcherRepository : IMatcherRepository
|
|||||||
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_Results_CvDocumentId_JobDocumentId_Language") == true
|
||||||
|
|| ex.InnerException?.Message.Contains("unique") == true)
|
||||||
|
{
|
||||||
|
// Duplicate key violation: record was inserted between the AnyAsync check and SaveChangesAsync.
|
||||||
|
// This is safe to ignore — the match result already exists in the database.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Duplicate match result ignored: CV={CvDocumentId} Job={JobDocumentId} Language={Language}. " +
|
||||||
|
"Record was likely inserted concurrently. This is expected behavior in high-concurrency scenarios.",
|
||||||
|
cvDocumentId, jobDocumentId, language);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct)
|
public async Task<string?> GetChatCompletionAsync(string cacheKey, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public sealed class CvSearchDbContext : DbContext
|
|||||||
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
|
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
|
||||||
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
|
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
|
||||||
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
|
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
|
||||||
|
public DbSet<JobProviderEntity> JobProviders => Set<JobProviderEntity>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -33,6 +34,7 @@ public sealed class CvSearchDbContext : DbContext
|
|||||||
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
|
entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired();
|
||||||
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
entity.Property(x => x.Email).HasMaxLength(256).IsRequired();
|
||||||
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
|
entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired();
|
||||||
|
entity.Property(x => x.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty);
|
||||||
entity.Property(x => x.Used).HasDefaultValue(false);
|
entity.Property(x => x.Used).HasDefaultValue(false);
|
||||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
});
|
});
|
||||||
@@ -65,5 +67,19 @@ public sealed class CvSearchDbContext : DbContext
|
|||||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
entity.HasIndex(x => x.SessionId);
|
entity.HasIndex(x => x.SessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<JobProviderEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("JobProviders");
|
||||||
|
entity.HasKey(x => x.Id);
|
||||||
|
entity.Property(x => x.Id).UseIdentityColumn();
|
||||||
|
entity.Property(x => x.Name).HasMaxLength(128).IsRequired();
|
||||||
|
entity.Property(x => x.SearchUrlTemplate).HasMaxLength(1024).IsRequired();
|
||||||
|
entity.Property(x => x.JobLinkContains).HasMaxLength(256).IsRequired();
|
||||||
|
entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired();
|
||||||
|
entity.Property(x => x.MaxResults).HasDefaultValue(20);
|
||||||
|
entity.Property(x => x.DisplayOrder).HasDefaultValue(0);
|
||||||
|
entity.Property(x => x.UseHeadlessBrowser).HasDefaultValue(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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 true, the scraper renders the page with headless Chromium instead of a plain HTTP GET.</summary>
|
||||||
|
public bool UseHeadlessBrowser { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When false, the Stage 2 anchor-text keyword filter is skipped.
|
||||||
|
/// Set to false for providers whose search URL already filters by relevance server-side (ejobs.ro, bestjobs.eu).
|
||||||
|
/// </summary>
|
||||||
|
public bool RequireKeywordInAnchor { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ public sealed class JobSearchSessionEntity : BaseEntity
|
|||||||
public string Email { get; set; } = string.Empty;
|
public string Email { get; set; } = string.Empty;
|
||||||
public string Status { get; set; } = JobSearchStatus.Pending;
|
public string Status { get; set; } = JobSearchStatus.Pending;
|
||||||
public string Keywords { get; set; } = string.Empty;
|
public string Keywords { get; set; } = string.Empty;
|
||||||
|
public string? Location { get; set; }
|
||||||
public string? ProviderConfigJson { get; set; }
|
public string? ProviderConfigJson { get; set; }
|
||||||
public string Language { get; set; } = "en";
|
public string Language { get; set; } = "en";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,4 +9,6 @@ public sealed class JobSearchTokenEntity : BaseEntity
|
|||||||
public string Language { get; set; } = "en";
|
public string Language { get; set; } = "en";
|
||||||
public DateTime ExpiresAt { get; set; }
|
public DateTime ExpiresAt { get; set; }
|
||||||
public bool Used { get; set; }
|
public bool Used { get; set; }
|
||||||
|
public string Keywords { get; set; } = string.Empty;
|
||||||
|
public string? Location { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,13 +73,13 @@ namespace CvSearch.Data.Migrations
|
|||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_JobSearchResults_SessionId",
|
name: "IX_JobSearchResults_SessionId",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "JobSearchResults",
|
table: "JobSearchResults",
|
||||||
column: "SessionId");
|
column: "SessionId");
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
migrationBuilder.CreateIndex(
|
||||||
name: "IX_JobSearchSessions_Status",
|
name: "IX_JobSearchSessions_Status",
|
||||||
schema: "cvSearch",
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "JobSearchSessions",
|
table: "JobSearchSessions",
|
||||||
column: "Status");
|
column: "Status");
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-7
@@ -1,6 +1,6 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvSearch.Models.Data;
|
using CvSearch.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace CvSearch.Models.Migrations
|
namespace CvSearch.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvSearchDbContext))]
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
[Migration("20260524145702_AddLanguageToJobSearchEntities")]
|
[Migration("20260529084440_AddJobProviders")]
|
||||||
partial class AddLanguageToJobSearchEntities
|
partial class AddJobProviders
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("InitialKeywordsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
|
b.Property<string>("JobLinkContains")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MaxResults")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(20);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SearchUrlTemplate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobProviders", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchResults", "cvSearch");
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -128,7 +176,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchSessions", "cvSearch");
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobProviders : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "JobProviders",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
Enabled = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
SearchUrlTemplate = table.Column<string>(type: "nvarchar(1024)", maxLength: 1024, nullable: false),
|
||||||
|
JobLinkContains = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||||
|
InitialKeywordsJson = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"),
|
||||||
|
MaxResults = table.Column<int>(type: "int", nullable: false, defaultValue: 20),
|
||||||
|
DisplayOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_JobProviders", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed the three default providers — all disabled so the feature is opt-in per environment.
|
||||||
|
// Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI.
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"],
|
||||||
|
values: new object[,]
|
||||||
|
{
|
||||||
|
{ "ejobs.ro", false, "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", "[]", 20, 0 },
|
||||||
|
{ "bestjobs.eu", false, "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}", "/ro/locuri-de-munca/", "[]", 20, 1 },
|
||||||
|
{ "linkedin.com", false, "https://www.linkedin.com/jobs/search/?keywords={keywords}", "/jobs/view/", "[]", 20, 2 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "JobProviders",
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+77
-8
@@ -1,6 +1,6 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvSearch.Models.Data;
|
using CvSearch.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
@@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace CvSearch.Models.Migrations
|
namespace CvSearch.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvSearchDbContext))]
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
[Migration("20260522093356_AddJobSearchTables")]
|
[Migration("20260529130000_AddKeywordsToJobSearchTokens")]
|
||||||
partial class AddJobSearchTables
|
partial class AddKeywordsToJobSearchTokens
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
@@ -26,7 +26,55 @@ namespace CvSearch.Models.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("InitialKeywordsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
|
b.Property<string>("JobLinkContains")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MaxResults")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(20);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SearchUrlTemplate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobProviders", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -75,7 +123,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchResults", "cvSearch");
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -101,6 +149,13 @@ namespace CvSearch.Models.Migrations
|
|||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("nvarchar(1000)");
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
b.Property<string>("ProviderConfigJson")
|
b.Property<string>("ProviderConfigJson")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -121,7 +176,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchSessions", "cvSearch");
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -145,6 +200,20 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.Property<DateTime>("ExpiresAt")
|
b.Property<DateTime>("ExpiresAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
b.Property<bool>("Used")
|
b.Property<bool>("Used")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bit")
|
.HasColumnType("bit")
|
||||||
+10
-9
@@ -1,32 +1,33 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using CvMatcher.Data;
|
using CvSearch.Data;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace CvMatcher.Data.Migrations
|
namespace CvSearch.Data.Migrations
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public partial class AddLanguageToCvMatchResult : Migration
|
public partial class AddKeywordsToJobSearchTokens : Migration
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.AddColumn<string>(
|
migrationBuilder.AddColumn<string>(
|
||||||
name: "Language",
|
name: "Keywords",
|
||||||
schema: MigrationConstants.SchemaName,
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "Results",
|
table: "JobSearchTokens",
|
||||||
type: "nvarchar(max)",
|
type: "nvarchar(1000)",
|
||||||
|
maxLength: 1000,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
defaultValue: "en");
|
defaultValue: "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.DropColumn(
|
migrationBuilder.DropColumn(
|
||||||
name: "Language",
|
name: "Keywords",
|
||||||
schema: MigrationConstants.SchemaName,
|
schema: MigrationConstants.SchemaName,
|
||||||
table: "Results");
|
table: "JobSearchTokens");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+66
-8
@@ -1,19 +1,22 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvSearch.Models.Data;
|
using CvSearch.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
namespace CvSearch.Models.Migrations
|
namespace CvSearch.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(CvSearchDbContext))]
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
partial class CvSearchDbContextModelSnapshot : ModelSnapshot
|
[Migration("20260529160000_FixBestJobsLinkFilter")]
|
||||||
|
partial class FixBestJobsLinkFilter
|
||||||
{
|
{
|
||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
@@ -23,7 +26,55 @@ namespace CvSearch.Models.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchResultEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("InitialKeywordsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
|
b.Property<string>("JobLinkContains")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MaxResults")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(20);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SearchUrlTemplate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobProviders", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -72,7 +123,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchResults", "cvSearch");
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchSessionEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -125,7 +176,7 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.ToTable("JobSearchSessions", "cvSearch");
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Models.Data.Entities.JobSearchTokenEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
@@ -149,6 +200,13 @@ namespace CvSearch.Models.Migrations
|
|||||||
b.Property<DateTime>("ExpiresAt")
|
b.Property<DateTime>("ExpiresAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FixBestJobsLinkFilter : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// bestjobs.eu individual job listings use /loc-de-munca/{slug}.
|
||||||
|
// The original seed value /ro/locuri-de-munca/ matched only category nav links,
|
||||||
|
// so zero job URLs passed the stage-1 filter.
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "JobLinkContains",
|
||||||
|
value: "/loc-de-munca/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "JobLinkContains",
|
||||||
|
value: "/ro/locuri-de-munca/");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+234
@@ -0,0 +1,234 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CvSearch.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
|
[Migration("20260529170000_AddHeadlessBrowserToProviders")]
|
||||||
|
partial class AddHeadlessBrowserToProviders
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("cvSearch")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("InitialKeywordsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
|
b.Property<string>("JobLinkContains")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MaxResults")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(20);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("SearchUrlTemplate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<bool>("UseHeadlessBrowser")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobProviders", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("JobText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("JobTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("nvarchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("JobUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ResultJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SessionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SessionId");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderConfigJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<bool>("Used")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchTokens", "cvSearch");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHeadlessBrowserToProviders : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "UseHeadlessBrowser",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
// ejobs.ro (Id=1) is a Nuxt SPA — the old /user/ URL 404s and plain HTTP GET
|
||||||
|
// returns only the JS bundle, not actual job listings.
|
||||||
|
// Fix: use the correct search URL and headless Chromium to render job results.
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"],
|
||||||
|
values: new object[] { "https://www.ejobs.ro/locuri-de-munca?q={keywords}", "/locuri-de-munca/", true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"],
|
||||||
|
values: new object[] { "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", false });
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "UseHeadlessBrowser",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+243
@@ -0,0 +1,243 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CvSearch.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
|
[Migration("20260608124304_AddRequireKeywordInAnchorAndLocation")]
|
||||||
|
partial class AddRequireKeywordInAnchorAndLocation
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("cvSearch")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("InitialKeywordsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
|
b.Property<string>("JobLinkContains")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MaxResults")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(20);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<bool>("RequireKeywordInAnchor")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("SearchUrlTemplate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<bool>("UseHeadlessBrowser")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobProviders", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("JobText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("JobTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("nvarchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("JobUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ResultJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SessionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SessionId");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderConfigJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("Used")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchTokens", "cvSearch");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddRequireKeywordInAnchorAndLocation : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Location",
|
||||||
|
schema: "cvSearch",
|
||||||
|
table: "JobSearchTokens",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Location",
|
||||||
|
schema: "cvSearch",
|
||||||
|
table: "JobSearchSessions",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "RequireKeywordInAnchor",
|
||||||
|
schema: "cvSearch",
|
||||||
|
table: "JobProviders",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: true);
|
||||||
|
|
||||||
|
// ejobs.ro (Id=1) and bestjobs.eu (Id=2) do server-side keyword filtering via their
|
||||||
|
// search URL — the Stage 2 anchor-text filter rejects all Romanian job titles because
|
||||||
|
// they rarely contain abstract LLM keywords.
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: "cvSearch",
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "RequireKeywordInAnchor",
|
||||||
|
value: false);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: "cvSearch",
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "RequireKeywordInAnchor",
|
||||||
|
value: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Location",
|
||||||
|
schema: "cvSearch",
|
||||||
|
table: "JobSearchTokens");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Location",
|
||||||
|
schema: "cvSearch",
|
||||||
|
table: "JobSearchSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "RequireKeywordInAnchor",
|
||||||
|
schema: "cvSearch",
|
||||||
|
table: "JobProviders");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+243
@@ -0,0 +1,243 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using CvSearch.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(CvSearchDbContext))]
|
||||||
|
[Migration("20260608124452_AddLocationToProviders")]
|
||||||
|
partial class AddLocationToProviders
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("cvSearch")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("InitialKeywordsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
|
b.Property<string>("JobLinkContains")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MaxResults")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(20);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<bool>("RequireKeywordInAnchor")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("SearchUrlTemplate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<bool>("UseHeadlessBrowser")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobProviders", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("JobText")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("JobTitle")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(512)
|
||||||
|
.HasColumnType("nvarchar(512)");
|
||||||
|
|
||||||
|
b.Property<string>("JobUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(2048)
|
||||||
|
.HasColumnType("nvarchar(2048)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("ResultJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("Score")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SessionId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SessionId");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchResults", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderConfigJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("nvarchar(32)");
|
||||||
|
|
||||||
|
b.Property<string>("TokenId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Status");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchSessions", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("CvDocumentId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("nvarchar(64)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)")
|
||||||
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("Used")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobSearchTokens", "cvSearch");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace CvSearch.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLocationToProviders : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// ejobs.ro (Id=1): location in URL path as slug, keywords via q= param.
|
||||||
|
// Verified URL structure: /locuri-de-munca/{location-slug}?q={keywords}
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "SearchUrlTemplate",
|
||||||
|
value: "https://www.ejobs.ro/locuri-de-munca/{location-slug}?q={keywords}");
|
||||||
|
|
||||||
|
// bestjobs.eu (Id=2): location in URL path as slug, keywords via query param.
|
||||||
|
// Verified URL structure: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords}
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "SearchUrlTemplate",
|
||||||
|
value: "https://bestjobs.eu/ro/locuri-de-munca-in-{location-slug}?keywords={keywords}");
|
||||||
|
|
||||||
|
// linkedin.com (Id=3): location as query parameter.
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "SearchUrlTemplate",
|
||||||
|
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}&location={location}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "SearchUrlTemplate",
|
||||||
|
value: "https://www.ejobs.ro/locuri-de-munca?q={keywords}");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "SearchUrlTemplate",
|
||||||
|
value: "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
table: "JobProviders",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "SearchUrlTemplate",
|
||||||
|
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using CvSearch.Data;
|
using CvSearch.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -23,6 +23,62 @@ namespace CvSearch.Data.Migrations
|
|||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(0);
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("InitialKeywordsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(2000)
|
||||||
|
.HasColumnType("nvarchar(2000)")
|
||||||
|
.HasDefaultValue("[]");
|
||||||
|
|
||||||
|
b.Property<string>("JobLinkContains")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)");
|
||||||
|
|
||||||
|
b.Property<int>("MaxResults")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int")
|
||||||
|
.HasDefaultValue(20);
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<bool>("RequireKeywordInAnchor")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("SearchUrlTemplate")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(1024)
|
||||||
|
.HasColumnType("nvarchar(1024)");
|
||||||
|
|
||||||
|
b.Property<bool>("UseHeadlessBrowser")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bit")
|
||||||
|
.HasDefaultValue(false);
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("JobProviders", "cvSearch");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -105,6 +161,9 @@ namespace CvSearch.Data.Migrations
|
|||||||
.HasColumnType("nvarchar(8)")
|
.HasColumnType("nvarchar(8)")
|
||||||
.HasDefaultValue("en");
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ProviderConfigJson")
|
b.Property<string>("ProviderConfigJson")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -149,6 +208,13 @@ namespace CvSearch.Data.Migrations
|
|||||||
b.Property<DateTime>("ExpiresAt")
|
b.Property<DateTime>("ExpiresAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Keywords")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(1000)
|
||||||
|
.HasColumnType("nvarchar(1000)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
b.Property<string>("Language")
|
b.Property<string>("Language")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -156,6 +222,9 @@ namespace CvSearch.Data.Migrations
|
|||||||
.HasColumnType("nvarchar(8)")
|
.HasColumnType("nvarchar(8)")
|
||||||
.HasDefaultValue("en");
|
.HasDefaultValue("en");
|
||||||
|
|
||||||
|
b.Property<string>("Location")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("Used")
|
b.Property<bool>("Used")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("bit")
|
.HasColumnType("bit")
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<RootNamespace>CvSearch.Models</RootNamespace>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace CvSearch.Models.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddJobSearchTables : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.EnsureSchema(
|
|
||||||
name: "cvSearch");
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "JobSearchResults",
|
|
||||||
schema: "cvSearch",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
SessionId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
ProviderName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
|
||||||
JobUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
|
|
||||||
JobTitle = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
|
|
||||||
JobText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
|
||||||
Score = table.Column<int>(type: "int", nullable: false),
|
|
||||||
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_JobSearchResults", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "JobSearchSessions",
|
|
||||||
schema: "cvSearch",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
TokenId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
|
||||||
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
|
||||||
Keywords = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
|
||||||
ProviderConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_JobSearchSessions", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "JobSearchTokens",
|
|
||||||
schema: "cvSearch",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
|
||||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
|
||||||
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
|
||||||
Used = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_JobSearchTokens", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_JobSearchResults_SessionId",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchResults",
|
|
||||||
column: "SessionId");
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_JobSearchSessions_Status",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchSessions",
|
|
||||||
column: "Status");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "JobSearchResults",
|
|
||||||
schema: "cvSearch");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "JobSearchSessions",
|
|
||||||
schema: "cvSearch");
|
|
||||||
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "JobSearchTokens",
|
|
||||||
schema: "cvSearch");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace CvSearch.Models.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddLanguageToJobSearchEntities : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Language",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchTokens",
|
|
||||||
type: "nvarchar(8)",
|
|
||||||
maxLength: 8,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "en");
|
|
||||||
|
|
||||||
migrationBuilder.AddColumn<string>(
|
|
||||||
name: "Language",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchSessions",
|
|
||||||
type: "nvarchar(8)",
|
|
||||||
maxLength: 8,
|
|
||||||
nullable: false,
|
|
||||||
defaultValue: "en");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Language",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchTokens");
|
|
||||||
|
|
||||||
migrationBuilder.DropColumn(
|
|
||||||
name: "Language",
|
|
||||||
schema: "cvSearch",
|
|
||||||
table: "JobSearchSessions");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
namespace CvSearch.Models.Settings;
|
|
||||||
|
|
||||||
public sealed class JobSearchSettings
|
|
||||||
{
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
public string JobSearchLinkBaseUrl { get; set; } = string.Empty;
|
|
||||||
public int TokenExpiryDays { get; set; } = 7;
|
|
||||||
public int MinMatchScore { get; set; } = 15;
|
|
||||||
public int MaxJobsToMatch { get; set; } = 15;
|
|
||||||
public List<JobProviderConfig> Providers { get; set; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class JobProviderConfig
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public bool Enabled { get; set; } = true;
|
|
||||||
public string SearchUrlTemplate { get; set; } = string.Empty;
|
|
||||||
public string JobLinkContains { get; set; } = string.Empty;
|
|
||||||
public List<string> InitialKeywords { get; set; } = [];
|
|
||||||
public int MaxResults { get; set; } = 20;
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<RootNamespace>CvSearch.Models</RootNamespace>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -4,6 +4,7 @@ WORKDIR /src
|
|||||||
|
|
||||||
COPY Apis/email-api/email-api.csproj Apis/email-api/
|
COPY Apis/email-api/email-api.csproj Apis/email-api/
|
||||||
COPY Apis/email-data/email-data.csproj Apis/email-data/
|
COPY Apis/email-data/email-data.csproj Apis/email-data/
|
||||||
|
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
|
||||||
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
|
COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/
|
||||||
COPY Apis/api-models/api-models.csproj Apis/api-models/
|
COPY Apis/api-models/api-models.csproj Apis/api-models/
|
||||||
COPY Apis/common/common.csproj Apis/common/
|
COPY Apis/common/common.csproj Apis/common/
|
||||||
@@ -15,6 +16,7 @@ RUN dotnet restore Apis/email-api/email-api.csproj
|
|||||||
|
|
||||||
COPY Apis/email-api/ Apis/email-api/
|
COPY Apis/email-api/ Apis/email-api/
|
||||||
COPY Apis/email-data/ Apis/email-data/
|
COPY Apis/email-data/ Apis/email-data/
|
||||||
|
COPY Apis/shared-data/ Apis/shared-data/
|
||||||
COPY Apis/email-api-models/ Apis/email-api-models/
|
COPY Apis/email-api-models/ Apis/email-api-models/
|
||||||
COPY Apis/api-models/ Apis/api-models/
|
COPY Apis/api-models/ Apis/api-models/
|
||||||
COPY Apis/common/ Apis/common/
|
COPY Apis/common/ Apis/common/
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ try
|
|||||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||||
|
|
||||||
builder.Services.AddDbContext<EmailApiDbContext>(options =>
|
builder.Services.AddDbContext<EmailDbContext>(options =>
|
||||||
{
|
{
|
||||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||||
options.UseSqlServer(connectionString, sql =>
|
options.UseSqlServer(connectionString, sql =>
|
||||||
{
|
{
|
||||||
sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName);
|
sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName);
|
||||||
sql.MigrationsAssembly("email-data");
|
sql.MigrationsAssembly("email-data");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -50,9 +50,8 @@ try
|
|||||||
|
|
||||||
app.UseDefaultSerilogRequestLogging();
|
app.UseDefaultSerilogRequestLogging();
|
||||||
app.UseJsonExceptionHandler(ServiceName);
|
app.UseJsonExceptionHandler(ServiceName);
|
||||||
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
|
|
||||||
|
|
||||||
app.UseInternalApiKeyProtection();
|
app.UseInternalApiKeyProtection();
|
||||||
|
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
|
||||||
|
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
@@ -61,7 +60,7 @@ try
|
|||||||
Log.Information("Running EF Core migrations if any");
|
Log.Information("Running EF Core migrations if any");
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EmailApiDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EmailDbContext>();
|
||||||
db.Database.Migrate();
|
db.Database.Migrate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"Serilog": {
|
"Serilog": {
|
||||||
"Using": [
|
"Using": [
|
||||||
"Serilog.Sinks.Console",
|
"Serilog.Sinks.Console",
|
||||||
"Serilog.Sinks.File",
|
"Serilog.Sinks.File"
|
||||||
"Serilog.Sinks.Email"
|
|
||||||
],
|
],
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
@@ -30,25 +29,6 @@
|
|||||||
"retainedFileCountLimit": 30,
|
"retainedFileCountLimit": 30,
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Email",
|
|
||||||
"Args": {
|
|
||||||
"restrictedToMinimumLevel": "Error",
|
|
||||||
"fromEmail": "",
|
|
||||||
"toEmail": "",
|
|
||||||
"mailServer": "",
|
|
||||||
"networkCredential": {
|
|
||||||
"userName": "",
|
|
||||||
"password": ""
|
|
||||||
},
|
|
||||||
"port": 587,
|
|
||||||
"enableSsl": true,
|
|
||||||
"emailSubject": "[myAi] Email API Error Alert",
|
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
|
||||||
"batchPostingLimit": 10,
|
|
||||||
"period": "0.00:05:00"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Enrich": [
|
"Enrich": [
|
||||||
|
|||||||
@@ -3,19 +3,19 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace Email.Data;
|
namespace Email.Data;
|
||||||
|
|
||||||
public sealed class EmailApiDbContext : DbContext
|
public sealed class EmailDbContext : DbContext
|
||||||
{
|
{
|
||||||
public const string SchemaName = MigrationConstants.SchemaName;
|
public const string SchemaName = MigrationConstants.SchemaName;
|
||||||
public const string MigrationTableName = MigrationConstants.MigrationTableName;
|
public const string MigrationTableName = MigrationConstants.MigrationTableName;
|
||||||
|
|
||||||
public EmailApiDbContext(DbContextOptions<EmailApiDbContext> options) : base(options) { }
|
public EmailDbContext(DbContextOptions<EmailDbContext> options) : base(options) { }
|
||||||
|
|
||||||
public DbSet<EmailTemplateEntity> EmailTemplates => Set<EmailTemplateEntity>();
|
public DbSet<EmailTemplateEntity> Templates => Set<EmailTemplateEntity>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
base.OnConfiguring(optionsBuilder);
|
base.OnConfiguring(optionsBuilder);
|
||||||
// Configure migration history table to use schema-qualified name: [emailApi].[_Migrations]
|
// Configure migration history table to use schema-qualified name: [email].[_Migrations]
|
||||||
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
|
optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ public sealed class EmailApiDbContext : DbContext
|
|||||||
|
|
||||||
modelBuilder.Entity<EmailTemplateEntity>(entity =>
|
modelBuilder.Entity<EmailTemplateEntity>(entity =>
|
||||||
{
|
{
|
||||||
entity.ToTable("EmailTemplates");
|
entity.ToTable("Templates");
|
||||||
entity.HasKey(x => new { x.Key, x.Language });
|
entity.HasKey(x => new { x.Key, x.Language });
|
||||||
entity.Property(x => x.Key).HasMaxLength(128);
|
entity.Property(x => x.Key).HasMaxLength(128);
|
||||||
entity.Property(x => x.Language).HasMaxLength(8);
|
entity.Property(x => x.Language).HasMaxLength(8);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace Email.Data;
|
namespace Email.Data;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Schema constants used by EmailApiDbContext and migrations.
|
/// Schema constants used by EmailDbContext and migrations.
|
||||||
/// Centralized to avoid hardcoded strings and ensure consistency.
|
/// Centralized to avoid hardcoded strings and ensure consistency.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class MigrationConstants
|
public static class MigrationConstants
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Email.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Email.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(EmailApiDbContext))]
|
|
||||||
[Migration("20260528100000_CreateEmailTemplates")]
|
|
||||||
partial class CreateEmailTemplates
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasDefaultSchema(MigrationConstants.SchemaName)
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
|
||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Key")
|
|
||||||
.HasMaxLength(128)
|
|
||||||
.HasColumnType("nvarchar(128)");
|
|
||||||
|
|
||||||
b.Property<string>("Language")
|
|
||||||
.HasMaxLength(8)
|
|
||||||
.HasColumnType("nvarchar(8)");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(500)
|
|
||||||
.HasColumnType("nvarchar(500)")
|
|
||||||
.HasDefaultValue("");
|
|
||||||
|
|
||||||
b.Property<string>("OperatorCopy")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasMaxLength(256)
|
|
||||||
.HasColumnType("nvarchar(256)")
|
|
||||||
.HasDefaultValue("");
|
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("datetime2")
|
|
||||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
|
||||||
|
|
||||||
b.Property<string>("Value")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.HasKey("Key", "Language");
|
|
||||||
|
|
||||||
b.ToTable("EmailTemplates", MigrationConstants.SchemaName);
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,191 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Email.Data;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Email.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class CreateEmailTemplates : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.EnsureSchema(name: MigrationConstants.SchemaName);
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "EmailTemplates",
|
|
||||||
schema: MigrationConstants.SchemaName,
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
|
||||||
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
|
|
||||||
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
|
||||||
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
|
||||||
OperatorCopy = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "")
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_EmailTemplates", x => new { x.Key, x.Language });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Seed(MigrationBuilder m)
|
|
||||||
{
|
|
||||||
const string op = "contact@myai.ro";
|
|
||||||
|
|
||||||
void Row(string key, string lang, string value, string description = "", string operatorCopy = "")
|
|
||||||
=> m.InsertData("EmailTemplates",
|
|
||||||
["Key", "Language", "Value", "Description", "OperatorCopy"],
|
|
||||||
[key, lang, value, description, operatorCopy],
|
|
||||||
MigrationConstants.SchemaName);
|
|
||||||
|
|
||||||
// ── HTML shell (no operator copy — these are layout fragments, not addressable emails) ──
|
|
||||||
Row("email.html-shell.start", "*",
|
|
||||||
"<!DOCTYPE html>\n<html>\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head>\n<body style=\"margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"padding:20px 0\">\n <tr><td align=\"center\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\"\n style=\"background:#ffffff;border-radius:8px;max-width:600px\">\n <tr><td style=\"background:#2c5282;padding:24px 32px;border-radius:8px 8px 0 0\">\n <h1 style=\"margin:0;color:#ffffff;font-size:22px;font-weight:600\">myAi</h1>\n </td></tr>\n <tr><td style=\"padding:32px\">",
|
|
||||||
"Opening HTML shell fragment — wrapped around every HtmlBody before sending");
|
|
||||||
|
|
||||||
Row("email.html-shell.end", "*",
|
|
||||||
" </td></tr>\n <tr><td style=\"background:#f8f9fa;padding:16px 32px;text-align:center;\n color:#6c757d;font-size:12px;border-radius:0 0 8px 8px\">\n Automated message from myAi.\n </td></tr>\n </table>\n </td></tr>\n </table>\n</body>\n</html>",
|
|
||||||
"Closing HTML shell fragment — appended after every HtmlBody before sending");
|
|
||||||
|
|
||||||
// ── CV match result email ──
|
|
||||||
Row("email.match.subject", "en",
|
|
||||||
"MyAi.ro CV Match: {{score}}% - {{jobLabel}}",
|
|
||||||
"Subject for the CV match result email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.subject", "ro",
|
|
||||||
"MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}",
|
|
||||||
"Subiect email rezultat potrivire CV",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.body", "en",
|
|
||||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">CV Match Report</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:130px;border:1px solid #dee2e6;color:#495057\">CV ID</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr>" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr style=\"background:#f8f9fa\">" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr>" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Score</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"</table>" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Summary</h3>" +
|
|
||||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Strengths</h3>{{strengths}}" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Gaps</h3>{{gaps}}" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recommendations</h3>{{recommendations}}",
|
|
||||||
"Body for the CV match result email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.body", "ro",
|
|
||||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">Raport Potrivire CV</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:130px;border:1px solid #dee2e6;color:#495057\">ID Document CV</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr>" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr style=\"background:#f8f9fa\">" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr>" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Scor</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"</table>" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Rezumat</h3>" +
|
|
||||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Puncte forte</h3>{{strengths}}" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Lipsuri</h3>{{gaps}}" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recomandări</h3>{{recommendations}}",
|
|
||||||
"Corpul emailului pentru rezultatul potrivirii CV",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.job-search-footer", "en",
|
|
||||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
|
||||||
"<p style=\"margin:0;color:#495057\">" +
|
|
||||||
"Want to find matching jobs automatically? " +
|
|
||||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Start a job search →</a><br>" +
|
|
||||||
"<small style=\"color:#6c757d\">Link valid for {{expiryDays}} days.</small>" +
|
|
||||||
"</p>" +
|
|
||||||
"</div>",
|
|
||||||
"Job search CTA appended to match result email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.job-search-footer", "ro",
|
|
||||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
|
||||||
"<p style=\"margin:0;color:#495057\">" +
|
|
||||||
"Vrei să găsești joburi potrivite automat? " +
|
|
||||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Pornește o căutare de joburi →</a><br>" +
|
|
||||||
"<small style=\"color:#6c757d\">Link valabil {{expiryDays}} zile.</small>" +
|
|
||||||
"</p>" +
|
|
||||||
"</div>",
|
|
||||||
"CTA cautare joburi adaugat la emailul de potrivire CV",
|
|
||||||
op);
|
|
||||||
|
|
||||||
// ── Job search results email ──
|
|
||||||
Row("email.search-results.subject", "en",
|
|
||||||
"MyAi.ro: {{count}} jobs matching your CV",
|
|
||||||
"Subject for job search results email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.subject", "ro",
|
|
||||||
"MyAi.ro: {{count}} joburi potrivite CV-ului tau",
|
|
||||||
"Subiect email rezultate cautare joburi",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.body", "en",
|
|
||||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Job Search Results</h2>" +
|
|
||||||
"<p style=\"color:#495057\">Found <strong>{{count}}</strong> matching job(s):</p>" +
|
|
||||||
"{{items}}",
|
|
||||||
"Body preamble for job search results email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.body", "ro",
|
|
||||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Rezultate Căutare Joburi</h2>" +
|
|
||||||
"<p style=\"color:#495057\">Am găsit <strong>{{count}}</strong> job(uri) potrivite:</p>" +
|
|
||||||
"{{items}}",
|
|
||||||
"Corpul emailului de rezultate cautare joburi",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.empty", "en",
|
|
||||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
|
||||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">No matching jobs found</p>" +
|
|
||||||
"<p style=\"margin:0\">Your job search completed but no matching jobs were found. Try again later or adjust your CV.</p>" +
|
|
||||||
"</div>",
|
|
||||||
"No results message for job search results email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.empty", "ro",
|
|
||||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
|
||||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">Niciun job potrivit găsit</p>" +
|
|
||||||
"<p style=\"margin:0\">Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.</p>" +
|
|
||||||
"</div>",
|
|
||||||
"Mesaj fara rezultate pentru emailul de cautare joburi",
|
|
||||||
op);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Email.Data;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Email.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class SeedEmailTemplates : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
Seed(migrationBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
// Delete all seeded templates (only those we know we added)
|
|
||||||
migrationBuilder.DeleteData(
|
|
||||||
table: "EmailTemplates",
|
|
||||||
keyColumns: new[] { "Key", "Language" },
|
|
||||||
keyValues: new object[] { "email.html-shell.start", "*" });
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Seed(MigrationBuilder m)
|
|
||||||
{
|
|
||||||
const string op = "contact@myai.ro";
|
|
||||||
const string schema = MigrationConstants.SchemaName;
|
|
||||||
|
|
||||||
void Row(string key, string lang, string value, string description = "", string operatorCopy = "")
|
|
||||||
=> m.InsertData("EmailTemplates",
|
|
||||||
["Key", "Language", "Value", "Description", "OperatorCopy"],
|
|
||||||
[key, lang, value, description, operatorCopy],
|
|
||||||
schema);
|
|
||||||
|
|
||||||
// ── HTML shell (no operator copy — these are layout fragments, not addressable emails) ──
|
|
||||||
Row("email.html-shell.start", "*",
|
|
||||||
"<!DOCTYPE html>\n<html>\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head>\n<body style=\"margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"padding:20px 0\">\n <tr><td align=\"center\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\"\n style=\"background:#ffffff;border-radius:8px;max-width:600px\">\n <tr><td style=\"background:#2c5282;padding:24px 32px;border-radius:8px 8px 0 0\">\n <h1 style=\"margin:0;color:#ffffff;font-size:22px;font-weight:600\">myAi</h1>\n </td></tr>\n <tr><td style=\"padding:32px\">",
|
|
||||||
"Opening HTML shell fragment — wrapped around every HtmlBody before sending");
|
|
||||||
|
|
||||||
Row("email.html-shell.end", "*",
|
|
||||||
" </td></tr>\n <tr><td style=\"background:#f8f9fa;padding:16px 32px;text-align:center;\n color:#6c757d;font-size:12px;border-radius:0 0 8px 8px\">\n Automated message from myAi.\n </td></tr>\n </table>\n </td></tr>\n </table>\n</body>\n</html>",
|
|
||||||
"Closing HTML shell fragment — appended after every HtmlBody before sending");
|
|
||||||
|
|
||||||
// ── CV match result email ──
|
|
||||||
Row("email.match.subject", "en",
|
|
||||||
"MyAi.ro CV Match: {{score}}% - {{jobLabel}}",
|
|
||||||
"Subject for the CV match result email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.subject", "ro",
|
|
||||||
"MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}",
|
|
||||||
"Subiect email rezultat potrivire CV",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.body", "en",
|
|
||||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">CV Match Report</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:130px;border:1px solid #dee2e6;color:#495057\">CV ID</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr>" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr style=\"background:#f8f9fa\">" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr>" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Score</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"</table>" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Summary</h3>" +
|
|
||||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Strengths</h3>{{strengths}}" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Gaps</h3>{{gaps}}" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recommendations</h3>{{recommendations}}",
|
|
||||||
"Body for the CV match result email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.body", "ro",
|
|
||||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">Raport Potrivire CV</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:130px;border:1px solid #dee2e6;color:#495057\">ID Document CV</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr>" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr style=\"background:#f8f9fa\">" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"<tr>" +
|
|
||||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Scor</td>" +
|
|
||||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
|
||||||
"</tr>" +
|
|
||||||
"</table>" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Rezumat</h3>" +
|
|
||||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Puncte forte</h3>{{strengths}}" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Lipsuri</h3>{{gaps}}" +
|
|
||||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recomandări</h3>{{recommendations}}",
|
|
||||||
"Corpul emailului pentru rezultatul potrivirii CV",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.job-search-footer", "en",
|
|
||||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
|
||||||
"<p style=\"margin:0;color:#495057\">" +
|
|
||||||
"Want to find matching jobs automatically? " +
|
|
||||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Start a job search →</a><br>" +
|
|
||||||
"<small style=\"color:#6c757d\">Link valid for {{expiryDays}} days.</small>" +
|
|
||||||
"</p>" +
|
|
||||||
"</div>",
|
|
||||||
"Job search CTA appended to match result email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.match.job-search-footer", "ro",
|
|
||||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
|
||||||
"<p style=\"margin:0;color:#495057\">" +
|
|
||||||
"Vrei să găsești joburi potrivite automat? " +
|
|
||||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Pornește o căutare de joburi →</a><br>" +
|
|
||||||
"<small style=\"color:#6c757d\">Link valabil {{expiryDays}} zile.</small>" +
|
|
||||||
"</p>" +
|
|
||||||
"</div>",
|
|
||||||
"CTA cautare joburi adaugat la emailul de potrivire CV",
|
|
||||||
op);
|
|
||||||
|
|
||||||
// ── Job search results email ──
|
|
||||||
Row("email.search-results.subject", "en",
|
|
||||||
"MyAi.ro: {{count}} jobs matching your CV",
|
|
||||||
"Subject for job search results email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.subject", "ro",
|
|
||||||
"MyAi.ro: {{count}} joburi potrivite CV-ului tau",
|
|
||||||
"Subiect email rezultate cautare joburi",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.body", "en",
|
|
||||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Job Search Results</h2>" +
|
|
||||||
"<p style=\"color:#495057\">Found <strong>{{count}}</strong> matching job(s):</p>" +
|
|
||||||
"{{items}}",
|
|
||||||
"Body preamble for job search results email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.body", "ro",
|
|
||||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Rezultate Căutare Joburi</h2>" +
|
|
||||||
"<p style=\"color:#495057\">Am găsit <strong>{{count}}</strong> job(uri) potrivite:</p>" +
|
|
||||||
"{{items}}",
|
|
||||||
"Corpul emailului de rezultate cautare joburi",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.empty", "en",
|
|
||||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
|
||||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">No matching jobs found</p>" +
|
|
||||||
"<p style=\"margin:0\">Your job search completed but no matching jobs were found. Try again later or adjust your CV.</p>" +
|
|
||||||
"</div>",
|
|
||||||
"No results message for job search results email",
|
|
||||||
op);
|
|
||||||
|
|
||||||
Row("email.search-results.empty", "ro",
|
|
||||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
|
||||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">Niciun job potrivit găsit</p>" +
|
|
||||||
"<p style=\"margin:0\">Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.</p>" +
|
|
||||||
"</div>",
|
|
||||||
"Mesaj fara rezultate pentru emailul de cautare joburi",
|
|
||||||
op);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+5
-5
@@ -11,16 +11,16 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
namespace Email.Data.Migrations
|
namespace Email.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(EmailApiDbContext))]
|
[DbContext(typeof(EmailDbContext))]
|
||||||
[Migration("20260528130652_SeedEmailTemplates")]
|
[Migration("20260601133043_InitialSchema")]
|
||||||
partial class SeedEmailTemplates
|
partial class InitialSchema
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasDefaultSchema(MigrationConstants.SchemaName)
|
.HasDefaultSchema("email")
|
||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ namespace Email.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Key", "Language");
|
b.HasKey("Key", "Language");
|
||||||
|
|
||||||
b.ToTable("EmailTemplates", MigrationConstants.SchemaName);
|
b.ToTable("Templates", "email");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using System;
|
||||||
|
using Email.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Email.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class InitialSchema : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.EnsureSchema(
|
||||||
|
name: MigrationConstants.SchemaName);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Templates",
|
||||||
|
schema: MigrationConstants.SchemaName,
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||||
|
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
|
||||||
|
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||||
|
OperatorCopy = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language });
|
||||||
|
});
|
||||||
|
|
||||||
|
Seed(migrationBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Seed(MigrationBuilder m)
|
||||||
|
{
|
||||||
|
void Row(string key, string lang, string value, string description = "")
|
||||||
|
=> m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], MigrationConstants.SchemaName);
|
||||||
|
|
||||||
|
// Match result email — subject
|
||||||
|
Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email");
|
||||||
|
Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV");
|
||||||
|
|
||||||
|
// Match result email — body (HTML formatted)
|
||||||
|
Row("email.match.body", "en",
|
||||||
|
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">CV Match Report</h2>
|
||||||
|
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 30px; border-collapse: collapse;"">
|
||||||
|
<tr style=""background-color: #2c5282; color: white;"">
|
||||||
|
<td width=""130"" style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #2c5282;"">CV ID</td>
|
||||||
|
<td style=""padding: 12px 15px; border: 1px solid #2c5282;"">{{cvDocumentId}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style=""background-color: #f8f9fa;"">
|
||||||
|
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Job</td>
|
||||||
|
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #333;"">{{jobLabel}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">URL</td>
|
||||||
|
<td style=""padding: 12px 15px; border: 1px solid #ddd;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr style=""background-color: #f8f9fa;"">
|
||||||
|
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Score</td>
|
||||||
|
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #27ae60; font-weight: bold; font-size: 18px;"">{{score}}%</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Summary</h3>
|
||||||
|
<p style=""line-height: 1.6; color: #333;"">{{summary}}</p>
|
||||||
|
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Strengths</h3>
|
||||||
|
<div style=""line-height: 1.8; color: #333;"">{{strengths}}</div>
|
||||||
|
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Gaps</h3>
|
||||||
|
<div style=""line-height: 1.8; color: #333;"">{{gaps}}</div>
|
||||||
|
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Recommendations</h3>
|
||||||
|
<div style=""line-height: 1.8; color: #333;"">{{recommendations}}</div>",
|
||||||
|
"Body for the CV match result email (HTML formatted)");
|
||||||
|
Row("email.match.body", "ro",
|
||||||
|
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Report Potrivire CV</h2>
|
||||||
|
<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 30px; border-collapse: collapse;"">
|
||||||
|
<tr style=""background-color: #2c5282; color: white;"">
|
||||||
|
<td width=""130"" style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #2c5282;"">ID Document CV</td>
|
||||||
|
<td style=""padding: 12px 15px; border: 1px solid #2c5282;"">{{cvDocumentId}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style=""background-color: #f8f9fa;"">
|
||||||
|
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Job</td>
|
||||||
|
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #333;"">{{jobLabel}}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">URL</td>
|
||||||
|
<td style=""padding: 12px 15px; border: 1px solid #ddd;""><a href=""{{jobUrl}}"" style=""color: #2c5282; text-decoration: none;"">{{jobUrl}}</a></td>
|
||||||
|
</tr>
|
||||||
|
<tr style=""background-color: #f8f9fa;"">
|
||||||
|
<td style=""width: 130px; padding: 12px 15px; font-weight: bold; border: 1px solid #ddd; color: #333;"">Scor</td>
|
||||||
|
<td style=""padding: 12px 15px; border: 1px solid #ddd; color: #27ae60; font-weight: bold; font-size: 18px;"">{{score}}%</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Rezumat</h3>
|
||||||
|
<p style=""line-height: 1.6; color: #333;"">{{summary}}</p>
|
||||||
|
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Puncte Forte</h3>
|
||||||
|
<div style=""line-height: 1.8; color: #333;"">{{strengths}}</div>
|
||||||
|
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Lipsuri</h3>
|
||||||
|
<div style=""line-height: 1.8; color: #333;"">{{gaps}}</div>
|
||||||
|
<h3 style=""color: #2c5282; margin-top: 25px; margin-bottom: 10px; border-bottom: 2px solid #2c5282; padding-bottom: 5px;"">Recomandări</h3>
|
||||||
|
<div style=""line-height: 1.8; color: #333;"">{{recommendations}}</div>",
|
||||||
|
"Corpul emailului pentru rezultatul potrivirii CV (format HTML)");
|
||||||
|
|
||||||
|
// Match result email — job search CTA footer (HTML formatted)
|
||||||
|
Row("email.match.job-search-footer", "en",
|
||||||
|
@"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
|
||||||
|
<p style=""margin-top: 20px; font-size: 14px; color: #333;"">Want to find more jobs matching your CV?</p>
|
||||||
|
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""margin-bottom: 20px;"">
|
||||||
|
<tr>
|
||||||
|
<td align=""center"" bgcolor=""#2c5282"" style=""background-color: #2c5282; padding: 10px 20px;"">
|
||||||
|
<a href=""{{jobSearchLink}}"" style=""color: #ffffff; font-weight: bold; text-decoration: none; font-size: 14px; display: block;"">Search Jobs</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style=""font-size: 12px; color: #666;"">(link valid for {{expiryDays}} days)</p>",
|
||||||
|
"Job search CTA appended to match result email (HTML formatted)");
|
||||||
|
Row("email.match.job-search-footer", "ro",
|
||||||
|
@"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
|
||||||
|
<p style=""margin-top: 20px; font-size: 14px; color: #333;"">Vrei să găsești mai multe joburi potrivite CV-ului tău?</p>
|
||||||
|
<table cellpadding=""0"" cellspacing=""0"" border=""0"" style=""margin-bottom: 20px;"">
|
||||||
|
<tr>
|
||||||
|
<td align=""center"" bgcolor=""#2c5282"" style=""background-color: #2c5282; padding: 10px 20px;"">
|
||||||
|
<a href=""{{jobSearchLink}}"" style=""color: #ffffff; font-weight: bold; text-decoration: none; font-size: 14px; display: block;"">Caută Joburi</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style=""font-size: 12px; color: #666;"">(link valabil {{expiryDays}} zile)</p>",
|
||||||
|
"CTA cautare joburi adaugat la emailul de potrivire CV (format HTML)");
|
||||||
|
|
||||||
|
// Job search results email — subject
|
||||||
|
Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email");
|
||||||
|
Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi");
|
||||||
|
|
||||||
|
// Job search results email — body preamble (items appended in code) - HTML formatted
|
||||||
|
Row("email.search-results.body", "en",
|
||||||
|
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Job Search Results</h2>
|
||||||
|
<p style=""margin-bottom: 20px; color: #333;"">MyAi.ro found <strong>{{count}}</strong> jobs matching your CV:</p>
|
||||||
|
{{items}}",
|
||||||
|
"Body preamble for job search results email (HTML formatted)");
|
||||||
|
Row("email.search-results.body", "ro",
|
||||||
|
@"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Rezultate Căutare Joburi</h2>
|
||||||
|
<p style=""margin-bottom: 20px; color: #333;"">MyAi.ro a găsit <strong>{{count}}</strong> joburi potrivite CV-ului tău:</p>
|
||||||
|
{{items}}",
|
||||||
|
"Corpul emailului de rezultate cautare joburi (format HTML)");
|
||||||
|
|
||||||
|
// Job search results email — scan summary block (keywords + providers used)
|
||||||
|
Row("email.search-results.scan-summary", "en",
|
||||||
|
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||||
|
<div style=""margin-bottom: 8px;""><strong>Keywords used:</strong> {{keywordsHtml}}</div>
|
||||||
|
<div><strong>Providers scanned:</strong> {{providers}}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>",
|
||||||
|
"Scan summary block prepended to job search results email (HTML formatted)");
|
||||||
|
Row("email.search-results.scan-summary", "ro",
|
||||||
|
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 18px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor=""#f8f9fa"" style=""background-color: #f8f9fa; padding: 14px 16px; font-size: 13px; color: #495057;"">
|
||||||
|
<div style=""margin-bottom: 8px;""><strong>Cuvinte cheie folosite:</strong> {{keywordsHtml}}</div>
|
||||||
|
<div><strong>Furnizori scanați:</strong> {{providers}}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>",
|
||||||
|
"Bloc rezumat scanare adaugat la emailul de rezultate cautare joburi (format HTML)");
|
||||||
|
|
||||||
|
// Job search results email — single job result item card
|
||||||
|
Row("email.search-results.item", "en",
|
||||||
|
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 12px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||||
|
<tr>
|
||||||
|
<td style=""padding: 12px 16px; background-color: #ffffff;"">
|
||||||
|
<strong style=""color: #212529;"">{{index}}. {{jobTitle}}</strong>
|
||||||
|
<span style=""background: #28a745; color: #fff; padding: 2px 8px; font-size: 12px; margin-left: 8px;"">{{score}}% match</span>
|
||||||
|
<span style=""color: #6c757d; font-size: 12px; margin-left: 4px;"">[{{providerName}}]</span><br>
|
||||||
|
<a href=""{{jobUrl}}"" style=""color: #2c5282; font-size: 13px;"">{{jobUrl}}</a>
|
||||||
|
{{summary}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>",
|
||||||
|
"Single job result card in job search results email (HTML formatted)");
|
||||||
|
Row("email.search-results.item", "ro",
|
||||||
|
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 12px; border: 1px solid #dee2e6; border-collapse: collapse;"">
|
||||||
|
<tr>
|
||||||
|
<td style=""padding: 12px 16px; background-color: #ffffff;"">
|
||||||
|
<strong style=""color: #212529;"">{{index}}. {{jobTitle}}</strong>
|
||||||
|
<span style=""background: #28a745; color: #fff; padding: 2px 8px; font-size: 12px; margin-left: 8px;"">{{score}}% potrivire</span>
|
||||||
|
<span style=""color: #6c757d; font-size: 12px; margin-left: 4px;"">[{{providerName}}]</span><br>
|
||||||
|
<a href=""{{jobUrl}}"" style=""color: #2c5282; font-size: 13px;"">{{jobUrl}}</a>
|
||||||
|
{{summary}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>",
|
||||||
|
"Card job individual in emailul de rezultate cautare joburi (format HTML)");
|
||||||
|
|
||||||
|
// Job search results email — no results found - HTML formatted
|
||||||
|
Row("email.search-results.empty", "en",
|
||||||
|
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin: 20px 0;"">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor=""#fff3cd"" style=""background-color: #fff3cd; border: 1px solid #ffc107; padding: 15px;"">
|
||||||
|
<p style=""margin: 0; color: #856404;""><strong>No jobs found</strong><br>
|
||||||
|
MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>",
|
||||||
|
"No results message for job search results email (HTML formatted)");
|
||||||
|
Row("email.search-results.empty", "ro",
|
||||||
|
@"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin: 20px 0;"">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor=""#fff3cd"" style=""background-color: #fff3cd; border: 1px solid #ffc107; padding: 15px;"">
|
||||||
|
<p style=""margin: 0; color: #856404;""><strong>Niciun job găsit</strong><br>
|
||||||
|
MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>",
|
||||||
|
"Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)");
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Templates",
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Email.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Email.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(EmailDbContext))]
|
||||||
|
[Migration("20260601145256_AddHtmlShellTemplates")]
|
||||||
|
partial class AddHtmlShellTemplates
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("email")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
|
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Key")
|
||||||
|
.HasMaxLength(128)
|
||||||
|
.HasColumnType("nvarchar(128)");
|
||||||
|
|
||||||
|
b.Property<string>("Language")
|
||||||
|
.HasMaxLength(8)
|
||||||
|
.HasColumnType("nvarchar(8)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<string>("OperatorCopy")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("nvarchar(256)")
|
||||||
|
.HasDefaultValue("");
|
||||||
|
|
||||||
|
b.Property<DateTime>("UpdatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("datetime2")
|
||||||
|
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Key", "Language");
|
||||||
|
|
||||||
|
b.ToTable("Templates", "email");
|
||||||
|
});
|
||||||
|
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Email.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Email.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHtmlShellTemplates : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// HTML email shell — opening tags (blue header, white card container)
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Templates",
|
||||||
|
columns: new[] { "Key", "Language", "Value", "Description" },
|
||||||
|
values: new object[] { "email.html-shell.start", "*", "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"background-color: #f5f5f5;\">\n <tr>\n <td align=\"center\" style=\"padding: 20px 0;\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"width: 600px; max-width: 600px; background-color: #ffffff;\">\n <tr>\n <td align=\"center\" style=\"background-color: #2c5282; padding: 24px; text-align: center;\">\n <h1 style=\"margin: 0; color: #ffffff; font-size: 24px; font-weight: 600;\">MyAi.ro</h1>\n </td>\n </tr>\n <tr>\n <td style=\"padding: 24px; background-color: #ffffff;\">\n", "Opening HTML wrapper for branded emails (blue header, white content area)" },
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
|
||||||
|
// HTML email shell — closing tags (footer)
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Templates",
|
||||||
|
columns: new[] { "Key", "Language", "Value", "Description" },
|
||||||
|
values: new object[] { "email.html-shell.end", "*", "\n </td>\n </tr>\n <tr>\n <td align=\"center\" style=\"background-color: #f8f9fa; padding: 16px; text-align: center; border-top: 1px solid #dee2e6;\">\n <p style=\"margin: 0; color: #6c757d; font-size: 12px;\">© 2026 MyAi.ro. All rights reserved.</p>\n </td>\n </tr>\n </table>\n </td>\n </tr>\n </table>\n</body>\n</html>\n", "Closing HTML wrapper for branded emails (footer and closing tags)" },
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Templates",
|
||||||
|
keyColumns: new[] { "Key", "Language" },
|
||||||
|
keyValues: new object[] { "email.html-shell.start", "*" },
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Templates",
|
||||||
|
keyColumns: new[] { "Key", "Language" },
|
||||||
|
keyValues: new object[] { "email.html-shell.end", "*" },
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-4
@@ -10,14 +10,14 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|||||||
|
|
||||||
namespace Email.Data.Migrations
|
namespace Email.Data.Migrations
|
||||||
{
|
{
|
||||||
[DbContext(typeof(EmailApiDbContext))]
|
[DbContext(typeof(EmailDbContext))]
|
||||||
partial class EmailApiDbContextModelSnapshot : ModelSnapshot
|
partial class EmailDbContextModelSnapshot : ModelSnapshot
|
||||||
{
|
{
|
||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasDefaultSchema(MigrationConstants.SchemaName)
|
.HasDefaultSchema("email")
|
||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
.HasAnnotation("ProductVersion", "10.0.7")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ namespace Email.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Key", "Language");
|
b.HasKey("Key", "Language");
|
||||||
|
|
||||||
b.ToTable("EmailTemplates", MigrationConstants.SchemaName);
|
b.ToTable("Templates", "email");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#pragma warning restore 612, 618
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,13 @@ namespace Email.Data.Repositories;
|
|||||||
|
|
||||||
public sealed class EfEmailTemplateRepository : IEmailTemplateRepository
|
public sealed class EfEmailTemplateRepository : IEmailTemplateRepository
|
||||||
{
|
{
|
||||||
private readonly EmailApiDbContext _db;
|
private readonly EmailDbContext _db;
|
||||||
|
|
||||||
public EfEmailTemplateRepository(EmailApiDbContext db)
|
public EfEmailTemplateRepository(EmailDbContext db)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct)
|
public async Task<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct)
|
||||||
=> await _db.EmailTemplates.AsNoTracking().ToListAsync(ct);
|
=> await _db.Templates.AsNoTracking().ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ public sealed class EmailTemplateService : IEmailTemplateService
|
|||||||
&& _valueCache.TryGetValue(CacheKey(key, "en"), out var fallback))
|
&& _valueCache.TryGetValue(CacheKey(key, "en"), out var fallback))
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|
||||||
_logger.LogWarning("Email template not found: key={Key}, language={Language}", key, language);
|
throw new InvalidOperationException(
|
||||||
return key;
|
$"Email template not found: key='{key}', language='{language}'. " +
|
||||||
|
$"This is a configuration error. Ensure the email.Templates table is properly seeded.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -71,6 +71,11 @@ namespace MyAi.Data.Migrations
|
|||||||
Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email");
|
Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email");
|
||||||
Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi");
|
Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi");
|
||||||
|
|
||||||
|
// HTML job-search page shell — wraps title + message in a centered card page
|
||||||
|
Row("html.job-search.shell", "*",
|
||||||
|
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>{{title}} - MyAi.ro</title><style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}h1{font-size:1.4rem;margin-bottom:.5rem;color:#2c5282}p{color:#555}</style></head><body><div class=\"card\"><h1>{{title}}</h1><p>{{message}}</p></div></body></html>",
|
||||||
|
"Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}.");
|
||||||
|
|
||||||
// HTML job-search start page messages
|
// HTML job-search start page messages
|
||||||
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page");
|
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page");
|
||||||
Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page");
|
Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page");
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using MyAi.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace MyAi.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHtmlJobSearchShell : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.InsertData(
|
||||||
|
table: "Templates",
|
||||||
|
columns: ["Key", "Language", "Value", "Description"],
|
||||||
|
values: new object[]
|
||||||
|
{
|
||||||
|
"html.job-search.shell",
|
||||||
|
"*",
|
||||||
|
"<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>{{title}} - MyAi.ro</title><style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f5f5f5}.card{background:#fff;padding:2rem 3rem;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.1);text-align:center;max-width:480px}h1{font-size:1.4rem;margin-bottom:.5rem;color:#2c5282}p{color:#555}</style></head><body><div class=\"card\"><h1>{{title}}</h1><p>{{message}}</p></div></body></html>",
|
||||||
|
"Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}."
|
||||||
|
},
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DeleteData(
|
||||||
|
table: "Templates",
|
||||||
|
keyColumns: ["Key", "Language"],
|
||||||
|
keyValues: new object[] { "html.job-search.shell", "*" },
|
||||||
|
schema: MigrationConstants.SchemaName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<RootNamespace>MyAi.Models</RootNamespace>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using MyAi.Models.Data;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace MyAi.Models.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(MyAiDbContext))]
|
|
||||||
[Migration("20260524145351_AddTemplates")]
|
|
||||||
partial class AddTemplates
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasDefaultSchema("myAi")
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
|
||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("MyAi.Models.Data.Entities.TemplateEntity", 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("Templates", "myAi");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace MyAi.Models.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddTemplates : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.EnsureSchema(
|
|
||||||
name: "myAi");
|
|
||||||
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "Templates",
|
|
||||||
schema: "myAi",
|
|
||||||
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_Templates", x => new { x.Key, x.Language });
|
|
||||||
});
|
|
||||||
|
|
||||||
Seed(migrationBuilder);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Seed(MigrationBuilder m)
|
|
||||||
{
|
|
||||||
void Row(string key, string lang, string value, string description = "")
|
|
||||||
=> m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], "myAi");
|
|
||||||
|
|
||||||
// Match result email — subject
|
|
||||||
Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email");
|
|
||||||
Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV");
|
|
||||||
|
|
||||||
// Match result email — body
|
|
||||||
Row("email.match.body", "en",
|
|
||||||
"CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}",
|
|
||||||
"Body for the CV match result email");
|
|
||||||
Row("email.match.body", "ro",
|
|
||||||
"Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}",
|
|
||||||
"Corpul emailului pentru rezultatul potrivirii CV");
|
|
||||||
|
|
||||||
// Match result email — job search CTA footer
|
|
||||||
Row("email.match.job-search-footer", "en",
|
|
||||||
"\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)",
|
|
||||||
"Job search CTA appended to match result email");
|
|
||||||
Row("email.match.job-search-footer", "ro",
|
|
||||||
"\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)",
|
|
||||||
"CTA cautare joburi adaugat la emailul de potrivire CV");
|
|
||||||
|
|
||||||
// Job search results email — subject
|
|
||||||
Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email");
|
|
||||||
Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi");
|
|
||||||
|
|
||||||
// Job search results email — body preamble (items appended in code)
|
|
||||||
Row("email.search-results.body", "en", "MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}", "Body preamble for job search results email");
|
|
||||||
Row("email.search-results.body", "ro", "MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}", "Corpul emailului de rezultate cautare joburi");
|
|
||||||
|
|
||||||
// Job search results email — no results found
|
|
||||||
Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email");
|
|
||||||
Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi");
|
|
||||||
|
|
||||||
// HTML job-search start page messages
|
|
||||||
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page");
|
|
||||||
Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page");
|
|
||||||
Row("html.job-search.started.title", "ro", "Căutare joburi pornită", "Titlu pagina cautare joburi pornita");
|
|
||||||
Row("html.job-search.started.message", "ro", "Căutarea joburilor a început. Rezultatele vor fi trimise pe email în scurt timp.", "Mesaj pagina cautare joburi pornita");
|
|
||||||
|
|
||||||
Row("html.job-search.already-used.title", "en", "Link already used", "Title for already-used page");
|
|
||||||
Row("html.job-search.already-used.message", "en", "This job search link has already been used.", "Message for already-used page");
|
|
||||||
Row("html.job-search.already-used.title", "ro", "Link deja folosit", "Titlu pagina link deja folosit");
|
|
||||||
Row("html.job-search.already-used.message", "ro", "Acest link de cautare joburi a fost deja folosit.", "Mesaj pagina link deja folosit");
|
|
||||||
|
|
||||||
Row("html.job-search.expired.title", "en", "Link expired", "Title for expired link page");
|
|
||||||
Row("html.job-search.expired.message", "en", "This job search link has expired. Please request a new CV match to get a fresh link.", "Message for expired link page");
|
|
||||||
Row("html.job-search.expired.title", "ro", "Link expirat", "Titlu pagina link expirat");
|
|
||||||
Row("html.job-search.expired.message", "ro", "Acest link de cautare joburi a expirat. Solicita o noua potrivire CV pentru a primi un link nou.", "Mesaj pagina link expirat");
|
|
||||||
|
|
||||||
Row("html.job-search.invalid.title", "en", "Invalid link", "Title for invalid link page");
|
|
||||||
Row("html.job-search.invalid.message", "en", "This job search link is not valid.", "Message for invalid link page");
|
|
||||||
Row("html.job-search.invalid.title", "ro", "Link invalid", "Titlu pagina link invalid");
|
|
||||||
Row("html.job-search.invalid.message", "ro", "Acest link de cautare joburi nu este valid.", "Mesaj pagina link invalid");
|
|
||||||
|
|
||||||
Row("html.job-search.error.title", "en", "Error", "Title for error page");
|
|
||||||
Row("html.job-search.error.message", "en", "An error occurred. Please try again later.", "Message for error page");
|
|
||||||
Row("html.job-search.error.title", "ro", "Eroare", "Titlu pagina eroare");
|
|
||||||
Row("html.job-search.error.message", "ro", "A apărut o eroare. Te rugăm să încerci din nou mai târziu.", "Mesaj pagina eroare");
|
|
||||||
|
|
||||||
// AI system prompt for CV matching (language is a {{languageName}} variable inside it)
|
|
||||||
Row("ai.cv-match.system-prompt", "*",
|
|
||||||
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\nPenalize missing required skills. Do not invent experience. Use concise business language.\nRespond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\nJSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}",
|
|
||||||
"System prompt template for the CV-to-job LLM matching call. {{languageName}} is substituted at runtime.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "Templates",
|
|
||||||
schema: "myAi");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Metadata;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
using MyAi.Models.Data;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace MyAi.Models.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(MyAiDbContext))]
|
|
||||||
partial class MyAiDbContextModelSnapshot : ModelSnapshot
|
|
||||||
{
|
|
||||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder
|
|
||||||
.HasDefaultSchema("myAi")
|
|
||||||
.HasAnnotation("ProductVersion", "10.0.7")
|
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
|
||||||
|
|
||||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
|
||||||
|
|
||||||
modelBuilder.Entity("MyAi.Models.Data.Entities.TemplateEntity", 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("Templates", "myAi");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using MyAi.Models.Data;
|
|
||||||
using System.Collections.Concurrent;
|
|
||||||
|
|
||||||
namespace MyAi.Models.Services;
|
|
||||||
|
|
||||||
public sealed class DbTemplateService : ITemplateService
|
|
||||||
{
|
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
|
||||||
private readonly ILogger<DbTemplateService> _logger;
|
|
||||||
private ConcurrentDictionary<string, string> _cache = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
private DateTime _loadedAt = DateTime.MinValue;
|
|
||||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10);
|
|
||||||
|
|
||||||
public DbTemplateService(IServiceScopeFactory scopeFactory, ILogger<DbTemplateService> logger)
|
|
||||||
{
|
|
||||||
_scopeFactory = scopeFactory;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Get(string key, string language = "en")
|
|
||||||
{
|
|
||||||
EnsureCacheLoaded();
|
|
||||||
|
|
||||||
if (_cache.TryGetValue(CacheKey(key, language), out var value))
|
|
||||||
return value;
|
|
||||||
|
|
||||||
if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& _cache.TryGetValue(CacheKey(key, "en"), out var fallback))
|
|
||||||
return fallback;
|
|
||||||
|
|
||||||
_logger.LogWarning("Template not found: key={Key}, language={Language}", key, language);
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
public string Render(string key, string language, params (string Key, string Value)[] placeholders)
|
|
||||||
{
|
|
||||||
var template = Get(key, language);
|
|
||||||
foreach (var (k, v) in placeholders)
|
|
||||||
template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase);
|
|
||||||
return template;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void EnsureCacheLoaded()
|
|
||||||
{
|
|
||||||
if (DateTime.UtcNow - _loadedAt < CacheTtl) return;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
|
||||||
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
|
|
||||||
var rows = db.Templates.AsNoTracking().ToList();
|
|
||||||
var fresh = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
foreach (var row in rows)
|
|
||||||
fresh[CacheKey(row.Key, row.Language)] = row.Value;
|
|
||||||
|
|
||||||
_cache = fresh;
|
|
||||||
_loadedAt = DateTime.UtcNow;
|
|
||||||
_logger.LogDebug("Template cache refreshed. {Count} templates loaded.", rows.Count);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Failed to refresh template cache. Serving stale cache.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string CacheKey(string key, string language) => $"{key}::{language}";
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace MyAi.Models.Services;
|
|
||||||
|
|
||||||
public interface ITemplateService
|
|
||||||
{
|
|
||||||
string Get(string key, string language = "en");
|
|
||||||
string Render(string key, string language, params (string Key, string Value)[] placeholders);
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<RootNamespace>MyAi.Models</RootNamespace>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
|
||||||
</PackageReference>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
"Serilog": {
|
"Serilog": {
|
||||||
"Using": [
|
"Using": [
|
||||||
"Serilog.Sinks.Console",
|
"Serilog.Sinks.Console",
|
||||||
"Serilog.Sinks.File",
|
"Serilog.Sinks.File"
|
||||||
"Serilog.Sinks.Email"
|
|
||||||
],
|
],
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
@@ -30,25 +29,6 @@
|
|||||||
"retainedFileCountLimit": 30,
|
"retainedFileCountLimit": 30,
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Email",
|
|
||||||
"Args": {
|
|
||||||
"restrictedToMinimumLevel": "Error",
|
|
||||||
"fromEmail": "",
|
|
||||||
"toEmail": "",
|
|
||||||
"mailServer": "",
|
|
||||||
"networkCredential": {
|
|
||||||
"userName": "",
|
|
||||||
"password": ""
|
|
||||||
},
|
|
||||||
"port": 587,
|
|
||||||
"enableSsl": true,
|
|
||||||
"emailSubject": "[mihes.ro API] Error Alert",
|
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
|
||||||
"batchPostingLimit": 10,
|
|
||||||
"period": "0.00:05:00"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Enrich": [
|
"Enrich": [
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ namespace Rag.Data.Migrations
|
|||||||
table.ForeignKey(
|
table.ForeignKey(
|
||||||
name: "FK_Chunks_Documents_DocumentId",
|
name: "FK_Chunks_Documents_DocumentId",
|
||||||
column: x => x.DocumentId,
|
column: x => x.DocumentId,
|
||||||
principalSchema: "rag",
|
principalSchema: MigrationConstants.SchemaName,
|
||||||
principalTable: "Documents",
|
principalTable: "Documents",
|
||||||
principalColumn: "Id",
|
principalColumn: "Id",
|
||||||
onDelete: ReferentialAction.Cascade);
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
<PackageVersion Include="MailKit" Version="4.16.0" />
|
<PackageVersion Include="MailKit" Version="4.16.0" />
|
||||||
<PackageVersion Include="PdfPig" Version="0.1.14" />
|
<PackageVersion Include="PdfPig" Version="0.1.14" />
|
||||||
|
<!-- Browser automation -->
|
||||||
|
<PackageVersion Include="Microsoft.Playwright" Version="1.60.0" />
|
||||||
<!-- Tooling -->
|
<!-- Tooling -->
|
||||||
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
<PackageVersion Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Net;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Azure.Identity;
|
using Azure.Identity;
|
||||||
|
using MailKit.Security;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
using Microsoft.AspNetCore.Diagnostics;
|
using Microsoft.AspNetCore.Diagnostics;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -9,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||||
using Swashbuckle.AspNetCore.Annotations;
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
|
||||||
@@ -41,6 +44,8 @@ public static class StartupExtensions
|
|||||||
.Enrich.WithProperty("Service", serviceName)
|
.Enrich.WithProperty("Service", serviceName)
|
||||||
.Enrich.WithProperty("AppVersion", appVersion)
|
.Enrich.WithProperty("AppVersion", appVersion)
|
||||||
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
|
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
|
||||||
|
|
||||||
|
AddEmailSinkIfConfigured(configuration, context.Configuration, serviceName);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,9 +62,40 @@ public static class StartupExtensions
|
|||||||
.Enrich.WithProperty("Service", serviceName)
|
.Enrich.WithProperty("Service", serviceName)
|
||||||
.Enrich.WithProperty("AppVersion", appVersion)
|
.Enrich.WithProperty("AppVersion", appVersion)
|
||||||
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
|
.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter());
|
||||||
|
|
||||||
|
AddEmailSinkIfConfigured(configuration, builder.Configuration, serviceName);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void AddEmailSinkIfConfigured(LoggerConfiguration loggerConfig, IConfiguration appConfig, string serviceName)
|
||||||
|
{
|
||||||
|
var from = appConfig["SerilogEmail:From"];
|
||||||
|
var to = appConfig["SerilogEmail:To"];
|
||||||
|
var host = appConfig["SerilogEmail:Host"];
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to) || string.IsNullOrWhiteSpace(host))
|
||||||
|
return;
|
||||||
|
|
||||||
|
var port = appConfig.GetValue("SerilogEmail:Port", 587);
|
||||||
|
var userName = appConfig["SerilogEmail:UserName"];
|
||||||
|
var password = appConfig["SerilogEmail:Password"];
|
||||||
|
|
||||||
|
NetworkCredential? credentials = null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(userName))
|
||||||
|
credentials = new NetworkCredential(userName, password);
|
||||||
|
|
||||||
|
loggerConfig.WriteTo.Email(
|
||||||
|
from: from,
|
||||||
|
to: to,
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
connectionSecurity: SecureSocketOptions.StartTls,
|
||||||
|
credentials: credentials,
|
||||||
|
subject: $"[myAi {serviceName}] Error Alert",
|
||||||
|
body: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
||||||
|
restrictedToMinimumLevel: LogEventLevel.Error);
|
||||||
|
}
|
||||||
|
|
||||||
public static void AddAzureKeyVaultIfConfigured(this WebApplicationBuilder builder)
|
public static void AddAzureKeyVaultIfConfigured(this WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
var keyVaultUri = builder.Configuration["KeyVault:VaultUri"];
|
var keyVaultUri = builder.Configuration["KeyVault:VaultUri"];
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
"Serilog": {
|
"Serilog": {
|
||||||
"Using": [
|
"Using": [
|
||||||
"Serilog.Sinks.Console",
|
"Serilog.Sinks.Console",
|
||||||
"Serilog.Sinks.File",
|
"Serilog.Sinks.File"
|
||||||
"Serilog.Sinks.Email"
|
|
||||||
],
|
],
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
@@ -31,25 +30,6 @@
|
|||||||
"retainedFileCountLimit": 30,
|
"retainedFileCountLimit": 30,
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Email",
|
|
||||||
"Args": {
|
|
||||||
"restrictedToMinimumLevel": "Error",
|
|
||||||
"fromEmail": "",
|
|
||||||
"toEmail": "",
|
|
||||||
"mailServer": "",
|
|
||||||
"networkCredential": {
|
|
||||||
"userName": "",
|
|
||||||
"password": ""
|
|
||||||
},
|
|
||||||
"port": 587,
|
|
||||||
"enableSsl": true,
|
|
||||||
"emailSubject": "[mihes.ro CV cleanup job] Error Alert",
|
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
|
||||||
"batchPostingLimit": 10,
|
|
||||||
"period": "0.00:05:00"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Enrich": [
|
"Enrich": [
|
||||||
|
|||||||
@@ -29,9 +29,28 @@ COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
|||||||
|
|
||||||
RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
# Download Playwright Chromium browser in the build stage.
|
||||||
|
# Node.js is only needed here to run npx — it is not copied to the final image.
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
|
||||||
|
&& npx --yes playwright@1.60.0 install chromium \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# System libraries required by Chromium on Debian bookworm
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
||||||
|
libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \
|
||||||
|
libgbm1 libasound2t64 libpango-1.0-0 libcairo2 libatspi2.0-0 \
|
||||||
|
libwayland-client0 libx11-xcb1 libx11-6 libxcb1 libxext6 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy the Playwright Chromium browser from the build stage
|
||||||
|
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
|
||||||
|
COPY --from=build /ms-playwright /ms-playwright
|
||||||
|
|
||||||
COPY --from=build /app/publish .
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
ENTRYPOINT ["dotnet", "cv-search-job.dll"]
|
ENTRYPOINT ["dotnet", "cv-search-job.dll"]
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ try
|
|||||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddDbContext<EmailApiDbContext>(options =>
|
builder.Services.AddDbContext<EmailDbContext>(options =>
|
||||||
{
|
{
|
||||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||||
options.UseSqlServer(connectionString, sql =>
|
options.UseSqlServer(connectionString, sql =>
|
||||||
{
|
{
|
||||||
sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName);
|
sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName);
|
||||||
sql.MigrationsAssembly("email-api-data");
|
sql.MigrationsAssembly("email-data");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ public sealed class CvSearchEmailSender
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language)
|
private string BuildBody(IReadOnlyList<JobSearchResultEntity> results, IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language)
|
||||||
{
|
{
|
||||||
var scanSummary = BuildScanSummary(keywords, providerNames);
|
var scanSummary = BuildScanSummary(keywords, providerNames, language);
|
||||||
|
|
||||||
if (results.Count == 0)
|
if (results.Count == 0)
|
||||||
return scanSummary + _emailTemplates.Get("email.search-results.empty", language);
|
return scanSummary + _emailTemplates.Get("email.search-results.empty", language);
|
||||||
@@ -98,18 +98,18 @@ public sealed class CvSearchEmailSender
|
|||||||
for (int i = 0; i < results.Count; i++)
|
for (int i = 0; i < results.Count; i++)
|
||||||
{
|
{
|
||||||
var r = results[i];
|
var r = results[i];
|
||||||
var matchResp = TryParseResult(r.ResultJson);
|
var summary = TryParseResult(r.ResultJson)?.Summary;
|
||||||
var summary = matchResp?.Summary;
|
var summaryHtml = string.IsNullOrWhiteSpace(summary)
|
||||||
|
? ""
|
||||||
|
: $"<p style=\"margin:8px 0 0;color:#495057;font-size:14px;line-height:1.5;\">{summary}</p>";
|
||||||
|
|
||||||
items.Append($"""
|
items.Append(_emailTemplates.Render("email.search-results.item", language,
|
||||||
<div style="border:1px solid #dee2e6;border-radius:6px;padding:16px;margin-bottom:12px">
|
("index", (i + 1).ToString()),
|
||||||
<strong style="color:#212529">{i + 1}. {r.JobTitle}</strong>
|
("jobTitle", r.JobTitle),
|
||||||
<span style="background:#28a745;color:#fff;padding:2px 8px;border-radius:12px;font-size:12px;margin-left:8px">{r.Score}% match</span>
|
("score", r.Score.ToString()),
|
||||||
<span style="color:#6c757d;font-size:12px;margin-left:4px">[{r.ProviderName}]</span><br>
|
("providerName", r.ProviderName),
|
||||||
<a href="{r.JobUrl}" style="color:#2c5282;font-size:13px">{r.JobUrl}</a>
|
("jobUrl", r.JobUrl),
|
||||||
{(string.IsNullOrWhiteSpace(summary) ? "" : $"<p style=\"margin:8px 0 0;color:#495057;font-size:14px;line-height:1.5\">{summary}</p>")}
|
("summary", summaryHtml)));
|
||||||
</div>
|
|
||||||
""");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return _emailTemplates.Render("email.search-results.body", language,
|
return _emailTemplates.Render("email.search-results.body", language,
|
||||||
@@ -118,25 +118,23 @@ public sealed class CvSearchEmailSender
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the scan summary block showing the CV keywords and providers used for the search.
|
/// Renders the scan summary block via template, passing keyword tags and provider list as data.
|
||||||
|
/// Keyword tags are built here because they are variable-count inline elements, not structural HTML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames)
|
private string BuildScanSummary(IReadOnlyList<string> keywords, IReadOnlyList<string> providerNames, string language)
|
||||||
{
|
{
|
||||||
var keywordsHtml = keywords.Count > 0
|
var keywordsHtml = keywords.Count > 0
|
||||||
? string.Join("", keywords.Select(k =>
|
? string.Join(" ", keywords.Select(k =>
|
||||||
$"<span style=\"display:inline-block;background:#e9ecef;border-radius:4px;padding:2px 8px;margin:2px;font-size:12px\">{k}</span>"))
|
$"<span style=\"display:inline-block;background:#e9ecef;padding:2px 8px;margin:2px 2px 2px 0;font-size:12px;\">{k}</span>"))
|
||||||
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic\">none detected</span>";
|
: "<span style=\"color:#6c757d;font-size:12px;font-style:italic;\">none detected</span>";
|
||||||
|
|
||||||
var providersText = providerNames.Count > 0
|
var providers = providerNames.Count > 0
|
||||||
? string.Join(", ", providerNames)
|
? string.Join(", ", providerNames)
|
||||||
: "none";
|
: "none";
|
||||||
|
|
||||||
return $"""
|
return _emailTemplates.Render("email.search-results.scan-summary", language,
|
||||||
<div style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:6px;padding:14px 16px;margin-bottom:18px;font-size:13px;color:#495057">
|
("keywordsHtml", keywordsHtml),
|
||||||
<div style="margin-bottom:8px"><strong>Keywords used:</strong> {keywordsHtml}</div>
|
("providers", providers));
|
||||||
<div><strong>Providers scanned:</strong> {providersText}</div>
|
|
||||||
</div>
|
|
||||||
""";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using CvMatcher.Models.Settings;
|
using CvMatcher.Models.Settings;
|
||||||
|
using Microsoft.Playwright;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace CvSearchJob.Services;
|
namespace CvSearchJob.Services;
|
||||||
@@ -9,6 +10,7 @@ namespace CvSearchJob.Services;
|
|||||||
/// Config-driven HTML scraper that fetches a provider's job listing page and extracts matching job URLs.
|
/// Config-driven HTML scraper that fetches a provider's job listing page and extracts matching job URLs.
|
||||||
/// Uses a two-stage anchor filter: href must contain the provider's link pattern, and anchor text must
|
/// Uses a two-stage anchor filter: href must contain the provider's link pattern, and anchor text must
|
||||||
/// contain at least one CV keyword.
|
/// contain at least one CV keyword.
|
||||||
|
/// Supports both plain HTTP GET (default) and headless Chromium rendering for JS-heavy SPAs.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class HtmlJobSearcher
|
public sealed class HtmlJobSearcher
|
||||||
{
|
{
|
||||||
@@ -28,13 +30,10 @@ public sealed class HtmlJobSearcher
|
|||||||
/// tags, applies the two-stage filter, and returns up to <see cref="JobProviderConfig.MaxResults"/> absolute URLs.
|
/// tags, applies the two-stage filter, and returns up to <see cref="JobProviderConfig.MaxResults"/> absolute URLs.
|
||||||
/// Returns an empty list when the HTTP request fails rather than throwing.
|
/// Returns an empty list when the HTTP request fails rather than throwing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="provider">Provider configuration including search URL template, link filter, and result cap.</param>
|
|
||||||
/// <param name="cvKeywords">Keywords extracted from the user's CV to inject into the search query.</param>
|
|
||||||
/// <param name="ct">Cancellation token.</param>
|
|
||||||
/// <returns>Deduplicated list of absolute job page URLs (query string stripped).</returns>
|
|
||||||
public async Task<IReadOnlyList<string>> SearchJobUrlsAsync(
|
public async Task<IReadOnlyList<string>> SearchJobUrlsAsync(
|
||||||
JobProviderConfig provider,
|
JobProviderConfig provider,
|
||||||
IReadOnlyList<string> cvKeywords,
|
IReadOnlyList<string> cvKeywords,
|
||||||
|
string? location,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var allKeywords = provider.InitialKeywords
|
var allKeywords = provider.InitialKeywords
|
||||||
@@ -50,29 +49,38 @@ public sealed class HtmlJobSearcher
|
|||||||
}
|
}
|
||||||
|
|
||||||
var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords));
|
var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords));
|
||||||
var searchUrl = provider.SearchUrlTemplate.Replace("{keywords}", keywordsEncoded);
|
var locationEncoded = HttpUtility.UrlEncode(location ?? string.Empty);
|
||||||
|
var locationSlug = (location ?? string.Empty)
|
||||||
|
.ToLowerInvariant()
|
||||||
|
.Replace(",", "")
|
||||||
|
.Replace(" ", "-")
|
||||||
|
.Trim('-');
|
||||||
|
var searchUrl = provider.SearchUrlTemplate
|
||||||
|
.Replace("{keywords}", keywordsEncoded)
|
||||||
|
.Replace("{location}", locationEncoded)
|
||||||
|
.Replace("{location-slug}", locationSlug);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Provider {Provider}: fetching {Url} | CV keywords: [{Keywords}]",
|
"Provider {Provider}: fetching {Url} [{Mode}] | CV keywords: [{Keywords}] | Location: {Location}",
|
||||||
provider.Name, searchUrl, string.Join(", ", cvKeywords));
|
provider.Name, searchUrl,
|
||||||
|
provider.UseHeadlessBrowser ? "headless" : "http",
|
||||||
|
string.Join(", ", cvKeywords),
|
||||||
|
location ?? "(none)");
|
||||||
|
|
||||||
|
string? html;
|
||||||
|
if (provider.UseHeadlessBrowser)
|
||||||
|
html = await FetchWithPlaywrightAsync(provider.Name, searchUrl, ct);
|
||||||
|
else
|
||||||
|
html = await FetchWithHttpAsync(provider.Name, searchUrl, ct);
|
||||||
|
|
||||||
|
if (html is null) return [];
|
||||||
|
|
||||||
string html;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
html = await _http.GetStringAsync(searchUrl, ct);
|
|
||||||
_logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length);
|
_logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length);
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Provider {Provider}: HTTP fetch failed for {Url}", provider.Name, searchUrl);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
var baseUri = new Uri(searchUrl);
|
var baseUri = new Uri(searchUrl);
|
||||||
var results = new List<string>();
|
var results = new List<string>();
|
||||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Match all anchor tags capturing href and inner text
|
|
||||||
var anchorPattern = new Regex(@"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>",
|
var anchorPattern = new Regex(@"<a[^>]+href=[""']([^""']+)[""'][^>]*>(.*?)</a>",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
RegexOptions.IgnoreCase | RegexOptions.Singleline);
|
||||||
|
|
||||||
@@ -92,8 +100,8 @@ public sealed class HtmlJobSearcher
|
|||||||
|
|
||||||
stage1Pass++;
|
stage1Pass++;
|
||||||
|
|
||||||
// Stage 2: anchor text must contain at least one CV keyword
|
if (provider.RequireKeywordInAnchor &&
|
||||||
if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase)))
|
!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Provider {Provider}: stage-2 reject | href={Href} | text={Text}",
|
"Provider {Provider}: stage-2 reject | href={Href} | text={Text}",
|
||||||
@@ -103,14 +111,12 @@ public sealed class HtmlJobSearcher
|
|||||||
|
|
||||||
stage2Pass++;
|
stage2Pass++;
|
||||||
|
|
||||||
// Make absolute URL
|
|
||||||
if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri))
|
if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri))
|
||||||
{
|
{
|
||||||
if (!Uri.TryCreate(baseUri, href, out absoluteUri))
|
if (!Uri.TryCreate(baseUri, href, out absoluteUri))
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip query string and fragment so different tracking variants of the same URL collapse to one.
|
|
||||||
var url = absoluteUri.GetLeftPart(UriPartial.Path);
|
var url = absoluteUri.GetLeftPart(UriPartial.Path);
|
||||||
if (seen.Add(url))
|
if (seen.Add(url))
|
||||||
results.Add(url);
|
results.Add(url);
|
||||||
@@ -122,4 +128,61 @@ public sealed class HtmlJobSearcher
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<string?> FetchWithHttpAsync(string providerName, string url, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _http.GetStringAsync(url, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Provider {Provider}: HTTP fetch failed for {Url}", providerName, url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> FetchWithPlaywrightAsync(string providerName, string url, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var playwright = await Playwright.CreateAsync();
|
||||||
|
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||||
|
{
|
||||||
|
Headless = true,
|
||||||
|
Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]
|
||||||
|
});
|
||||||
|
|
||||||
|
var page = await browser.NewPageAsync();
|
||||||
|
|
||||||
|
IResponse? response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await page.GotoAsync(url, new PageGotoOptions
|
||||||
|
{
|
||||||
|
WaitUntil = WaitUntilState.NetworkIdle,
|
||||||
|
Timeout = 30_000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
// NetworkIdle timed out — use whatever content rendered so far
|
||||||
|
_logger.LogWarning("Provider {Provider}: Playwright NetworkIdle timeout for {Url}, using partial content", providerName, url);
|
||||||
|
return await page.ContentAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response is null || response.Status >= 400)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Provider {Provider}: Playwright got HTTP {Status} for {Url}", providerName, response?.Status, url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await page.ContentAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Provider {Provider}: Playwright fetch failed for {Url}", providerName, url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ public sealed class CvSearchJobTask : IJobTask
|
|||||||
|
|
||||||
foreach (var provider in providers)
|
foreach (var provider in providers)
|
||||||
{
|
{
|
||||||
var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, ct);
|
var urls = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, session.Location, ct);
|
||||||
_logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} URLs", session.Id, provider.Name, urls.Count);
|
_logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} URLs", session.Id, provider.Name, urls.Count);
|
||||||
foreach (var url in urls) jobUrls.Add(url);
|
foreach (var url in urls) jobUrls.Add(url);
|
||||||
}
|
}
|
||||||
@@ -204,20 +204,27 @@ public sealed class CvSearchJobTask : IJobTask
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deserialises the provider configuration snapshot stored on the session.
|
/// Deserialises the provider configuration snapshot stored on the session.
|
||||||
/// Falls back to the current live config when the snapshot is absent or unparseable.
|
/// Providers are always snapshotted from the DB at session-creation time, so the snapshot
|
||||||
|
/// should always be present. Returns an empty list (with a warning) when it is missing or corrupt.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private List<JobProviderConfig> GetProviders(string? providerConfigJson)
|
private List<JobProviderConfig> GetProviders(string? providerConfigJson)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(providerConfigJson)) return _settings.Providers.Where(p => p.Enabled).ToList();
|
if (string.IsNullOrWhiteSpace(providerConfigJson))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Session has no provider config snapshot — returning empty provider list");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<List<JobProviderConfig>>(providerConfigJson,
|
return JsonSerializer.Deserialize<List<JobProviderConfig>>(providerConfigJson,
|
||||||
new JsonSerializerOptions(JsonSerializerDefaults.Web))
|
new JsonSerializerOptions(JsonSerializerDefaults.Web))
|
||||||
?? _settings.Providers.Where(p => p.Enabled).ToList();
|
?? [];
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return _settings.Providers.Where(p => p.Enabled).ToList();
|
_logger.LogWarning(ex, "Failed to deserialise provider config snapshot — returning empty provider list");
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,7 @@
|
|||||||
"Serilog": {
|
"Serilog": {
|
||||||
"Using": [
|
"Using": [
|
||||||
"Serilog.Sinks.Console",
|
"Serilog.Sinks.Console",
|
||||||
"Serilog.Sinks.File",
|
"Serilog.Sinks.File"
|
||||||
"Serilog.Sinks.Email"
|
|
||||||
],
|
],
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
@@ -42,25 +41,6 @@
|
|||||||
"retainedFileCountLimit": 30,
|
"retainedFileCountLimit": 30,
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "Email",
|
|
||||||
"Args": {
|
|
||||||
"restrictedToMinimumLevel": "Error",
|
|
||||||
"fromEmail": "",
|
|
||||||
"toEmail": "",
|
|
||||||
"mailServer": "",
|
|
||||||
"networkCredential": {
|
|
||||||
"userName": "",
|
|
||||||
"password": ""
|
|
||||||
},
|
|
||||||
"port": 587,
|
|
||||||
"enableSsl": true,
|
|
||||||
"emailSubject": "[mihes.ro CV search job] Error Alert",
|
|
||||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}",
|
|
||||||
"batchPostingLimit": 10,
|
|
||||||
"period": "0.00:05:00"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"Enrich": [
|
"Enrich": [
|
||||||
@@ -103,33 +83,7 @@
|
|||||||
"JobSearchLinkBaseUrl": "https://myai.ro",
|
"JobSearchLinkBaseUrl": "https://myai.ro",
|
||||||
"TokenExpiryDays": 7,
|
"TokenExpiryDays": 7,
|
||||||
"MinMatchScore": 15,
|
"MinMatchScore": 15,
|
||||||
"MaxJobsToMatch": 15,
|
"MaxJobsToMatch": 15
|
||||||
"Providers": [
|
|
||||||
{
|
|
||||||
"Name": "ejobs.ro",
|
|
||||||
"Enabled": false,
|
|
||||||
"SearchUrlTemplate": "https://www.ejobs.ro/locuri-de-munca/{keywords}/",
|
|
||||||
"JobLinkContains": "/user/locuri-de-munca/job/",
|
|
||||||
"InitialKeywords": [],
|
|
||||||
"MaxResults": 20
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "bestjobs.eu",
|
|
||||||
"Enabled": false,
|
|
||||||
"SearchUrlTemplate": "https://www.bestjobs.eu/ro/locuri-de-munca?q={keywords}",
|
|
||||||
"JobLinkContains": "/ro/locuri-de-munca/",
|
|
||||||
"InitialKeywords": [],
|
|
||||||
"MaxResults": 20
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Name": "linkedin.com",
|
|
||||||
"Enabled": false,
|
|
||||||
"SearchUrlTemplate": "https://www.linkedin.com/jobs/search/?keywords={keywords}&location=Romania",
|
|
||||||
"JobLinkContains": "/jobs/view/",
|
|
||||||
"InitialKeywords": [],
|
|
||||||
"MaxResults": 20
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"Jobs": {
|
"Jobs": {
|
||||||
"Tasks": [
|
"Tasks": [
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||||
<PackageReference Include="Refit.HttpClientFactory" />
|
<PackageReference Include="Refit.HttpClientFactory" />
|
||||||
|
<PackageReference Include="Microsoft.Playwright" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -147,3 +147,6 @@ RateLimiting__Policies__contact__QueueLimit=0
|
|||||||
RateLimiting__Policies__cvMatcher__PermitLimit=10
|
RateLimiting__Policies__cvMatcher__PermitLimit=10
|
||||||
RateLimiting__Policies__cvMatcher__Window=00:10:00
|
RateLimiting__Policies__cvMatcher__Window=00:10:00
|
||||||
RateLimiting__Policies__cvMatcher__QueueLimit=0
|
RateLimiting__Policies__cvMatcher__QueueLimit=0
|
||||||
|
RateLimiting__Policies__download__PermitLimit=5
|
||||||
|
RateLimiting__Policies__download__Window=00:01:00
|
||||||
|
RateLimiting__Policies__download__QueueLimit=0
|
||||||
|
|||||||
@@ -35,13 +35,12 @@ services:
|
|||||||
- Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text}
|
- Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text}
|
||||||
- Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180}
|
- Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180}
|
||||||
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${LOGS_PATH:-/opt/myai/logs}/rag-api:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/rag-api:/app/logs
|
||||||
networks:
|
networks:
|
||||||
@@ -85,13 +84,12 @@ services:
|
|||||||
- Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5}
|
- Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5}
|
||||||
- Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000}
|
- Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000}
|
||||||
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${LOGS_PATH:-/opt/myai/logs}/cv-matcher-api:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/cv-matcher-api:/app/logs
|
||||||
networks:
|
networks:
|
||||||
@@ -126,13 +124,12 @@ services:
|
|||||||
|
|
||||||
- FileStorage__Path=${FileStorage__Path:-Files}
|
- FileStorage__Path=${FileStorage__Path:-Files}
|
||||||
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${LOGS_PATH:-/opt/myai/logs}/email-api:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/email-api:/app/logs
|
||||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||||
@@ -194,17 +191,19 @@ services:
|
|||||||
- RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10}
|
- RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10}
|
||||||
- RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00}
|
- RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00}
|
||||||
- RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0}
|
- RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0}
|
||||||
|
- RateLimiting__Policies__download__PermitLimit=${RateLimiting__Policies__download__PermitLimit:-5}
|
||||||
|
- RateLimiting__Policies__download__Window=${RateLimiting__Policies__download__Window:-00:01:00}
|
||||||
|
- RateLimiting__Policies__download__QueueLimit=${RateLimiting__Policies__download__QueueLimit:-0}
|
||||||
|
|
||||||
- Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-}
|
- Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-}
|
||||||
- Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-}
|
- Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-}
|
||||||
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${LOGS_PATH:-/opt/myai/logs}/api:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/api:/app/logs
|
||||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||||
@@ -229,13 +228,12 @@ services:
|
|||||||
- Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00}
|
- Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00}
|
||||||
- Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40}
|
- Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40}
|
||||||
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${LOGS_PATH:-/opt/myai/logs}/cv-cleanup-job:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/cv-cleanup-job:/app/logs
|
||||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||||
@@ -280,13 +278,12 @@ services:
|
|||||||
- Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true}
|
- Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true}
|
||||||
- Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30}
|
- Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30}
|
||||||
|
|
||||||
- Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-}
|
- SerilogEmail__From=${SerilogEmail__From:-}
|
||||||
- Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-}
|
- SerilogEmail__To=${SerilogEmail__To:-}
|
||||||
- Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-}
|
- SerilogEmail__Host=${SerilogEmail__Host:-}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-}
|
- SerilogEmail__Port=${SerilogEmail__Port:-587}
|
||||||
- Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-}
|
- SerilogEmail__UserName=${SerilogEmail__UserName:-}
|
||||||
- Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587}
|
- SerilogEmail__Password=${SerilogEmail__Password:-}
|
||||||
- Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ${LOGS_PATH:-/opt/myai/logs}/cv-search-job:/app/logs
|
- ${LOGS_PATH:-/opt/myai/logs}/cv-search-job:/app/logs
|
||||||
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
- ${FILES_PATH:-/opt/myai/files}:/app/Files
|
||||||
|
|||||||
Reference in New Issue
Block a user