feat(templates): add localized push templates and actions

Share template keys across email and push with independent editors and revisions. Render localized actions and preserve version-one delivery.

Ref: IT-1627
This commit is contained in:
2026-10-31 09:38:05 +02:00
parent d4ee8fc0c8
commit aba4173827
79 changed files with 3906 additions and 104 deletions
@@ -0,0 +1,49 @@
using System.Text.Json;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public static class PushTemplateActionValidation
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = false
};
public static PushTemplateActions Parse(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new EmailTemplateWriteException("Actions JSON is required. Use {} for no actions.", false);
try
{
var actions = JsonSerializer.Deserialize<PushTemplateActions>(json, JsonOptions)
?? throw new JsonException();
Validate(actions);
return actions;
}
catch (JsonException)
{
throw new EmailTemplateWriteException("Invalid actions JSON. Use defaultAction and buttons with action and label.", false);
}
}
public static void Validate(PushTemplateActions actions)
{
if (actions.DefaultAction is not null) ValidateKey(actions.DefaultAction);
if (actions.Buttons is null || actions.Buttons.Count > 3)
throw new EmailTemplateWriteException("Configure up to three push buttons.", false);
var keys = new HashSet<string>(StringComparer.Ordinal);
foreach (var button in actions.Buttons)
{
if (button is null) throw new EmailTemplateWriteException("A button needs an action and label.", false);
ValidateKey(button.Action);
if (!keys.Add(button.Action) || string.IsNullOrWhiteSpace(button.Label) || button.Label.Length > 100)
throw new EmailTemplateWriteException("Button actions must be unique and labels must contain 1–100 characters.", false);
}
}
private static void ValidateKey(string key)
{
if (string.IsNullOrWhiteSpace(key) || key.Length > 100 || key.Any(c => !char.IsAsciiLetterOrDigit(c) && c is not '_' and not '-' and not '.'))
throw new EmailTemplateWriteException("Action keys need 1–100 letters, digits, dots, underscores or hyphens.", false);
}
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record PushTemplateActions
{
public string? DefaultAction { get; init; }
public IReadOnlyList<PushTemplateButton> Buttons { get; init; } = [];
}
@@ -0,0 +1,10 @@
using System.Text.Json.Serialization;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
[JsonUnmappedMemberHandling(JsonUnmappedMemberHandling.Disallow)]
public sealed record PushTemplateButton
{
public required string Action { get; init; }
public required string Label { get; init; }
}
@@ -0,0 +1,11 @@
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public sealed class PushTemplateDefinition
{
public Guid Id { get; set; }
public Guid ServiceId { get; set; }
public required string Key { get; set; }
public Guid Revision { get; set; }
public IReadOnlyList<EmailTemplateVariable> Variables { get; set; } = [];
public IReadOnlyList<PushTemplateTranslation> Languages { get; set; } = [];
}
@@ -0,0 +1,9 @@
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public sealed class PushTemplateTranslation
{
public string ActionsJson { get; set; } = "{}";
public required string LanguageCode { get; set; }
public required string Title { get; set; }
public required string Body { get; set; }
}
@@ -0,0 +1,42 @@
using System.Text.RegularExpressions;
namespace HrynCo.NotificationService.DAL.Abstract.Templates;
public static partial class PushTemplateValidation
{
public static void Validate(PushTemplateDefinition definition)
{
EmailTemplateValidation.ValidateIdentity("Push", definition.Key);
EmailTemplateValidation.ValidateVariables(definition.Variables);
var names = definition.Variables.Select(v => v.Name).ToHashSet(StringComparer.Ordinal);
foreach (var language in definition.Languages)
ValidateTranslation(language, names);
if (definition.Languages.Select(l => l.LanguageCode).Distinct(StringComparer.Ordinal).Count() != definition.Languages.Count)
throw new EmailTemplateWriteException("Language codes must be unique within a template.", false);
}
private static void ValidateTranslation(PushTemplateTranslation translation, HashSet<string> names)
{
if (string.IsNullOrWhiteSpace(translation.LanguageCode) || translation.LanguageCode.Length > 10)
throw new EmailTemplateWriteException("A valid language code is required.", false);
if (string.IsNullOrWhiteSpace(translation.Title) || string.IsNullOrWhiteSpace(translation.Body))
throw new EmailTemplateWriteException("Every populated push language needs both Title and Body.", false);
ValidatePlaceholders(translation.Title, names);
ValidatePlaceholders(translation.Body, names);
var actions = PushTemplateActionValidation.Parse(translation.ActionsJson);
foreach (var button in actions.Buttons) ValidatePlaceholders(button.Label, names);
}
private static void ValidatePlaceholders(string text, HashSet<string> names)
{
foreach (Match match in Placeholder().Matches(text))
if (!names.Contains(match.Groups[1].Value))
throw new EmailTemplateWriteException("Every placeholder must be declared in this channel's variables.", false);
var remainder = Placeholder().Replace(text, "");
if (remainder.Contains("{{", StringComparison.Ordinal) || remainder.Contains("}}", StringComparison.Ordinal))
throw new EmailTemplateWriteException("Use complete placeholders in the form {{VariableName}}.", false);
}
[GeneratedRegex(@"\{\{([^{}]+)\}\}")]
private static partial Regex Placeholder();
}