17 Commits

Author SHA1 Message Date
claude 99e5cfb76b Fix job search: location filtering, keyword quality, anchor filter bypass
Closes #41

- Add RequireKeywordInAnchor per-provider flag (default true); set false for
  ejobs.ro and bestjobs.eu so Stage 2 anchor-text filter is skipped — their
  search URL already filters by relevance server-side
- Update AI system prompts (en + ro) to extract concise job-board-friendly
  keywords (role title + key tech, not abstract concepts) and candidate location
- Propagate location through JobMatchResponse -> CreateJobSearchTokenRequest ->
  JobSearchTokenEntity -> JobSearchSessionEntity
- Add {location} and {location-slug} substitution in HtmlJobSearcher
- Update provider SearchUrlTemplates to include location:
    ejobs.ro:    /locuri-de-munca/{location-slug}?q={keywords}
    bestjobs.eu: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords}
    linkedin.com: ?keywords={keywords}&location={location}
- Three new migrations: AddRequireKeywordInAnchorAndLocation,
  ImproveKeywordsAndAddLocation, AddLocationToProviders

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 15:45:45 +03:00
claude 91b2baa445 Fix email-api middleware order: API key check before swagger
Build and Push Docker Images Staging / build (push) Successful in 17m14s
UseInternalApiKeyProtection was registered after UseSwaggerInDevelopment,
allowing unauthenticated access to /swagger. Swapped order to match
rag-api and cv-matcher-api.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:41:29 +03:00
claude 0f64cb8d99 Fix email-api Dockerfile: add missing shared-data COPY
email-data references shared-data but the email-api Dockerfile never
copied it into the build context, causing MSB9008 during Docker build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:30:25 +03:00
claude b67e926c5f Fix Serilog email sink: configure in code, not JSON config
Serilog.Settings.Configuration cannot deserialize NetworkCredential or
MailKit's SecureSocketOptions from JSON, causing an InvalidOperationException
in the binder and preventing containers from starting.

Fix: remove Email from the WriteTo JSON array entirely and wire it in code
inside ConfigureJsonSerilog using a dedicated SerilogEmail:* config section.
The sink is skipped when From/To/Host are absent, so local dev is unaffected.

Also renames the docker-compose env vars from the verbose
Serilog__WriteTo__2__Args__* prefix to the clean SerilogEmail__* prefix.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:25:26 +03:00
claude f7d856147e Escalate provider fetch failures to Error for alert emails
HTTP and Playwright fetch failures in HtmlJobSearcher are now logged at
Error so that Serilog's email sink triggers an alert when a job provider
is unreachable. Per-URL match failures remain at Warning (expected noise).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 22:03:46 +03:00
claude 8679bd1efd Fix Serilog email sink config for v4 API breaking changes
Serilog.Sinks.Email v4 renamed all configuration parameters from their
v2 names. The old names were silently ignored, so no error alert emails
were ever sent.

Parameter renames applied across all 6 appsettings.json and docker-compose:
  fromEmail → from
  toEmail   → to
  mailServer → host
  networkCredential → credentials
  enableSsl: true → connectionSecurity: StartTls
  emailSubject → subject
  outputTemplate → body
  batchPostingLimit / period removed (v4 batching uses a separate overload)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 21:57:06 +03:00
claude 1bcf95d8d4 Add download rate limit policy to template and docker-compose
Build and Push Docker Images Staging / build (push) Failing after 1m38s
5 requests / 1 min per IP. docker-compose.yml wired with ${VAR:-default}.
Staging and production .env files updated locally (gitignored).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:40:25 +03:00
claude 73f67d1342 Protect FileDownloadController with reCAPTCHA v3 and rate limiting
- Require captchaToken query param on initial (non-range) download requests
- Range requests (HTTP resume) bypass captcha — they are continuations of an already-validated download
- Add download rate limit policy: 5 requests / 1 min per IP (configured in .env)
- Inject ICaptchaVerifier; action name is file_download

UI change required: execute grecaptcha.execute(siteKey, {action: 'file_download'})
before triggering the download and append ?captchaToken=<token> to the URL.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:37:44 +03:00
claude 650505c08d Merge pull request 'Fix Outlook email layout and move all HTML/prompts out of code' (#38) from main into staging
Build and Push Docker Images Staging / build (push) Successful in 29m52s
Merge main into staging
2026-06-01 17:30:18 +00:00
claude 4066ab5f3f Remove duplicate html.job-search.* rows from email.Templates
These templates belong to the myAi schema (myai-data) and are read by
CvMatcherController via ITemplateService. The email-data copies were
never read by any code — removing them to avoid confusion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:22:29 +03:00
claude 7a316b4a45 Move hardcoded HtmlPage shell into html.job-search.shell DB template
The job-search status page HTML wrapper was baked into a static helper
method in CvMatcherController. Extracted to a new template key
html.job-search.shell (*) with {{title}} and {{message}} placeholders.
Added to AddTemplates seed and a new AddHtmlJobSearchShell migration
for existing DBs. Controller now calls _templates.Render() for all paths.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:17:58 +03:00
claude 808a4901d9 Add keywords field to AI CV-match system prompt
The LLM JSON shape was missing the keywords array so res.Keywords was
always empty, causing "none detected" in job search emails. Both en/ro
prompts now include "keywords" in the required JSON shape so the LLM
extracts relevant job-search terms from the CV/job pair.

Note: the cvMatcher.CvMatchResults cache must be cleared on existing DBs
so cached responses (which lack keywords) are not served.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:13:00 +03:00
claude b5b654532c Fix HTML shell templates to use table-based layout (Outlook-safe)
Replace div/CSS-class approach with nested table layout so the 600px
container is enforced via HTML attributes, not a <style> block that
Outlook strips. Also removes border-radius and display:inline-block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:06:55 +03:00
claude 2838885e22 Fix email templates for Outlook compatibility and move HTML out of code
- Replace div-based layouts with table-based HTML throughout (max-width/border-radius/display:inline-block ignored by Outlook)
- email.match.body: width:100% table with per-cell borders and fixed 130px label column
- email.match.job-search-footer: table-based button with bgcolor attribute
- email.search-results.empty: div replaced with full-width table
- email.search-results.body: remove div wrapper around items
- Add email.search-results.scan-summary and email.search-results.item templates
- CvSearchEmailSender: remove all hardcoded HTML; render via IEmailTemplateService

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:01:58 +03:00
claude 8f90a4cfda Reduce email match table width to 500px max-width, centered
- Changed table width from 100% to max-width: 500px with margin: 0 auto
- Applies to both English and Romanian email.match.body templates
- Table now narrower and centered in email

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 19:18:26 +03:00
claude 978dd3a069 Update email templates to HTML format and fix EmailApiEmailSender
- Convert email.match.body, email.match.job-search-footer, email.search-results.body, and email.search-results.empty templates from plain text to proper HTML format in InitialSchema migration
- Update EmailApiEmailSender.BuildMatchEmailBody() to work with HTML templates instead of plain text
- Add WebUtility.HtmlEncode() for security when inserting dynamic content (summary)
- Templates now use semantic HTML tags (table, h2, h3, ul, li, p, div, hr, a) instead of plain text with newlines
- All 32 email template variants (16 keys × 2 languages) and 8 html.job-search.* templates seeded via migration

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 19:16:02 +03:00
claude f9530b168f Restore AddHtmlShellTemplates migration with copyright symbol fix
- Restored email.html-shell.start and email.html-shell.end templates to InitialSchema migration
- Fixed copyright symbol: changed © to &copy; HTML entity (avoids encoding issues in database)
- These templates wrap plain text email bodies in proper HTML structure
- Migration runs after InitialSchema, seeding the HTML wrapper templates

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-06-01 18:37:26 +03:00
39 changed files with 1281 additions and 319 deletions
+10 -24
View File
@@ -181,7 +181,7 @@ public sealed class CvMatcherController : ControllerBase
try try
{ {
var tokenResp = await _jobSearchApi.CreateTokenAsync( var tokenResp = await _jobSearchApi.CreateTokenAsync(
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords }, new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location },
ct); ct);
if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) if (!string.IsNullOrWhiteSpace(tokenResp.TokenId))
{ {
@@ -246,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
+33 -7
View File
@@ -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
+12 -7
View File
@@ -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,
+1 -21
View File
@@ -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": [
@@ -6,4 +6,5 @@ public sealed class CreateJobSearchTokenRequest
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 List<string> Keywords { get; set; } = [];
public string? Location { get; set; }
} }
@@ -9,6 +9,7 @@
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 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; }
@@ -23,4 +23,9 @@ public sealed class JobProviderConfig
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> /// <summary>When true the scraper uses a headless Chromium browser to render JS-heavy pages.</summary>
public bool UseHeadlessBrowser { get; set; } 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, request.Keywords, 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)
@@ -13,12 +13,13 @@ public interface IJobTokenService
/// <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="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> /// <returns>
/// The generated token ID to embed in the one-click job search link, /// 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). /// or <c>null</c> when no job providers are currently enabled (link should be suppressed).
/// </returns> /// </returns>
Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, CancellationToken ct); 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.
@@ -34,7 +34,7 @@ public sealed class JobTokenService : IJobTokenService
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<string?> CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList<string> keywords, 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); var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct);
if (!hasEnabledProviders) if (!hasEnabledProviders)
@@ -50,6 +50,7 @@ public sealed class JobTokenService : IJobTokenService
Email = email, Email = email,
Language = language, Language = language,
Keywords = string.Join(",", keywords), 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
@@ -57,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}, Keywords={Keywords}", token.Id, cvDocumentId, token.Keywords); _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;
} }
@@ -92,6 +93,7 @@ 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
}; };
@@ -126,7 +128,8 @@ public sealed class JobTokenService : IJobTokenService
JobLinkContains = entity.JobLinkContains, JobLinkContains = entity.JobLinkContains,
InitialKeywords = keywords, InitialKeywords = keywords,
MaxResults = entity.MaxResults, MaxResults = entity.MaxResults,
UseHeadlessBrowser = entity.UseHeadlessBrowser UseHeadlessBrowser = entity.UseHeadlessBrowser,
RequireKeywordInAnchor = entity.RequireKeywordInAnchor
}; };
} }
+1 -21
View File
@@ -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": [
@@ -82,12 +82,12 @@ namespace CvMatcher.Data.Migrations
// AI system prompt for CV matching — English // AI system prompt for CV matching — English
Row("ai.cv-match.system-prompt", "en", 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\",\"strength 2 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"]}", "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."); "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 // AI system prompt for CV matching — Romanian
Row("ai.cv-match.system-prompt", "ro", 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ă\",\"punct forte 2 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"]}", "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."); "System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job.");
} }
@@ -0,0 +1,130 @@
// <auto-generated />
using System;
using CvMatcher.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvMatcher.Data.Migrations
{
[DbContext(typeof(CvMatcherDbContext))]
[Migration("20260608124331_ImproveKeywordsAndAddLocation")]
partial class ImproveKeywordsAndAddLocation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvMatcher")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("Language")
.HasMaxLength(8)
.HasColumnType("nvarchar(8)");
b.Property<string>("Description")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)")
.HasDefaultValue("");
b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language");
b.ToTable("AiPrompts", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("JobDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Language")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CvDocumentId", "JobDocumentId", "Language")
.IsUnique();
b.ToTable("Results", "cvMatcher");
});
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
{
b.Property<string>("CacheKey")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("ResponseText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<decimal>("Temperature")
.HasColumnType("decimal(4,2)");
b.HasKey("CacheKey");
b.ToTable("ChatCache", "cvMatcher");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvMatcher.Data.Migrations
{
/// <inheritdoc />
public partial class ImproveKeywordsAndAddLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Update English prompt: tighter keywords instruction (job-board search terms, not abstract
// concepts) and add location field so the LLM extracts the candidate's city/country.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\nFor 'keywords': extract 2-4 short, concrete terms a recruiter would search for on a job board — the candidate's primary role title and key technologies (e.g. 'Senior .NET Developer', 'C#', 'Azure'). Avoid abstract concepts like 'leadership', 'cloud', or 'microservices'.\nFor 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.",
"System prompt for CV-to-job matching in English. Extracts job-board-friendly keywords (role title + key tech) and candidate location."
]);
// Update Romanian prompt: same improvements.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\nPentru 'keywords': extrage 2-4 termeni scurți și concreți pe care un recrutor i-ar căuta pe un site de joburi — titlul principal al rolului și tehnologiile cheie (ex. 'Senior .NET Developer', 'C#', 'Azure'). Evită concepte abstracte precum 'leadership', 'cloud' sau 'microservicii'.\nPentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.",
"System prompt pentru potrivire CV-job în limba română. Extrage cuvinte cheie prietenoase pentru site-uri de joburi (titlu rol + tehnologii cheie) și locația candidatului."
]);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "en"],
columns: ["Value", "Description"],
values: [
"You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}",
"System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job."
]);
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "AiPrompts",
keyColumns: ["Key", "Language"],
keyValues: ["ai.cv-match.system-prompt", "ro"],
columns: ["Value", "Description"],
values: [
"Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}",
"System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job."
]);
}
}
}
@@ -33,4 +33,10 @@ public sealed class JobProviderEntity
/// <summary>When true, the scraper renders the page with headless Chromium instead of a plain HTTP GET.</summary> /// <summary>When true, the scraper renders the page with headless Chromium instead of a plain HTTP GET.</summary>
public bool UseHeadlessBrowser { get; set; } 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";
} }
@@ -10,4 +10,5 @@ public sealed class JobSearchTokenEntity : BaseEntity
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 Keywords { get; set; } = string.Empty;
public string? Location { get; set; }
} }
@@ -0,0 +1,243 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260608124304_AddRequireKeywordInAnchorAndLocation")]
partial class AddRequireKeywordInAnchorAndLocation
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<bool>("UseHeadlessBrowser")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,74 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddRequireKeywordInAnchorAndLocation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Location",
schema: "cvSearch",
table: "JobSearchTokens",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Location",
schema: "cvSearch",
table: "JobSearchSessions",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "RequireKeywordInAnchor",
schema: "cvSearch",
table: "JobProviders",
type: "bit",
nullable: false,
defaultValue: true);
// ejobs.ro (Id=1) and bestjobs.eu (Id=2) do server-side keyword filtering via their
// search URL — the Stage 2 anchor-text filter rejects all Romanian job titles because
// they rarely contain abstract LLM keywords.
migrationBuilder.UpdateData(
schema: "cvSearch",
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
column: "RequireKeywordInAnchor",
value: false);
migrationBuilder.UpdateData(
schema: "cvSearch",
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "RequireKeywordInAnchor",
value: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Location",
schema: "cvSearch",
table: "JobSearchTokens");
migrationBuilder.DropColumn(
name: "Location",
schema: "cvSearch",
table: "JobSearchSessions");
migrationBuilder.DropColumn(
name: "RequireKeywordInAnchor",
schema: "cvSearch",
table: "JobProviders");
}
}
}
@@ -0,0 +1,243 @@
// <auto-generated />
using System;
using CvSearch.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace CvSearch.Data.Migrations
{
[DbContext(typeof(CvSearchDbContext))]
[Migration("20260608124452_AddLocationToProviders")]
partial class AddLocationToProviders
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("cvSearch")
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("DisplayOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<bool>("Enabled")
.HasColumnType("bit");
b.Property<string>("InitialKeywordsJson")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)")
.HasDefaultValue("[]");
b.Property<string>("JobLinkContains")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<int>("MaxResults")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(20);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("nvarchar(1024)");
b.Property<bool>("UseHeadlessBrowser")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobProviders", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("JobText")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("JobTitle")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("nvarchar(512)");
b.Property<string>("JobUrl")
.IsRequired()
.HasMaxLength(2048)
.HasColumnType("nvarchar(2048)");
b.Property<string>("ProviderName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("nvarchar(128)");
b.Property<string>("ResultJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("Score")
.HasColumnType("int");
b.Property<string>("SessionId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("SessionId");
b.ToTable("JobSearchResults", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("Keywords")
.IsRequired()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProviderConfigJson")
.HasColumnType("nvarchar(max)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("TokenId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.HasKey("Id");
b.HasIndex("Status");
b.ToTable("JobSearchSessions", "cvSearch");
});
modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b =>
{
b.Property<string>("Id")
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<DateTime>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("CvDocumentId")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("Keywords")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)")
.HasDefaultValue("");
b.Property<string>("Language")
.IsRequired()
.ValueGeneratedOnAdd()
.HasMaxLength(8)
.HasColumnType("nvarchar(8)")
.HasDefaultValue("en");
b.Property<string>("Location")
.HasColumnType("nvarchar(max)");
b.Property<bool>("Used")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.HasKey("Id");
b.ToTable("JobSearchTokens", "cvSearch");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,71 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace CvSearch.Data.Migrations
{
/// <inheritdoc />
public partial class AddLocationToProviders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// ejobs.ro (Id=1): location in URL path as slug, keywords via q= param.
// Verified URL structure: /locuri-de-munca/{location-slug}?q={keywords}
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
column: "SearchUrlTemplate",
value: "https://www.ejobs.ro/locuri-de-munca/{location-slug}?q={keywords}");
// bestjobs.eu (Id=2): location in URL path as slug, keywords via query param.
// Verified URL structure: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords}
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "SearchUrlTemplate",
value: "https://bestjobs.eu/ro/locuri-de-munca-in-{location-slug}?keywords={keywords}");
// linkedin.com (Id=3): location as query parameter.
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 3,
column: "SearchUrlTemplate",
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}&location={location}");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 1,
column: "SearchUrlTemplate",
value: "https://www.ejobs.ro/locuri-de-munca?q={keywords}");
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 2,
column: "SearchUrlTemplate",
value: "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}");
migrationBuilder.UpdateData(
schema: MigrationConstants.SchemaName,
table: "JobProviders",
keyColumn: "Id",
keyValue: 3,
column: "SearchUrlTemplate",
value: "https://www.linkedin.com/jobs/search/?keywords={keywords}");
}
}
}
@@ -61,6 +61,9 @@ namespace CvSearch.Data.Migrations
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
b.Property<bool>("RequireKeywordInAnchor")
.HasColumnType("bit");
b.Property<string>("SearchUrlTemplate") b.Property<string>("SearchUrlTemplate")
.IsRequired() .IsRequired()
.HasMaxLength(1024) .HasMaxLength(1024)
@@ -158,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)");
@@ -216,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")
+2
View File
@@ -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/
+1 -2
View File
@@ -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();
+1 -21
View File
@@ -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": [
@@ -44,59 +44,180 @@ namespace Email.Data.Migrations
Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email"); 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"); Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV");
// Match result email — body // Match result email — body (HTML formatted)
Row("email.match.body", "en", 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}}", @"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">CV Match Report</h2>
"Body for the CV match result email"); <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", 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}}", @"<h2 style=""color: #2c5282; margin-bottom: 20px; border-bottom: 3px solid #2c5282; padding-bottom: 10px;"">Report Potrivire CV</h2>
"Corpul emailului pentru rezultatul potrivirii CV"); <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 // Match result email — job search CTA footer (HTML formatted)
Row("email.match.job-search-footer", "en", 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)", @"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
"Job search CTA appended to match result email"); <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", 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)", @"<hr style=""border: none; border-top: 1px solid #ddd; margin: 30px 0;"">
"CTA cautare joburi adaugat la emailul de potrivire CV"); <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 // 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", "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"); 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) // Job search results email — body preamble (items appended in code) - HTML formatted
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", "en",
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"); @"<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 — no results found // Job search results email — scan summary block (keywords + providers used)
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.scan-summary", "en",
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"); @"<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>&nbsp;{{keywordsHtml}}</div>
<div><strong>Providers scanned:</strong>&nbsp;{{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>&nbsp;{{keywordsHtml}}</div>
<div><strong>Furnizori scanați:</strong>&nbsp;{{providers}}</div>
</td>
</tr>
</table>",
"Bloc rezumat scanare adaugat la emailul de rezultate cautare joburi (format HTML)");
// HTML job-search start page messages // Job search results email — single job result item card
Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page"); Row("email.search-results.item", "en",
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"); @"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin-bottom: 12px; border: 1px solid #dee2e6; border-collapse: collapse;"">
Row("html.job-search.started.title", "ro", "Căutare joburi pornită", "Titlu pagina cautare joburi pornita"); <tr>
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"); <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)");
Row("html.job-search.already-used.title", "en", "Link already used", "Title for already-used page"); // Job search results email — no results found - HTML formatted
Row("html.job-search.already-used.message", "en", "This job search link has already been used.", "Message for already-used page"); Row("email.search-results.empty", "en",
Row("html.job-search.already-used.title", "ro", "Link deja folosit", "Titlu pagina link deja folosit"); @"<table width=""100%"" cellpadding=""0"" cellspacing=""0"" border=""0"" style=""width: 100%; margin: 20px 0;"">
Row("html.job-search.already-used.message", "ro", "Acest link de cautare joburi a fost deja folosit.", "Mesaj pagina link deja folosit"); <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)");
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");
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -1,4 +1,4 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using Email.Data; using Email.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -18,7 +18,7 @@ namespace Email.Data.Migrations
/// <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("email") .HasDefaultSchema("email")
.HasAnnotation("ProductVersion", "10.0.7") .HasAnnotation("ProductVersion", "10.0.7")
@@ -27,43 +27,44 @@ namespace Email.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b =>
{ {
b.Property<string>("Key") b.Property<string>("Key")
.HasMaxLength(128) .HasMaxLength(128)
.HasColumnType("nvarchar(128)"); .HasColumnType("nvarchar(128)");
b.Property<string>("Language") b.Property<string>("Language")
.HasMaxLength(8) .HasMaxLength(8)
.HasColumnType("nvarchar(8)"); .HasColumnType("nvarchar(8)");
b.Property<string>("Description") b.Property<string>("Description")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("nvarchar(500)") .HasColumnType("nvarchar(500)")
.HasDefaultValue(""); .HasDefaultValue("");
b.Property<string>("OperatorCopy") b.Property<string>("OperatorCopy")
.IsRequired() .IsRequired()
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasMaxLength(256) .HasMaxLength(256)
.HasColumnType("nvarchar(256)") .HasColumnType("nvarchar(256)")
.HasDefaultValue(""); .HasDefaultValue("");
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("datetime2") .HasColumnType("datetime2")
.HasDefaultValueSql("SYSUTCDATETIME()"); .HasDefaultValueSql("SYSUTCDATETIME()");
b.Property<string>("Value") b.Property<string>("Value")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.HasKey("Key", "Language"); b.HasKey("Key", "Language");
b.ToTable("Templates", "email"); b.ToTable("Templates", "email");
}); });
#pragma warning restore 612, 618
#pragma warning restore 612, 618
} }
} }
} }
@@ -1,4 +1,4 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
using Email.Data; using Email.Data;
#nullable disable #nullable disable
@@ -15,14 +15,14 @@ namespace Email.Data.Migrations
migrationBuilder.InsertData( migrationBuilder.InsertData(
table: "Templates", table: "Templates",
columns: new[] { "Key", "Language", "Value", "Description" }, 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 <style>\n body { font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif; margin: 0; padding: 0; background-color: #f5f5f5; }\n .email-container { max-width: 600px; margin: 0 auto; background-color: #ffffff; }\n .email-header { background-color: #2c5282; color: white; padding: 24px; text-align: center; }\n .email-header h1 { margin: 0; font-size: 24px; font-weight: 600; }\n .email-body { padding: 24px; }\n .email-footer { background-color: #f8f9fa; padding: 16px; text-align: center; color: #6c757d; font-size: 12px; border-top: 1px solid #dee2e6; }\n </style>\n</head>\n<body>\n <div class=\"email-container\">\n <div class=\"email-header\">\n <h1>MyAi.ro</h1>\n </div>\n <div class=\"email-body\">\n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, 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); schema: MigrationConstants.SchemaName);
// HTML email shell — closing tags (footer) // HTML email shell — closing tags (footer)
migrationBuilder.InsertData( migrationBuilder.InsertData(
table: "Templates", table: "Templates",
columns: new[] { "Key", "Language", "Value", "Description" }, columns: new[] { "Key", "Language", "Value", "Description" },
values: new object[] { "email.html-shell.end", "*", " </div>\n <div class=\"email-footer\">\n <p>© 2026 MyAi.ro. All rights reserved.</p>\n </div>\n </div>\n</body>\n</html>\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, 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;\">&copy; 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); schema: MigrationConstants.SchemaName);
} }
@@ -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 -21
View File
@@ -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": [
@@ -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"];
+1 -21
View File
@@ -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": [
@@ -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 2px 2px 0;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>&nbsp;{keywordsHtml}</div> ("providers", providers));
<div><strong>Providers scanned:</strong>&nbsp;{providersText}</div>
</div>
""";
} }
/// <summary> /// <summary>
+18 -6
View File
@@ -33,6 +33,7 @@ public sealed class HtmlJobSearcher
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
@@ -48,13 +49,23 @@ 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} [{Mode}] | CV keywords: [{Keywords}]", "Provider {Provider}: fetching {Url} [{Mode}] | CV keywords: [{Keywords}] | Location: {Location}",
provider.Name, searchUrl, provider.Name, searchUrl,
provider.UseHeadlessBrowser ? "headless" : "http", provider.UseHeadlessBrowser ? "headless" : "http",
string.Join(", ", cvKeywords)); string.Join(", ", cvKeywords),
location ?? "(none)");
string? html; string? html;
if (provider.UseHeadlessBrowser) if (provider.UseHeadlessBrowser)
@@ -89,7 +100,8 @@ public sealed class HtmlJobSearcher
stage1Pass++; stage1Pass++;
if (!cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase))) if (provider.RequireKeywordInAnchor &&
!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}",
@@ -125,7 +137,7 @@ public sealed class HtmlJobSearcher
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Provider {Provider}: HTTP fetch failed for {Url}", providerName, url); _logger.LogError(ex, "Provider {Provider}: HTTP fetch failed for {Url}", providerName, url);
return null; return null;
} }
} }
@@ -169,7 +181,7 @@ public sealed class HtmlJobSearcher
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Provider {Provider}: Playwright fetch failed for {Url}", providerName, url); _logger.LogError(ex, "Provider {Provider}: Playwright fetch failed for {Url}", providerName, url);
return null; return null;
} }
} }
+1 -1
View File
@@ -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);
} }
+1 -21
View File
@@ -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": [
+3
View File
@@ -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
+39 -42
View File
@@ -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