b114156e9c
Changed configuration error handling to throw InvalidOperationException instead of silently using fallback values. This ensures: 1. Missing email templates (critical config) → 500 error to UI 2. Missing AI prompts (critical config) → 500 error to UI 3. Clear error messages indicating config issue 4. Prompts administrators to check database seeding Services updated: - EmailTemplateService.Get() throws for missing template - CvMatcherService.ScorePairAsync() throws for missing AI prompt This prevents silent failures with degraded service quality and makes it obvious to users that the system has a configuration problem that needs fixing. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
110 lines
4.2 KiB
C#
110 lines
4.2 KiB
C#
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;
|
|
|
|
throw new InvalidOperationException(
|
|
$"Email template not found: key='{key}', language='{language}'. " +
|
|
$"This is a configuration error. Ensure the email.Templates table is properly seeded.");
|
|
}
|
|
|
|
/// <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}";
|
|
}
|