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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user