refactor(data): rename email-api-data to email-data for consistent naming

- Rename project folder Apis/email-api-data → Apis/email-data
- Rename csproj file: email-api-data.csproj → email-data.csproj
- Update csproj properties: AssemblyName and RootNamespace (email-data, Email.Data)
- Update C# namespaces: EmailApi.Data → Email.Data across all email-data files
- Update project references in api.csproj and email-api.csproj
- Update migration assembly references in api/Program.cs and email-api/Program.cs
- Update cv-search-job references to use email-data project and Email.Data namespace
- Update solution file to reference new email-data project path
- Remove hardcoded schema name from SmtpEmailDispatcher, use template service instead

This maintains consistency with other data project naming convention (no service-type suffix).
All tests passing, build succeeds.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 09:51:03 +03:00
parent 33d92551da
commit ea9bc87981
23 changed files with 49 additions and 45 deletions
@@ -0,0 +1,108 @@
using System.Collections.Concurrent;
using Email.Data.Repositories.Contracts;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Email.Data.Services;
/// <summary>
/// Singleton implementation of <see cref="IEmailTemplateService"/> that caches all email templates
/// from the database and refreshes them every 10 minutes.
/// Uses <see cref="IServiceScopeFactory"/> to resolve the scoped repository from a singleton lifetime.
/// </summary>
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;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <inheritdoc />
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;
}
/// <summary>
/// Reloads all templates from the database when the cache TTL has expired.
/// Swaps both caches atomically; logs an error and continues serving the stale cache on failure.
/// </summary>
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.");
}
}
/// <summary>Builds the dictionary key used for both caches.</summary>
private static string CacheKey(string key, string language) => $"{key}::{language}";
}
@@ -0,0 +1,38 @@
namespace Email.Data.Services;
/// <summary>
/// Provides access to localised email templates stored in the <c>emailApi.EmailTemplates</c> table.
/// Implementations are expected to cache templates and refresh periodically.
/// </summary>
public interface IEmailTemplateService
{
/// <summary>
/// Returns the template value for the given key and language.
/// Falls back to <c>"en"</c> when the requested language has no entry.
/// Returns the raw key string when no matching template is found.
/// </summary>
/// <param name="key">Template key (e.g. <c>"email.match.subject"</c>).</param>
/// <param name="language">Two-letter language code (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
/// <returns>Template value string.</returns>
string Get(string key, string language = "en");
/// <summary>
/// Retrieves the template and substitutes <c>{{placeholder}}</c> tokens with the provided values.
/// </summary>
/// <param name="key">Template key.</param>
/// <param name="language">Two-letter language code.</param>
/// <param name="placeholders">Named replacement pairs in the form <c>("name", value)</c>.</param>
/// <returns>Rendered template string with all placeholders replaced.</returns>
string Render(string key, string language, params (string Key, string Value)[] placeholders);
/// <summary>
/// Returns the operator copy address for the given template key.
/// Uses the specific row's <c>OperatorCopy</c> value when non-empty; otherwise falls back
/// to the first non-empty <c>OperatorCopy</c> across all cached rows, so future template rows
/// with an empty value automatically inherit the globally configured address.
/// </summary>
/// <param name="key">Template key used to look up the specific row (typically the subject key).</param>
/// <param name="language">Two-letter language code.</param>
/// <returns>Operator copy email address, or <c>null</c> when none is configured.</returns>
string? GetOperatorCopy(string key, string language);
}