using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using StellaOps.Notify.Models; using StellaOps.Notify.Storage.Mongo.Repositories; namespace StellaOps.Notifier.WebService.Services; /// /// Default implementation of INotifyTemplateService with locale fallback and version tracking. /// public sealed class NotifyTemplateService : INotifyTemplateService { private const string DefaultLocale = "en-us"; private readonly INotifyTemplateRepository _repository; private readonly INotifyTemplateRenderer _renderer; private readonly TimeProvider _timeProvider; private readonly ILogger _logger; public NotifyTemplateService( INotifyTemplateRepository repository, INotifyTemplateRenderer renderer, TimeProvider timeProvider, ILogger logger) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); _timeProvider = timeProvider ?? TimeProvider.System; _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } public async Task GetByKeyAsync( string tenantId, string key, string locale, NotifyChannelType? channelType = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(key); locale = NormalizeLocale(locale); var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); // Filter by key var matching = allTemplates.Where(t => t.Key.Equals(key, StringComparison.OrdinalIgnoreCase)); // Filter by channel type if specified if (channelType.HasValue) { matching = matching.Where(t => t.ChannelType == channelType.Value); } var candidates = matching.ToArray(); // Try exact locale match var exactMatch = candidates.FirstOrDefault(t => t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase)); if (exactMatch is not null) { return exactMatch; } // Try language-only match (e.g., "en" from "en-us") var languageCode = locale.Split('-')[0]; var languageMatch = candidates.FirstOrDefault(t => t.Locale.StartsWith(languageCode, StringComparison.OrdinalIgnoreCase)); if (languageMatch is not null) { _logger.LogDebug("Template {Key} not found for locale {Locale}, using {FallbackLocale}.", key, locale, languageMatch.Locale); return languageMatch; } // Fall back to default locale var defaultMatch = candidates.FirstOrDefault(t => t.Locale.Equals(DefaultLocale, StringComparison.OrdinalIgnoreCase)); if (defaultMatch is not null) { _logger.LogDebug("Template {Key} not found for locale {Locale}, using default locale.", key, locale); return defaultMatch; } // Return any available template for the key return candidates.FirstOrDefault(); } public Task GetByIdAsync( string tenantId, string templateId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(templateId); return _repository.GetAsync(tenantId, templateId, cancellationToken); } public async Task> ListAsync( string tenantId, string? keyPrefix = null, string? locale = null, NotifyChannelType? channelType = null, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); var allTemplates = await _repository.ListAsync(tenantId, cancellationToken).ConfigureAwait(false); IEnumerable filtered = allTemplates; if (!string.IsNullOrWhiteSpace(keyPrefix)) { filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase)); } if (!string.IsNullOrWhiteSpace(locale)) { var normalizedLocale = NormalizeLocale(locale); filtered = filtered.Where(t => t.Locale.Equals(normalizedLocale, StringComparison.OrdinalIgnoreCase)); } if (channelType.HasValue) { filtered = filtered.Where(t => t.ChannelType == channelType.Value); } return filtered.OrderBy(t => t.Key).ThenBy(t => t.Locale).ToArray(); } public async Task UpsertAsync( NotifyTemplate template, string updatedBy, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(template); ArgumentException.ThrowIfNullOrWhiteSpace(updatedBy); var now = _timeProvider.GetUtcNow(); // Check for existing template to preserve creation metadata var existing = await _repository.GetAsync(template.TenantId, template.TemplateId, cancellationToken) .ConfigureAwait(false); var updatedTemplate = NotifyTemplate.Create( templateId: template.TemplateId, tenantId: template.TenantId, channelType: template.ChannelType, key: template.Key, locale: template.Locale, body: template.Body, renderMode: template.RenderMode, format: template.Format, description: template.Description, metadata: template.Metadata, createdBy: existing?.CreatedBy ?? updatedBy, createdAt: existing?.CreatedAt ?? now, updatedBy: updatedBy, updatedAt: now); await _repository.UpsertAsync(updatedTemplate, cancellationToken).ConfigureAwait(false); _logger.LogInformation( "Template {TemplateId} (key={Key}, locale={Locale}) upserted by {UpdatedBy}.", updatedTemplate.TemplateId, updatedTemplate.Key, updatedTemplate.Locale, updatedBy); return updatedTemplate; } public async Task DeleteAsync( string tenantId, string templateId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); ArgumentException.ThrowIfNullOrWhiteSpace(templateId); await _repository.DeleteAsync(tenantId, templateId, cancellationToken).ConfigureAwait(false); _logger.LogInformation("Template {TemplateId} deleted from tenant {TenantId}.", templateId, tenantId); } public Task PreviewAsync( NotifyTemplate template, JsonNode? samplePayload, TemplateRenderOptions? options = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(template); options ??= new TemplateRenderOptions(); // Apply redaction to payload if allowlist is specified var redactedFields = new List(); var processedPayload = samplePayload; if (options.RedactionAllowlist is { Count: > 0 }) { processedPayload = ApplyRedaction(samplePayload, options.RedactionAllowlist, redactedFields); } // Render body var renderedBody = _renderer.Render(template, processedPayload, options); // Render subject if present in metadata string? renderedSubject = null; if (template.Metadata.TryGetValue("subject", out var subjectTemplate)) { var subjectTemplateObj = NotifyTemplate.Create( templateId: "subject-preview", tenantId: template.TenantId, channelType: template.ChannelType, key: "subject", locale: template.Locale, body: subjectTemplate); renderedSubject = _renderer.Render(subjectTemplateObj, processedPayload, options); } // Build provenance link if requested string? provenanceLink = null; if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl)) { provenanceLink = $"{options.ProvenanceBaseUrl.TrimEnd('/')}/templates/{template.TemplateId}"; } var result = new TemplatePreviewResult { RenderedBody = renderedBody, RenderedSubject = renderedSubject, RenderMode = template.RenderMode, Format = options.FormatOverride ?? template.Format, RedactedFields = redactedFields, ProvenanceLink = provenanceLink }; return Task.FromResult(result); } private static JsonNode? ApplyRedaction(JsonNode? payload, IReadOnlySet allowlist, List redactedFields) { if (payload is not JsonObject obj) { return payload; } var result = new JsonObject(); foreach (var (key, value) in obj) { if (allowlist.Contains(key)) { result[key] = value?.DeepClone(); } else { result[key] = "[REDACTED]"; redactedFields.Add(key); } } return result; } private static string NormalizeLocale(string? locale) { return string.IsNullOrWhiteSpace(locale) ? DefaultLocale : locale.ToLowerInvariant(); } }