feat: add email-api-data project with EmailTemplates repository and service
New data project owning the emailApi schema: - EmailTemplateEntity with Key, Language, Value, Description, UpdatedAt, OperatorCopy - EmailApiDbContext (schema: emailApi, custom migration table _EmailApiMigrations) - IEmailTemplateRepository / EfEmailTemplateRepository (scoped) - IEmailTemplateService / EmailTemplateService (singleton, 10-min cache) - GetOperatorCopy falls back to first non-empty OperatorCopy across all rows - Initial migration CreateEmailTemplates: creates table + seeds all email.* templates (match + search-results in en/ro) and html-shell fragments with OperatorCopy = "contact@myai.ro" for addressable rows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
using EmailApi.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EmailApi.Data;
|
||||
|
||||
public sealed class EmailApiDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "emailApi";
|
||||
public const string MigrationTableName = "_EmailApiMigrations";
|
||||
|
||||
public EmailApiDbContext(DbContextOptions<EmailApiDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<EmailTemplateEntity> EmailTemplates => Set<EmailTemplateEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<EmailTemplateEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("EmailTemplates");
|
||||
entity.HasKey(x => new { x.Key, x.Language });
|
||||
entity.Property(x => x.Key).HasMaxLength(128);
|
||||
entity.Property(x => x.Language).HasMaxLength(8);
|
||||
entity.Property(x => x.Value).IsRequired();
|
||||
entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty);
|
||||
entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.Property(x => x.OperatorCopy).HasMaxLength(256).HasDefaultValue(string.Empty);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace EmailApi.Data.Entities;
|
||||
|
||||
// composite PK (Key + Language) — BaseEntity not applicable
|
||||
public sealed class EmailTemplateEntity
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Language { get; set; } = string.Empty;
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
public string OperatorCopy { get; set; } = string.Empty;
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using EmailApi.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EmailApi.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EmailApiDbContext))]
|
||||
[Migration("20260528100000_CreateEmailTemplates")]
|
||||
partial class CreateEmailTemplates
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("emailApi")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("OperatorCopy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("EmailTemplates", "emailApi");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EmailApi.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CreateEmailTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(name: "emailApi");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmailTemplates",
|
||||
schema: "emailApi",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
OperatorCopy = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmailTemplates", x => new { x.Key, x.Language });
|
||||
});
|
||||
|
||||
Seed(migrationBuilder);
|
||||
}
|
||||
|
||||
private static void Seed(MigrationBuilder m)
|
||||
{
|
||||
const string op = "contact@myai.ro";
|
||||
|
||||
void Row(string key, string lang, string value, string description = "", string operatorCopy = "")
|
||||
=> m.InsertData("EmailTemplates",
|
||||
["Key", "Language", "Value", "Description", "OperatorCopy"],
|
||||
[key, lang, value, description, operatorCopy],
|
||||
"emailApi");
|
||||
|
||||
// ── HTML shell (no operator copy — these are layout fragments, not addressable emails) ──
|
||||
Row("email.html-shell.start", "*",
|
||||
"<!DOCTYPE html>\n<html>\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head>\n<body style=\"margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"padding:20px 0\">\n <tr><td align=\"center\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\"\n style=\"background:#ffffff;border-radius:8px;max-width:600px\">\n <tr><td style=\"background:#2c5282;padding:24px 32px;border-radius:8px 8px 0 0\">\n <h1 style=\"margin:0;color:#ffffff;font-size:22px;font-weight:600\">myAi</h1>\n </td></tr>\n <tr><td style=\"padding:32px\">",
|
||||
"Opening HTML shell fragment — wrapped around every HtmlBody before sending");
|
||||
|
||||
Row("email.html-shell.end", "*",
|
||||
" </td></tr>\n <tr><td style=\"background:#f8f9fa;padding:16px 32px;text-align:center;\n color:#6c757d;font-size:12px;border-radius:0 0 8px 8px\">\n Automated message from myAi.\n </td></tr>\n </table>\n </td></tr>\n </table>\n</body>\n</html>",
|
||||
"Closing HTML shell fragment — appended after every HtmlBody before sending");
|
||||
|
||||
// ── CV match result email ──
|
||||
Row("email.match.subject", "en",
|
||||
"MyAi.ro CV Match: {{score}}% - {{jobLabel}}",
|
||||
"Subject for the CV match result email",
|
||||
op);
|
||||
|
||||
Row("email.match.subject", "ro",
|
||||
"MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}",
|
||||
"Subiect email rezultat potrivire CV",
|
||||
op);
|
||||
|
||||
Row("email.match.body", "en",
|
||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">CV Match Report</h2>" +
|
||||
"<table cellpadding=\"10\" cellspacing=\"0\" style=\"width:100%;border-collapse:collapse;margin-bottom:24px\">" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;width:130px;border:1px solid #dee2e6;color:#495057\">CV ID</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Score</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
||||
"</tr>" +
|
||||
"</table>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Summary</h3>" +
|
||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Strengths</h3>{{strengths}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Gaps</h3>{{gaps}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recommendations</h3>{{recommendations}}",
|
||||
"Body for the CV match result email",
|
||||
op);
|
||||
|
||||
Row("email.match.body", "ro",
|
||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">Raport Potrivire CV</h2>" +
|
||||
"<table cellpadding=\"10\" cellspacing=\"0\" style=\"width:100%;border-collapse:collapse;margin-bottom:24px\">" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;width:130px;border:1px solid #dee2e6;color:#495057\">ID Document CV</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Scor</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
||||
"</tr>" +
|
||||
"</table>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Rezumat</h3>" +
|
||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Puncte forte</h3>{{strengths}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Lipsuri</h3>{{gaps}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recomandări</h3>{{recommendations}}",
|
||||
"Corpul emailului pentru rezultatul potrivirii CV",
|
||||
op);
|
||||
|
||||
Row("email.match.job-search-footer", "en",
|
||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
||||
"<p style=\"margin:0;color:#495057\">" +
|
||||
"Want to find matching jobs automatically? " +
|
||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Start a job search →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valid for {{expiryDays}} days.</small>" +
|
||||
"</p>" +
|
||||
"</div>",
|
||||
"Job search CTA appended to match result email",
|
||||
op);
|
||||
|
||||
Row("email.match.job-search-footer", "ro",
|
||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
||||
"<p style=\"margin:0;color:#495057\">" +
|
||||
"Vrei să găsești joburi potrivite automat? " +
|
||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Pornește o căutare de joburi →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valabil {{expiryDays}} zile.</small>" +
|
||||
"</p>" +
|
||||
"</div>",
|
||||
"CTA cautare joburi adaugat la emailul de potrivire CV",
|
||||
op);
|
||||
|
||||
// ── Job search results email ──
|
||||
Row("email.search-results.subject", "en",
|
||||
"MyAi.ro: {{count}} jobs matching your CV",
|
||||
"Subject for job search results email",
|
||||
op);
|
||||
|
||||
Row("email.search-results.subject", "ro",
|
||||
"MyAi.ro: {{count}} joburi potrivite CV-ului tau",
|
||||
"Subiect email rezultate cautare joburi",
|
||||
op);
|
||||
|
||||
Row("email.search-results.body", "en",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Job Search Results</h2>" +
|
||||
"<p style=\"color:#495057\">Found <strong>{{count}}</strong> matching job(s):</p>" +
|
||||
"{{items}}",
|
||||
"Body preamble for job search results email",
|
||||
op);
|
||||
|
||||
Row("email.search-results.body", "ro",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Rezultate Căutare Joburi</h2>" +
|
||||
"<p style=\"color:#495057\">Am găsit <strong>{{count}}</strong> job(uri) potrivite:</p>" +
|
||||
"{{items}}",
|
||||
"Corpul emailului de rezultate cautare joburi",
|
||||
op);
|
||||
|
||||
Row("email.search-results.empty", "en",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">No matching jobs found</p>" +
|
||||
"<p style=\"margin:0\">Your job search completed but no matching jobs were found. Try again later or adjust your CV.</p>" +
|
||||
"</div>",
|
||||
"No results message for job search results email",
|
||||
op);
|
||||
|
||||
Row("email.search-results.empty", "ro",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">Niciun job potrivit găsit</p>" +
|
||||
"<p style=\"margin:0\">Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.</p>" +
|
||||
"</div>",
|
||||
"Mesaj fara rezultate pentru emailul de cautare joburi",
|
||||
op);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using EmailApi.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EmailApi.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EmailApiDbContext))]
|
||||
partial class EmailApiDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("emailApi")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("OperatorCopy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("EmailTemplates", "emailApi");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using EmailApi.Data.Entities;
|
||||
|
||||
namespace EmailApi.Data.Repositories.Contracts;
|
||||
|
||||
public interface IEmailTemplateRepository
|
||||
{
|
||||
Task<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using EmailApi.Data.Entities;
|
||||
using EmailApi.Data.Repositories.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EmailApi.Data.Repositories;
|
||||
|
||||
public sealed class EfEmailTemplateRepository : IEmailTemplateRepository
|
||||
{
|
||||
private readonly EmailApiDbContext _db;
|
||||
|
||||
public EfEmailTemplateRepository(EmailApiDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct)
|
||||
=> await _db.EmailTemplates.AsNoTracking().ToListAsync(ct);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Collections.Concurrent;
|
||||
using EmailApi.Data.Repositories.Contracts;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace EmailApi.Data.Services;
|
||||
|
||||
public sealed class EmailTemplateService : IEmailTemplateService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<EmailTemplateService> _logger;
|
||||
private ConcurrentDictionary<string, string> _valueCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private ConcurrentDictionary<string, string> _operatorCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DateTime _loadedAt = DateTime.MinValue;
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10);
|
||||
|
||||
public EmailTemplateService(IServiceScopeFactory scopeFactory, ILogger<EmailTemplateService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Get(string key, string language = "en")
|
||||
{
|
||||
EnsureCacheLoaded();
|
||||
|
||||
if (_valueCache.TryGetValue(CacheKey(key, language), out var value))
|
||||
return value;
|
||||
|
||||
if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase)
|
||||
&& _valueCache.TryGetValue(CacheKey(key, "en"), out var fallback))
|
||||
return fallback;
|
||||
|
||||
_logger.LogWarning("Email template not found: key={Key}, language={Language}", key, language);
|
||||
return key;
|
||||
}
|
||||
|
||||
public string Render(string key, string language, params (string Key, string Value)[] placeholders)
|
||||
{
|
||||
var template = Get(key, language);
|
||||
foreach (var (k, v) in placeholders)
|
||||
template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase);
|
||||
return template;
|
||||
}
|
||||
|
||||
public string? GetOperatorCopy(string key, string language)
|
||||
{
|
||||
EnsureCacheLoaded();
|
||||
|
||||
if (_operatorCache.TryGetValue(CacheKey(key, language), out var specific)
|
||||
&& !string.IsNullOrWhiteSpace(specific))
|
||||
return specific;
|
||||
|
||||
// Fall back to first non-empty OperatorCopy in the cache
|
||||
foreach (var val in _operatorCache.Values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(val))
|
||||
return val;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void EnsureCacheLoaded()
|
||||
{
|
||||
if (DateTime.UtcNow - _loadedAt < CacheTtl) return;
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
|
||||
var rows = repo.GetAllAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
var freshValues = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var freshOperator = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
freshValues[CacheKey(row.Key, row.Language)] = row.Value;
|
||||
freshOperator[CacheKey(row.Key, row.Language)] = row.OperatorCopy;
|
||||
}
|
||||
|
||||
_valueCache = freshValues;
|
||||
_operatorCache = freshOperator;
|
||||
_loadedAt = DateTime.UtcNow;
|
||||
_logger.LogDebug("Email template cache refreshed. {Count} templates loaded.", rows.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to refresh email template cache. Serving stale cache.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string CacheKey(string key, string language) => $"{key}::{language}";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace EmailApi.Data.Services;
|
||||
|
||||
public interface IEmailTemplateService
|
||||
{
|
||||
string Get(string key, string language = "en");
|
||||
string Render(string key, string language, params (string Key, string Value)[] placeholders);
|
||||
string? GetOperatorCopy(string key, string language);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>email-api-data</AssemblyName>
|
||||
<RootNamespace>EmailApi.Data</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-models", "Apis\em
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api\email-api.csproj", "{434119EA-2FFC-4433-9B8E-1E6D94006413}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-data", "Apis\email-api-data\email-api-data.csproj", "{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -343,6 +345,18 @@ Global
|
||||
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.Build.0 = Release|Any CPU
|
||||
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -370,6 +384,7 @@ Global
|
||||
{069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
||||
{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}
|
||||
{434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B}
|
||||
{C1D2E3F4-A5B6-4789-CDEF-012345678ABC} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67}
|
||||
|
||||
Reference in New Issue
Block a user