using System.Collections.Concurrent; using Email.Data.Repositories.Contracts; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace Email.Data.Services; /// /// Singleton implementation of that caches all email templates /// from the database and refreshes them every 10 minutes. /// Uses to resolve the scoped repository from a singleton lifetime. /// public sealed class EmailTemplateService : IEmailTemplateService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private ConcurrentDictionary _valueCache = new(StringComparer.OrdinalIgnoreCase); private ConcurrentDictionary _operatorCache = new(StringComparer.OrdinalIgnoreCase); private DateTime _loadedAt = DateTime.MinValue; private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); public EmailTemplateService(IServiceScopeFactory scopeFactory, ILogger 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; } /// /// 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. /// private void EnsureCacheLoaded() { if (DateTime.UtcNow - _loadedAt < CacheTtl) return; try { using var scope = _scopeFactory.CreateScope(); var repo = scope.ServiceProvider.GetRequiredService(); var rows = repo.GetAllAsync(CancellationToken.None).GetAwaiter().GetResult(); var freshValues = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); var freshOperator = new ConcurrentDictionary(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."); } } /// Builds the dictionary key used for both caches. private static string CacheKey(string key, string language) => $"{key}::{language}"; }