up
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
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
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating or updating a channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public NotifyChannelType? Type { get; init; }
|
||||
public string? Endpoint { get; init; }
|
||||
public string? Target { get; init; }
|
||||
public string? SecretRef { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Serialization;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update an escalation policy.
|
||||
/// </summary>
|
||||
public sealed record EscalationPolicyUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableArray<EscalationLevelRequest> Levels { get; init; }
|
||||
public int? RepeatCount { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escalation level configuration.
|
||||
/// </summary>
|
||||
public sealed record EscalationLevelRequest
|
||||
{
|
||||
public int Order { get; init; }
|
||||
public TimeSpan EscalateAfter { get; init; }
|
||||
public ImmutableArray<EscalationTargetRequest> Targets { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escalation target configuration.
|
||||
/// </summary>
|
||||
public sealed record EscalationTargetRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? TargetId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to start an escalation for an incident.
|
||||
/// </summary>
|
||||
public sealed record StartEscalationRequest
|
||||
{
|
||||
public string? IncidentId { get; init; }
|
||||
public string? PolicyId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to acknowledge an escalation.
|
||||
/// </summary>
|
||||
public sealed record AcknowledgeEscalationRequest
|
||||
{
|
||||
public string? StateIdOrIncidentId { get; init; }
|
||||
public string? AcknowledgedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve an escalation.
|
||||
/// </summary>
|
||||
public sealed record ResolveEscalationRequest
|
||||
{
|
||||
public string? StateIdOrIncidentId { get; init; }
|
||||
public string? ResolvedBy { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update an on-call schedule.
|
||||
/// </summary>
|
||||
public sealed record OnCallScheduleUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public string? TimeZone { get; init; }
|
||||
public ImmutableArray<OnCallLayerRequest> Layers { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On-call layer configuration.
|
||||
/// </summary>
|
||||
public sealed record OnCallLayerRequest
|
||||
{
|
||||
public string? LayerId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public int Priority { get; init; }
|
||||
public DateTimeOffset RotationStartsAt { get; init; }
|
||||
public TimeSpan RotationInterval { get; init; }
|
||||
public ImmutableArray<OnCallParticipantRequest> Participants { get; init; }
|
||||
public OnCallRestrictionRequest? Restrictions { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On-call participant configuration.
|
||||
/// </summary>
|
||||
public sealed record OnCallParticipantRequest
|
||||
{
|
||||
public string? UserId { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? Email { get; init; }
|
||||
public ImmutableArray<ContactMethodRequest> ContactMethods { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contact method configuration.
|
||||
/// </summary>
|
||||
public sealed record ContactMethodRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public string? Address { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On-call restriction configuration.
|
||||
/// </summary>
|
||||
public sealed record OnCallRestrictionRequest
|
||||
{
|
||||
public string? Type { get; init; }
|
||||
public ImmutableArray<TimeRangeRequest> TimeRanges { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Time range for on-call restrictions.
|
||||
/// </summary>
|
||||
public sealed record TimeRangeRequest
|
||||
{
|
||||
public TimeOnly StartTime { get; init; }
|
||||
public TimeOnly EndTime { get; init; }
|
||||
public DayOfWeek? DayOfWeek { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to add an on-call override.
|
||||
/// </summary>
|
||||
public sealed record OnCallOverrideRequest
|
||||
{
|
||||
public string? UserId { get; init; }
|
||||
public DateTimeOffset StartsAt { get; init; }
|
||||
public DateTimeOffset EndsAt { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve who is on-call.
|
||||
/// </summary>
|
||||
public sealed record OnCallResolveRequest
|
||||
{
|
||||
public DateTimeOffset? EvaluationTime { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create/update a localization bundle.
|
||||
/// </summary>
|
||||
public sealed record LocalizationBundleUpsertRequest
|
||||
{
|
||||
public string? Locale { get; init; }
|
||||
public string? BundleKey { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Strings { get; init; }
|
||||
public bool? IsDefault { get; init; }
|
||||
public string? ParentLocale { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to resolve localized strings.
|
||||
/// </summary>
|
||||
public sealed record LocalizationResolveRequest
|
||||
{
|
||||
public string? BundleKey { get; init; }
|
||||
public IReadOnlyList<string>? StringKeys { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response containing resolved localized strings.
|
||||
/// </summary>
|
||||
public sealed record LocalizationResolveResponse
|
||||
{
|
||||
public required IReadOnlyDictionary<string, LocalizedStringResult> Strings { get; init; }
|
||||
public required string RequestedLocale { get; init; }
|
||||
public required IReadOnlyList<string> FallbackChain { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result for a single localized string.
|
||||
/// </summary>
|
||||
public sealed record LocalizedStringResult
|
||||
{
|
||||
public required string Value { get; init; }
|
||||
public required string ResolvedLocale { get; init; }
|
||||
public required bool UsedFallback { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Immutable;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a quiet hours schedule.
|
||||
/// </summary>
|
||||
public sealed class QuietHoursUpsertRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string CronExpression { get; init; }
|
||||
public required TimeSpan Duration { get; init; }
|
||||
public required string TimeZone { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a maintenance window.
|
||||
/// </summary>
|
||||
public sealed class MaintenanceWindowUpsertRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required DateTimeOffset StartsAt { get; init; }
|
||||
public required DateTimeOffset EndsAt { get; init; }
|
||||
public bool? SuppressNotifications { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
public ImmutableArray<string> ChannelIds { get; init; } = [];
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create or update a throttle configuration.
|
||||
/// </summary>
|
||||
public sealed class ThrottleConfigUpsertRequest
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required TimeSpan DefaultWindow { get; init; }
|
||||
public int? MaxNotificationsPerWindow { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public bool? IsDefault { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public ImmutableDictionary<string, string>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to create an operator override.
|
||||
/// </summary>
|
||||
public sealed class OperatorOverrideCreateRequest
|
||||
{
|
||||
public required string OverrideType { get; init; }
|
||||
public required DateTimeOffset ExpiresAt { get; init; }
|
||||
public string? ChannelId { get; init; }
|
||||
public string? RuleId { get; init; }
|
||||
public string? Reason { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating or updating a rule.
|
||||
/// </summary>
|
||||
public sealed record RuleUpsertRequest
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public RuleMatchRequest? Match { get; init; }
|
||||
public IReadOnlyList<RuleActionRequest>? Actions { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Match criteria for a rule.
|
||||
/// </summary>
|
||||
public sealed record RuleMatchRequest
|
||||
{
|
||||
public string[]? EventKinds { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Action definition for a rule.
|
||||
/// </summary>
|
||||
public sealed record RuleActionRequest
|
||||
{
|
||||
public string? ActionId { get; init; }
|
||||
public string? Channel { get; init; }
|
||||
public string? Template { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public bool? Enabled { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request to run a historical simulation against past events.
|
||||
/// </summary>
|
||||
public sealed class SimulationRunRequest
|
||||
{
|
||||
public required DateTimeOffset PeriodStart { get; init; }
|
||||
public required DateTimeOffset PeriodEnd { get; init; }
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
public ImmutableArray<string> EventKinds { get; init; } = [];
|
||||
public int MaxEvents { get; init; } = 1000;
|
||||
public bool IncludeNonMatches { get; init; } = true;
|
||||
public bool EvaluateThrottling { get; init; } = true;
|
||||
public bool EvaluateQuietHours { get; init; } = true;
|
||||
public DateTimeOffset? EvaluationTimestamp { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request to simulate a single event against current rules.
|
||||
/// </summary>
|
||||
public sealed class SimulateSingleEventRequest
|
||||
{
|
||||
public required JsonObject EventPayload { get; init; }
|
||||
public ImmutableArray<string> RuleIds { get; init; } = [];
|
||||
public DateTimeOffset? EvaluationTimestamp { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Request for creating or updating a template.
|
||||
/// </summary>
|
||||
public sealed record TemplateUpsertRequest
|
||||
{
|
||||
public string? Key { get; init; }
|
||||
public string? Body { get; init; }
|
||||
public string? Locale { get; init; }
|
||||
public NotifyChannelType? ChannelType { get; init; }
|
||||
public NotifyTemplateRenderMode? RenderMode { get; init; }
|
||||
public NotifyDeliveryFormat? Format { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IEnumerable<KeyValuePair<string, string>>? Metadata { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request for previewing a template render.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewRequest
|
||||
{
|
||||
public JsonNode? SamplePayload { get; init; }
|
||||
public bool? IncludeProvenance { get; init; }
|
||||
public string? ProvenanceBaseUrl { get; init; }
|
||||
public NotifyDeliveryFormat? FormatOverride { get; init; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,348 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Web;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Advanced template renderer with Handlebars-style syntax, format conversion, and redaction support.
|
||||
/// Supports {{property}}, {{#each}}, {{#if}}, and format-specific output (Markdown/HTML/JSON/PlainText).
|
||||
/// </summary>
|
||||
public sealed partial class AdvancedTemplateRenderer : INotifyTemplateRenderer
|
||||
{
|
||||
private static readonly Regex PlaceholderPattern = PlaceholderRegex();
|
||||
private static readonly Regex EachBlockPattern = EachBlockRegex();
|
||||
private static readonly Regex IfBlockPattern = IfBlockRegex();
|
||||
private static readonly Regex ElseBlockPattern = ElseBlockRegex();
|
||||
|
||||
private readonly ILogger<AdvancedTemplateRenderer> _logger;
|
||||
|
||||
public AdvancedTemplateRenderer(ILogger<AdvancedTemplateRenderer> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(template);
|
||||
|
||||
var body = template.Body;
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
options ??= new TemplateRenderOptions();
|
||||
|
||||
try
|
||||
{
|
||||
// Process conditional blocks first
|
||||
body = ProcessIfBlocks(body, payload);
|
||||
|
||||
// Process {{#each}} blocks
|
||||
body = ProcessEachBlocks(body, payload);
|
||||
|
||||
// Substitute simple placeholders
|
||||
body = SubstitutePlaceholders(body, payload);
|
||||
|
||||
// Convert to target format based on render mode
|
||||
body = ConvertToTargetFormat(body, template.RenderMode, options.FormatOverride ?? template.Format);
|
||||
|
||||
// Append provenance link if requested
|
||||
if (options.IncludeProvenance && !string.IsNullOrWhiteSpace(options.ProvenanceBaseUrl))
|
||||
{
|
||||
body = AppendProvenanceLink(body, template, options.ProvenanceBaseUrl);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Template rendering failed for {TemplateId}.", template.TemplateId);
|
||||
return $"[Render Error: {ex.Message}]";
|
||||
}
|
||||
}
|
||||
|
||||
private static string ProcessIfBlocks(string body, JsonNode? payload)
|
||||
{
|
||||
// Process {{#if condition}}...{{else}}...{{/if}} blocks
|
||||
return IfBlockPattern.Replace(body, match =>
|
||||
{
|
||||
var conditionPath = match.Groups[1].Value.Trim();
|
||||
var ifContent = match.Groups[2].Value;
|
||||
|
||||
var elseMatch = ElseBlockPattern.Match(ifContent);
|
||||
string trueContent;
|
||||
string falseContent;
|
||||
|
||||
if (elseMatch.Success)
|
||||
{
|
||||
trueContent = ifContent[..elseMatch.Index];
|
||||
falseContent = elseMatch.Groups[1].Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
trueContent = ifContent;
|
||||
falseContent = string.Empty;
|
||||
}
|
||||
|
||||
var conditionValue = ResolvePath(payload, conditionPath);
|
||||
var isTruthy = EvaluateTruthy(conditionValue);
|
||||
|
||||
return isTruthy ? trueContent : falseContent;
|
||||
});
|
||||
}
|
||||
|
||||
private static bool EvaluateTruthy(JsonNode? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonValue jv when jv.TryGetValue(out bool b) => b,
|
||||
JsonValue jv when jv.TryGetValue(out string? s) => !string.IsNullOrEmpty(s),
|
||||
JsonValue jv when jv.TryGetValue(out int i) => i != 0,
|
||||
JsonValue jv when jv.TryGetValue(out double d) => d != 0.0,
|
||||
JsonArray arr => arr.Count > 0,
|
||||
JsonObject obj => obj.Count > 0,
|
||||
_ => true
|
||||
};
|
||||
}
|
||||
|
||||
private static string ProcessEachBlocks(string body, JsonNode? payload)
|
||||
{
|
||||
return EachBlockPattern.Replace(body, match =>
|
||||
{
|
||||
var collectionPath = match.Groups[1].Value.Trim();
|
||||
var innerTemplate = match.Groups[2].Value;
|
||||
|
||||
var collection = ResolvePath(payload, collectionPath);
|
||||
|
||||
if (collection is JsonArray arr)
|
||||
{
|
||||
var results = new List<string>();
|
||||
var index = 0;
|
||||
foreach (var item in arr)
|
||||
{
|
||||
var itemResult = innerTemplate
|
||||
.Replace("{{@index}}", index.ToString())
|
||||
.Replace("{{this}}", item?.ToString() ?? string.Empty);
|
||||
|
||||
// Also substitute nested properties from item
|
||||
if (item is JsonObject itemObj)
|
||||
{
|
||||
itemResult = SubstitutePlaceholders(itemResult, itemObj);
|
||||
}
|
||||
|
||||
results.Add(itemResult);
|
||||
index++;
|
||||
}
|
||||
|
||||
return string.Join(string.Empty, results);
|
||||
}
|
||||
|
||||
if (collection is JsonObject obj)
|
||||
{
|
||||
var results = new List<string>();
|
||||
foreach (var (key, value) in obj)
|
||||
{
|
||||
var itemResult = innerTemplate
|
||||
.Replace("{{@key}}", key)
|
||||
.Replace("{{this}}", value?.ToString() ?? string.Empty);
|
||||
results.Add(itemResult);
|
||||
}
|
||||
|
||||
return string.Join(string.Empty, results);
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static string SubstitutePlaceholders(string body, JsonNode? payload)
|
||||
{
|
||||
return PlaceholderPattern.Replace(body, match =>
|
||||
{
|
||||
var path = match.Groups[1].Value.Trim();
|
||||
var resolved = ResolvePath(payload, path);
|
||||
return resolved?.ToString() ?? string.Empty;
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonNode? ResolvePath(JsonNode? root, string path)
|
||||
{
|
||||
if (root is null || string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var segments = path.Split('.');
|
||||
var current = root;
|
||||
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next))
|
||||
{
|
||||
current = next;
|
||||
}
|
||||
else if (current is JsonArray arr && int.TryParse(segment, out var index) && index >= 0 && index < arr.Count)
|
||||
{
|
||||
current = arr[index];
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
private string ConvertToTargetFormat(string body, NotifyTemplateRenderMode sourceMode, NotifyDeliveryFormat targetFormat)
|
||||
{
|
||||
// If source is already in the target format family, return as-is
|
||||
if (sourceMode == NotifyTemplateRenderMode.Json && targetFormat == NotifyDeliveryFormat.Json)
|
||||
{
|
||||
return body;
|
||||
}
|
||||
|
||||
return targetFormat switch
|
||||
{
|
||||
NotifyDeliveryFormat.Json => ConvertToJson(body, sourceMode),
|
||||
NotifyDeliveryFormat.Slack => ConvertToSlack(body, sourceMode),
|
||||
NotifyDeliveryFormat.Teams => ConvertToTeams(body, sourceMode),
|
||||
NotifyDeliveryFormat.Email => ConvertToEmail(body, sourceMode),
|
||||
NotifyDeliveryFormat.Webhook => body, // Pass through as-is
|
||||
_ => body
|
||||
};
|
||||
}
|
||||
|
||||
private static string ConvertToJson(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
// Wrap content in a JSON structure
|
||||
var content = new JsonObject
|
||||
{
|
||||
["content"] = body,
|
||||
["format"] = sourceMode.ToString()
|
||||
};
|
||||
|
||||
return content.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static string ConvertToSlack(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
// Convert Markdown to Slack mrkdwn format
|
||||
if (sourceMode == NotifyTemplateRenderMode.Markdown)
|
||||
{
|
||||
// Slack uses similar markdown but with some differences
|
||||
// Convert **bold** to *bold* for Slack
|
||||
body = Regex.Replace(body, @"\*\*(.+?)\*\*", "*$1*");
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ConvertToTeams(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
// Teams uses Adaptive Cards or MessageCard format
|
||||
// For simple conversion, wrap in basic card structure
|
||||
if (sourceMode == NotifyTemplateRenderMode.Markdown ||
|
||||
sourceMode == NotifyTemplateRenderMode.PlainText)
|
||||
{
|
||||
var card = new JsonObject
|
||||
{
|
||||
["@type"] = "MessageCard",
|
||||
["@context"] = "http://schema.org/extensions",
|
||||
["summary"] = "Notification",
|
||||
["sections"] = new JsonArray
|
||||
{
|
||||
new JsonObject
|
||||
{
|
||||
["text"] = body
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return card.ToJsonString(new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ConvertToEmail(string body, NotifyTemplateRenderMode sourceMode)
|
||||
{
|
||||
if (sourceMode == NotifyTemplateRenderMode.Markdown)
|
||||
{
|
||||
// Basic Markdown to HTML conversion for email
|
||||
return ConvertMarkdownToHtml(body);
|
||||
}
|
||||
|
||||
if (sourceMode == NotifyTemplateRenderMode.PlainText)
|
||||
{
|
||||
// Wrap plain text in basic HTML structure
|
||||
return $"<html><body><pre>{HttpUtility.HtmlEncode(body)}</pre></body></html>";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
private static string ConvertMarkdownToHtml(string markdown)
|
||||
{
|
||||
var html = new StringBuilder(markdown);
|
||||
|
||||
// Headers
|
||||
html.Replace("\n### ", "\n<h3>");
|
||||
html.Replace("\n## ", "\n<h2>");
|
||||
html.Replace("\n# ", "\n<h1>");
|
||||
|
||||
// Bold
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*\*(.+?)\*\*", "<strong>$1</strong>"));
|
||||
|
||||
// Italic
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"\*(.+?)\*", "<em>$1</em>"));
|
||||
|
||||
// Code
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"`(.+?)`", "<code>$1</code>"));
|
||||
|
||||
// Links
|
||||
html = new StringBuilder(Regex.Replace(html.ToString(), @"\[(.+?)\]\((.+?)\)", "<a href=\"$2\">$1</a>"));
|
||||
|
||||
// Line breaks
|
||||
html.Replace("\n\n", "</p><p>");
|
||||
html.Replace("\n", "<br/>");
|
||||
|
||||
return $"<html><body><p>{html}</p></body></html>";
|
||||
}
|
||||
|
||||
private static string AppendProvenanceLink(string body, NotifyTemplate template, string baseUrl)
|
||||
{
|
||||
var provenanceUrl = $"{baseUrl.TrimEnd('/')}/templates/{template.TemplateId}";
|
||||
|
||||
return template.RenderMode switch
|
||||
{
|
||||
NotifyTemplateRenderMode.Markdown => $"{body}\n\n---\n_Template: [{template.Key}]({provenanceUrl})_",
|
||||
NotifyTemplateRenderMode.Html => $"{body}<hr/><p><small>Template: <a href=\"{provenanceUrl}\">{template.Key}</a></small></p>",
|
||||
NotifyTemplateRenderMode.PlainText => $"{body}\n\n---\nTemplate: {template.Key} ({provenanceUrl})",
|
||||
_ => body
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\{\{([^#/}]+)\}\}", RegexOptions.Compiled)]
|
||||
private static partial Regex PlaceholderRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{#each\s+([^}]+)\}\}(.*?)\{\{/each\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex EachBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{#if\s+([^}]+)\}\}(.*?)\{\{/if\}\}", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex IfBlockRegex();
|
||||
|
||||
[GeneratedRegex(@"\{\{else\}\}(.*)", RegexOptions.Compiled | RegexOptions.Singleline)]
|
||||
private static partial Regex ElseBlockRegex();
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of ILocalizationResolver with hierarchical fallback chain.
|
||||
/// </summary>
|
||||
public sealed class DefaultLocalizationResolver : ILocalizationResolver
|
||||
{
|
||||
private const string DefaultLocale = "en-us";
|
||||
private const string DefaultLanguage = "en";
|
||||
|
||||
private readonly INotifyLocalizationRepository _repository;
|
||||
private readonly ILogger<DefaultLocalizationResolver> _logger;
|
||||
|
||||
public DefaultLocalizationResolver(
|
||||
INotifyLocalizationRepository repository,
|
||||
ILogger<DefaultLocalizationResolver> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<LocalizedString?> ResolveAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
string stringKey,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stringKey);
|
||||
|
||||
locale = NormalizeLocale(locale);
|
||||
var fallbackChain = BuildFallbackChain(locale);
|
||||
|
||||
foreach (var tryLocale in fallbackChain)
|
||||
{
|
||||
var bundle = await _repository.GetByKeyAndLocaleAsync(
|
||||
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = bundle.GetString(stringKey);
|
||||
if (value is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Resolved string '{StringKey}' from bundle '{BundleKey}' locale '{ResolvedLocale}' (requested: {RequestedLocale})",
|
||||
stringKey, bundleKey, tryLocale, locale);
|
||||
|
||||
return new LocalizedString
|
||||
{
|
||||
Value = value,
|
||||
ResolvedLocale = tryLocale,
|
||||
RequestedLocale = locale,
|
||||
FallbackChain = fallbackChain
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try the default bundle
|
||||
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (defaultBundle is not null)
|
||||
{
|
||||
var value = defaultBundle.GetString(stringKey);
|
||||
if (value is not null)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Resolved string '{StringKey}' from default bundle '{BundleKey}' locale '{ResolvedLocale}'",
|
||||
stringKey, bundleKey, defaultBundle.Locale);
|
||||
|
||||
return new LocalizedString
|
||||
{
|
||||
Value = value,
|
||||
ResolvedLocale = defaultBundle.Locale,
|
||||
RequestedLocale = locale,
|
||||
FallbackChain = fallbackChain.Append(defaultBundle.Locale).Distinct().ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"String '{StringKey}' not found in bundle '{BundleKey}' for any locale in chain: {FallbackChain}",
|
||||
stringKey, bundleKey, string.Join(" -> ", fallbackChain));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<string, LocalizedString>> ResolveBatchAsync(
|
||||
string tenantId,
|
||||
string bundleKey,
|
||||
IEnumerable<string> stringKeys,
|
||||
string locale,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(tenantId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(bundleKey);
|
||||
ArgumentNullException.ThrowIfNull(stringKeys);
|
||||
|
||||
locale = NormalizeLocale(locale);
|
||||
var fallbackChain = BuildFallbackChain(locale);
|
||||
var keysToResolve = new HashSet<string>(stringKeys, StringComparer.Ordinal);
|
||||
var results = new Dictionary<string, LocalizedString>(StringComparer.Ordinal);
|
||||
|
||||
// Load all bundles in the fallback chain
|
||||
var bundles = new List<NotifyLocalizationBundle>();
|
||||
foreach (var tryLocale in fallbackChain)
|
||||
{
|
||||
var bundle = await _repository.GetByKeyAndLocaleAsync(
|
||||
tenantId, bundleKey, tryLocale, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (bundle is not null)
|
||||
{
|
||||
bundles.Add(bundle);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default bundle
|
||||
var defaultBundle = await _repository.GetDefaultAsync(tenantId, bundleKey, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (defaultBundle is not null && !bundles.Any(b => b.BundleId == defaultBundle.BundleId))
|
||||
{
|
||||
bundles.Add(defaultBundle);
|
||||
}
|
||||
|
||||
// Resolve each key through the bundles
|
||||
foreach (var key in keysToResolve)
|
||||
{
|
||||
foreach (var bundle in bundles)
|
||||
{
|
||||
var value = bundle.GetString(key);
|
||||
if (value is not null)
|
||||
{
|
||||
results[key] = new LocalizedString
|
||||
{
|
||||
Value = value,
|
||||
ResolvedLocale = bundle.Locale,
|
||||
RequestedLocale = locale,
|
||||
FallbackChain = fallbackChain
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fallback chain for the given locale.
|
||||
/// Example: "pt-br" -> ["pt-br", "pt", "en-us", "en"]
|
||||
/// </summary>
|
||||
private static IReadOnlyList<string> BuildFallbackChain(string locale)
|
||||
{
|
||||
var chain = new List<string> { locale };
|
||||
|
||||
// Add language-only fallback (e.g., "pt" from "pt-br")
|
||||
var dashIndex = locale.IndexOf('-');
|
||||
if (dashIndex > 0)
|
||||
{
|
||||
var languageOnly = locale[..dashIndex];
|
||||
if (!chain.Contains(languageOnly, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.Add(languageOnly);
|
||||
}
|
||||
}
|
||||
|
||||
// Add default locale if not already in chain
|
||||
if (!chain.Contains(DefaultLocale, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.Add(DefaultLocale);
|
||||
}
|
||||
|
||||
// Add default language if not already in chain
|
||||
if (!chain.Contains(DefaultLanguage, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
chain.Add(DefaultLanguage);
|
||||
}
|
||||
|
||||
return chain;
|
||||
}
|
||||
|
||||
private static string NormalizeLocale(string? locale)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(locale))
|
||||
{
|
||||
return DefaultLocale;
|
||||
}
|
||||
|
||||
return locale.ToLowerInvariant().Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Template renderer with support for render options, format conversion, and redaction.
|
||||
/// </summary>
|
||||
public interface INotifyTemplateRenderer
|
||||
{
|
||||
/// <summary>
|
||||
/// Renders a template with the given payload and options.
|
||||
/// </summary>
|
||||
string Render(NotifyTemplate template, JsonNode? payload, TemplateRenderOptions? options = null);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.WebService.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Application-level service for managing versioned templates with localization support.
|
||||
/// </summary>
|
||||
public interface INotifyTemplateService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a template by key and locale, falling back to the default locale if not found.
|
||||
/// </summary>
|
||||
Task<NotifyTemplate?> GetByKeyAsync(
|
||||
string tenantId,
|
||||
string key,
|
||||
string locale,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific template by ID.
|
||||
/// </summary>
|
||||
Task<NotifyTemplate?> GetByIdAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Lists all templates for a tenant, optionally filtered.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<NotifyTemplate>> ListAsync(
|
||||
string tenantId,
|
||||
string? keyPrefix = null,
|
||||
string? locale = null,
|
||||
NotifyChannelType? channelType = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Creates or updates a template with version tracking.
|
||||
/// </summary>
|
||||
Task<NotifyTemplate> UpsertAsync(
|
||||
NotifyTemplate template,
|
||||
string updatedBy,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a template.
|
||||
/// </summary>
|
||||
Task DeleteAsync(
|
||||
string tenantId,
|
||||
string templateId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Renders a template preview with sample payload (no persistence).
|
||||
/// </summary>
|
||||
Task<TemplatePreviewResult> PreviewAsync(
|
||||
NotifyTemplate template,
|
||||
JsonNode? samplePayload,
|
||||
TemplateRenderOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a template preview render.
|
||||
/// </summary>
|
||||
public sealed record TemplatePreviewResult
|
||||
{
|
||||
public required string RenderedBody { get; init; }
|
||||
public required string? RenderedSubject { get; init; }
|
||||
public required NotifyTemplateRenderMode RenderMode { get; init; }
|
||||
public required NotifyDeliveryFormat Format { get; init; }
|
||||
public IReadOnlyList<string> RedactedFields { get; init; } = [];
|
||||
public string? ProvenanceLink { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for template rendering.
|
||||
/// </summary>
|
||||
public sealed record TemplateRenderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Fields to redact from the output (dot-notation paths).
|
||||
/// </summary>
|
||||
public IReadOnlySet<string>? RedactionAllowlist { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to include provenance links in output.
|
||||
/// </summary>
|
||||
public bool IncludeProvenance { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Base URL for provenance links.
|
||||
/// </summary>
|
||||
public string? ProvenanceBaseUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target format override.
|
||||
/// </summary>
|
||||
public NotifyDeliveryFormat? FormatOverride { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -121,12 +121,12 @@ public sealed class AttestationTemplateSeeder : IHostedService
|
||||
var rulesElement = doc.RootElement.GetProperty("rules");
|
||||
|
||||
var channels = channelsElement.EnumerateArray()
|
||||
.Select(ToChannel)
|
||||
.Select(el => ToChannel(el, tenant))
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
|
||||
await channelRepository.UpsertAsync(channel, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var rule in rulesElement.EnumerateArray())
|
||||
@@ -162,7 +162,7 @@ public sealed class AttestationTemplateSeeder : IHostedService
|
||||
description: "Seeded attestation routing rule.");
|
||||
}
|
||||
|
||||
private static NotifyChannel ToChannel(JsonElement element)
|
||||
private static NotifyChannel ToChannel(JsonElement element, string tenantOverride)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
|
||||
@@ -178,7 +178,7 @@ public sealed class AttestationTemplateSeeder : IHostedService
|
||||
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
|
||||
tenantId: tenantOverride,
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
|
||||
@@ -121,12 +121,12 @@ public sealed class RiskTemplateSeeder : IHostedService
|
||||
var rulesElement = doc.RootElement.GetProperty("rules");
|
||||
|
||||
var channels = channelsElement.EnumerateArray()
|
||||
.Select(ToChannel)
|
||||
.Select(el => ToChannel(el, tenant))
|
||||
.ToArray();
|
||||
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
await channelRepository.UpsertAsync(channel with { TenantId = tenant }, cancellationToken).ConfigureAwait(false);
|
||||
await channelRepository.UpsertAsync(channel, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var rule in rulesElement.EnumerateArray())
|
||||
@@ -164,7 +164,7 @@ public sealed class RiskTemplateSeeder : IHostedService
|
||||
description: "Seeded risk routing rule.");
|
||||
}
|
||||
|
||||
private static NotifyChannel ToChannel(JsonElement element)
|
||||
private static NotifyChannel ToChannel(JsonElement element, string tenantOverride)
|
||||
{
|
||||
var channelId = element.GetProperty("channelId").GetString() ?? throw new InvalidOperationException("channelId missing");
|
||||
var type = ParseEnum<NotifyChannelType>(element.GetProperty("type").GetString(), NotifyChannelType.Custom);
|
||||
@@ -180,7 +180,7 @@ public sealed class RiskTemplateSeeder : IHostedService
|
||||
|
||||
return NotifyChannel.Create(
|
||||
channelId: channelId,
|
||||
tenantId: element.GetProperty("tenantId").GetString() ?? "bootstrap",
|
||||
tenantId: tenantOverride,
|
||||
name: name,
|
||||
type: type,
|
||||
config: config,
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Queue/StellaOps.Notify.Queue.csproj" />
|
||||
<ProjectReference Include="../../../Notify/__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../StellaOps.Notifier.Worker/StellaOps.Notifier.Worker.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
Reference in New Issue
Block a user