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;
|
||||
|
||||
@@ -6,6 +6,7 @@ COPY Apis/api/api.csproj Apis/api/
|
||||
COPY Apis/shared-models/shared-models.csproj Apis/shared-models/
|
||||
COPY Apis/api-models/api-models.csproj Apis/api-models/
|
||||
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
|
||||
COPY Apis/myai-models/myai-models.csproj Apis/myai-models/
|
||||
COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/
|
||||
|
||||
RUN dotnet restore Apis/api/api.csproj
|
||||
@@ -14,6 +15,7 @@ COPY Apis/api/ Apis/api/
|
||||
COPY Apis/shared-models/ Apis/shared-models/
|
||||
COPY Apis/api-models/ Apis/api-models/
|
||||
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
|
||||
COPY Apis/myai-models/ Apis/myai-models/
|
||||
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
||||
|
||||
RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using System.Reflection;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Models.Settings;
|
||||
using MyAi.Models.Data;
|
||||
using MyAi.Models.Services;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using Shared.Models.Settings;
|
||||
using StartupHelpers;
|
||||
|
||||
StartupExtensions.LoadDotEnvFile();
|
||||
@@ -30,6 +34,17 @@ try
|
||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
|
||||
builder.Services.AddDbContext<MyAiDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsAssembly("myai-models");
|
||||
sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName);
|
||||
});
|
||||
});
|
||||
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
||||
|
||||
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
||||
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
||||
@@ -71,6 +86,13 @@ try
|
||||
app.UseRateLimiter();
|
||||
app.MapControllers();
|
||||
|
||||
Log.Information("Running EF Core migrations if any");
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete. Listening for requests...", ServiceName);
|
||||
app.Run();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Models.Requests;
|
||||
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
@@ -8,5 +9,7 @@ namespace Api.Services.Contracts
|
||||
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
|
||||
Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct);
|
||||
Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct);
|
||||
string BuildMatchEmailSubject(int score, string? jobLabel, string language);
|
||||
string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using MimeKit;
|
||||
using Models.Settings;
|
||||
using Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using MyAi.Models.Services;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
@@ -15,27 +16,29 @@ namespace Api.Services
|
||||
private readonly ContactSettings _contact;
|
||||
private readonly SubscribeSettings _subscribe;
|
||||
private readonly FileStorageSettings _fileStorage;
|
||||
private readonly ITemplateService _templates;
|
||||
private readonly ILogger<SmtpEmailSender> _log;
|
||||
private readonly string _environmentName;
|
||||
|
||||
public SmtpEmailSender(IOptions<SmtpSettings> smtp,
|
||||
public SmtpEmailSender(
|
||||
IOptions<SmtpSettings> smtp,
|
||||
IOptions<ContactSettings> contact,
|
||||
IOptions<SubscribeSettings> subscribe,
|
||||
IOptions<FileStorageSettings> fileStorage,
|
||||
ITemplateService templates,
|
||||
ILogger<SmtpEmailSender> log)
|
||||
{
|
||||
_smtp = smtp.Value;
|
||||
_contact = contact.Value;
|
||||
_subscribe = subscribe.Value;
|
||||
_fileStorage = fileStorage.Value;
|
||||
_templates = templates;
|
||||
_log = log;
|
||||
// Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development"
|
||||
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||
}
|
||||
|
||||
public async Task SendContactAsync(ContactRequest req, CancellationToken ct)
|
||||
{
|
||||
// Throw error if ToEmail is not configured, since contact requests are important to process.
|
||||
if (string.IsNullOrWhiteSpace(_contact.ToEmail))
|
||||
{
|
||||
_log.LogDebug("Contact email skipped - ToEmail not configured");
|
||||
@@ -71,7 +74,6 @@ namespace Api.Services
|
||||
|
||||
public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct)
|
||||
{
|
||||
// Throw error if ToEmail is not configured, since subscription requests are important to process.
|
||||
if (string.IsNullOrWhiteSpace(_subscribe.ToEmail))
|
||||
{
|
||||
_log.LogDebug("Subscription email skipped - ToEmail not configured");
|
||||
@@ -101,7 +103,6 @@ namespace Api.Services
|
||||
|
||||
public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct)
|
||||
{
|
||||
// Skip sending if ToEmail is not configured
|
||||
if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail))
|
||||
{
|
||||
_log.LogDebug("File download notification skipped - ToEmail not configured");
|
||||
@@ -135,8 +136,6 @@ namespace Api.Services
|
||||
/// </summary>
|
||||
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
|
||||
{
|
||||
// If you're in enterprise environments, you may need to tweak certificate validation.
|
||||
// Don't disable it casually.
|
||||
var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto;
|
||||
|
||||
_log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}",
|
||||
@@ -212,43 +211,43 @@ namespace Api.Services
|
||||
await SendEmailAsync(msg, "cv match email", ct);
|
||||
_log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string? jobSearchLink = null)
|
||||
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
||||
{
|
||||
var body = $@"CV Matcher result
|
||||
var strengths = result.Strengths?.Count > 0
|
||||
? "- " + string.Join("\n- ", result.Strengths)
|
||||
: string.Empty;
|
||||
var gaps = result.Gaps?.Count > 0
|
||||
? "- " + string.Join("\n- ", result.Gaps)
|
||||
: string.Empty;
|
||||
var recommendations = result.Recommendations?.Count > 0
|
||||
? "- " + string.Join("\n- ", result.Recommendations)
|
||||
: string.Empty;
|
||||
|
||||
CV Document ID: {cvDocumentId}
|
||||
Job: {jobLabel ?? "N/A"}
|
||||
Job URL: {result.JobUrl ?? "N/A"}
|
||||
Score: {result.Score}%
|
||||
|
||||
Summary:
|
||||
{result.Summary}
|
||||
|
||||
Strengths:
|
||||
- {string.Join("\n- ", result.Strengths)}
|
||||
|
||||
Gaps:
|
||||
- {string.Join("\n- ", result.Gaps)}
|
||||
|
||||
Recommendations:
|
||||
- {string.Join("\n- ", result.Recommendations)}";
|
||||
var body = _templates.Render("email.match.body", language,
|
||||
("cvDocumentId", cvDocumentId),
|
||||
("jobLabel", jobLabel ?? "N/A"),
|
||||
("jobUrl", result.JobUrl ?? "N/A"),
|
||||
("score", result.Score.ToString()),
|
||||
("summary", result.Summary ?? string.Empty),
|
||||
("strengths", strengths),
|
||||
("gaps", gaps),
|
||||
("recommendations", recommendations));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||
{
|
||||
body += $@"
|
||||
|
||||
---
|
||||
Vrei sa gasesti mai multe joburi potrivite CV-ului tau?
|
||||
Click: {jobSearchLink}
|
||||
(link valabil 7 zile)";
|
||||
body += _templates.Render("email.match.job-search-footer", language,
|
||||
("jobSearchLink", jobSearchLink),
|
||||
("expiryDays", expiryDays.ToString()));
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public static string BuildMatchEmailSubject(int score, string? jobLabel)
|
||||
=> $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}";
|
||||
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
|
||||
_templates.Render("email.match.subject", language,
|
||||
("score", score.ToString()),
|
||||
("jobLabel", jobLabel ?? "Job"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\shared-models\shared-models.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\myai-models\myai-models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user