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:
+11
@@ -0,0 +1,11 @@
|
||||
using HrynCo.NotificationService.DAL.Abstract.Templates;
|
||||
|
||||
namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
|
||||
public interface IPushTemplateDefinitionRepository
|
||||
{
|
||||
Task<IReadOnlyList<PushTemplateDefinition>> GetDefinitionsAsync(Guid serviceId, CancellationToken ct = default);
|
||||
Task<PushTemplateDefinition?> GetAsync(Guid serviceId, string key, CancellationToken ct = default);
|
||||
Task SaveAsync(PushTemplateDefinition definition, bool isNew, CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(Guid serviceId, string key, Guid revision, CancellationToken ct = default);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user