feat: DB-backed localized templates + language-aware emails

- New Apis/myai-models project: MyAiDbContext (schema myAi), TemplateEntity,
  ITemplateService, DbTemplateService with 10-min in-memory cache
- Seeds EN+RO variants for all user-facing templates (match email, job search
  results email, HTML status pages, AI system prompt)
- Match result email now sent in user's UI language (en/ro)
- Job search results email now respects session language
- Language propagates: MatchJobRequest -> token -> session -> email
- Add Language column to JobSearchTokens and JobSearchSessions (default 'en')
- All three Dockerfiles updated to include myai-models in build context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 18:06:44 +03:00
parent 2cada13fe3
commit fc6fe7a78b
34 changed files with 927 additions and 98 deletions
+18 -8
View File
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Options;
using Swashbuckle.AspNetCore.Annotations;
using Shared.Models.Responses;
using MyAi.Models.Services;
namespace Api.Controllers;
@@ -27,6 +28,7 @@ public sealed class CvMatcherController : ControllerBase
private readonly FileStorageSettings _fileStorageSettings;
private readonly JobSearchLinkSettings _jobSearchLinkSettings;
private readonly IEmailSender _emailSender;
private readonly ITemplateService _templates;
private readonly ILogger<CvMatcherController> _logger;
public CvMatcherController(
@@ -36,6 +38,7 @@ public sealed class CvMatcherController : ControllerBase
IOptions<FileStorageSettings> fileStorageSettings,
IOptions<JobSearchLinkSettings> jobSearchLinkSettings,
IEmailSender emailSender,
ITemplateService templates,
ILogger<CvMatcherController> logger)
{
_cvApi = cvApi;
@@ -44,6 +47,7 @@ public sealed class CvMatcherController : ControllerBase
_fileStorageSettings = fileStorageSettings.Value;
_jobSearchLinkSettings = jobSearchLinkSettings.Value;
_emailSender = emailSender;
_templates = templates;
_logger = logger;
}
@@ -160,13 +164,15 @@ public sealed class CvMatcherController : ControllerBase
? request.JobUrl
: "Manual job description";
var language = NormalizeLanguage(request.Language);
string? jobSearchLink = null;
if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId))
{
try
{
var tokenResp = await _jobSearchApi.CreateTokenAsync(
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email },
new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language },
ct);
var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/');
jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}";
@@ -179,8 +185,8 @@ public sealed class CvMatcherController : ControllerBase
await _emailSender.SendMatchAsync(
request.Email,
SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel),
SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, jobSearchLink),
_emailSender.BuildMatchEmailSubject(res.Score, jobLabel, language),
_emailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, language, jobSearchLink),
attachmentPath,
ct);
@@ -217,23 +223,24 @@ public sealed class CvMatcherController : ControllerBase
try
{
var result = await _jobSearchApi.StartSearchAsync(t, ct);
var lang = "en";
var html = result.Status switch
{
StartJobSearchStatus.Started =>
HtmlPage("Job search started", "Your job search has started. Results will be sent to your email shortly."),
HtmlPage(_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)),
StartJobSearchStatus.AlreadyUsed =>
HtmlPage("Link already used", "This job search link has already been used."),
HtmlPage(_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)),
StartJobSearchStatus.Expired =>
HtmlPage("Link expired", "This job search link has expired. Please request a new CV match to get a fresh link."),
HtmlPage(_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)),
_ =>
HtmlPage("Invalid link", "This job search link is not valid.")
HtmlPage(_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang))
};
return Content(html, "text/html");
}
catch (Exception ex)
{
_logger.LogError(ex, "Job search start failed for token {Token}.", t);
return Content(HtmlPage("Error", "An error occurred. Please try again later."), "text/html");
return Content(HtmlPage(_templates.Get("html.job-search.error.title", "en"), _templates.Get("html.job-search.error.message", "en")), "text/html");
}
}
@@ -288,6 +295,9 @@ public sealed class CvMatcherController : ControllerBase
return Path.Combine(GetFileStoragePath(), $"{safeId}.pdf");
}
private static string NormalizeLanguage(string? language) =>
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
private string GetFileStoragePath()
{
var fileStoragePath = _fileStorageSettings.Path;