Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled
274 lines
9.5 KiB
C#
274 lines
9.5 KiB
C#
using System.Text.Json.Nodes;
|
|
using Microsoft.Extensions.Logging;
|
|
using StellaOps.Notify.Models;
|
|
using StellaOps.Notify.Storage.Mongo.Repositories;
|
|
|
|
namespace StellaOps.Notifier.WebService.Services;
|
|
|
|
/// <summary>
|
|
/// Default implementation of INotifyTemplateService with locale fallback and version tracking.
|
|
/// </summary>
|
|
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<NotifyTemplateService> _logger;
|
|
|
|
public NotifyTemplateService(
|
|
INotifyTemplateRepository repository,
|
|
INotifyTemplateRenderer renderer,
|
|
TimeProvider timeProvider,
|
|
ILogger<NotifyTemplateService> 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<NotifyTemplate?> 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<NotifyTemplate?> GetByIdAsync(
|
|
string tenantId,
|
|
string templateId,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(templateId);
|
|
|
|
return _repository.GetAsync(tenantId, templateId, cancellationToken);
|
|
}
|
|
|
|
public async Task<IReadOnlyList<NotifyTemplate>> 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<NotifyTemplate> 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<NotifyTemplate> 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<TemplatePreviewResult> 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<string>();
|
|
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<string> allowlist, List<string> 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();
|
|
}
|
|
}
|