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();
}
}