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
@@ -4,24 +4,40 @@ using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed class FcmPushSender(
INotificationServiceRepository services, IPushChannelRepository channels, IFirebasePushClient client) : IPushSender
INotificationServiceRepository services, IPushChannelRepository channels, IFirebasePushClient client,
IPushTemplateDefinitionRepository templates) : IPushSender
{
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken)
{
if (!PushMessageValidator.IsSupportedVersion(data.Version))
return new PushDeliveryOutcome("Unsupported push payload version. Supported versions are 1 and 2.");
if (!PushMessageValidator.IsValidContent(data)) return new PushDeliveryOutcome("Invalid push content for the requested version.");
if (data.ExpiresAt <= DateTimeOffset.UtcNow) return new PushDeliveryOutcome("Push expired.");
var service = await services.GetByNameAsync(data.ServiceName, cancellationToken);
if (service is null) return new PushDeliveryOutcome("Push service is not configured.");
RenderedPushMessageData rendered;
try { rendered = await RenderAsync(service.Id, data, cancellationToken); }
catch (EmailTemplateWriteException) { return new PushDeliveryOutcome("Push template data or action configuration is invalid."); }
var channel = await channels.GetActiveForServiceAsync(service.Id, cancellationToken);
if (channel is null) return new PushDeliveryOutcome("No active push channel is configured for this service.");
var credentials = await channels.GetCredentialsAsync(channel.Id, cancellationToken);
return await SendWithCredentialsAsync(messageId, data, channel.FirebaseProjectId, credentials, cancellationToken);
return await SendWithCredentialsAsync(messageId, rendered, channel.FirebaseProjectId, credentials, cancellationToken);
}
private async Task<PushDeliveryOutcome> SendWithCredentialsAsync(Guid messageId, SendPushMessageData data,
private async Task<RenderedPushMessageData> RenderAsync(Guid serviceId, SendPushMessageData data, CancellationToken ct)
{
if (data.Version == 1) return new RenderedPushMessageData(data, data.Title!, data.Body!, null, []);
var template = await templates.GetAsync(serviceId, data.TemplateKey!, ct)
?? throw new EmailTemplateWriteException("Push template is not configured.", false);
return PushTemplateRenderer.Render(template, data);
}
private async Task<PushDeliveryOutcome> SendWithCredentialsAsync(Guid messageId, RenderedPushMessageData data,
string projectId, string? json, CancellationToken ct)
{
try
@@ -3,42 +3,91 @@ namespace HrynCo.NotificationService.Worker.Services.Push;
using FirebaseAdmin.Messaging;
using FirebaseAdmin;
using Google.Apis.Auth.OAuth2;
using HrynCo.NotificationService.Contracts.Messages;
using System.Text;
using System.Text.Json;
using HrynCo.NotificationService.DAL.Abstract.Push;
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
public sealed class FirebasePushClient : IFirebasePushClient
{
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data,
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
};
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, RenderedPushMessageData data,
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
{
var remaining = data.ExpiresAt - DateTimeOffset.UtcNow;
if (!PushMessageValidator.IsSupportedVersion(data.Delivery.Version))
return new PushDeliveryOutcome("Unsupported push payload version. Supported versions are 1 and 2.");
var remaining = data.Delivery.ExpiresAt - DateTimeOffset.UtcNow;
if (remaining <= TimeSpan.Zero) return new PushDeliveryOutcome("Push expired.");
var payload = BuildPayload(messageId, data);
if (data.Delivery.Version == 2 && Encoding.UTF8.GetByteCount(JsonSerializer.Serialize(payload)) > 3500)
return new PushDeliveryOutcome("Rendered push payload is too large.");
return await SendMessageAsync(new Message
{
Token = data.Token,
Token = data.Delivery.Token,
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = remaining },
Data = new Dictionary<string, string>
{
["version"] = "1", ["messageId"] = messageId.ToString("D"),
["bindingId"] = data.BindingId.ToString("D"), ["title"] = data.Title,
["body"] = data.Body, ["route"] = data.Route,
["expiresAt"] = data.ExpiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
}
Data = payload
}, credentials, cancellationToken);
}
public Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials,
CancellationToken cancellationToken) => SendMessageAsync(new Message
internal static Dictionary<string, string> BuildPayload(Guid messageId, RenderedPushMessageData data)
{
var payload = BasePayload(messageId, data.Delivery.BindingId, data.Delivery.ExpiresAt, data.Title, data.Body);
switch (data.Delivery.Version)
{
Token = token,
Notification = new Notification
{
Title = "Notification Service test",
Body = "This is a test notification from the channel administration page."
},
case 1:
payload["version"] = "1";
payload["route"] = data.Delivery.Route;
break;
case 2:
AddActions(payload, data.DefaultAction, data.Buttons);
break;
default: throw new ArgumentOutOfRangeException(nameof(data), "Unsupported push payload version.");
}
return payload;
}
private static Dictionary<string, string> BasePayload(Guid messageId, Guid bindingId, DateTimeOffset expiresAt, string title, string body) => new()
{
["messageId"] = messageId.ToString("D"), ["bindingId"] = bindingId.ToString("D"),
["title"] = title, ["body"] = body,
["expiresAt"] = expiresAt.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
};
private static void AddActions(Dictionary<string, string> payload, RenderedPushAction? defaultAction, IReadOnlyList<RenderedPushAction> buttons)
{
payload["version"] = "2";
payload["actions"] = JsonSerializer.Serialize(new { defaultAction, buttons }, JsonOptions);
}
public Task<PushDeliveryOutcome> SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials,
CancellationToken cancellationToken)
{
if (message.Validate() is { } error) return Task.FromResult(new PushDeliveryOutcome(error));
return SendMessageAsync(BuildTestMessage(message), credentials, cancellationToken);
}
internal static Message BuildTestMessage(PushChannelTestMessage test)
{
if (test.Validate() is { } error) throw new ArgumentException(error, nameof(test));
const string title = "Notification Service test";
const string body = "This is a test notification from the channel administration page.";
var message = new Message
{
Token = test.Token,
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = TimeSpan.FromMinutes(5) }
}, credentials, cancellationToken);
};
if (test.Version == 1) message.Notification = new Notification { Title = title, Body = body };
else
{
var payload = BasePayload(Guid.NewGuid(), test.BindingId!.Value, DateTimeOffset.UtcNow.AddMinutes(5), title, body);
AddActions(payload, new RenderedPushAction(test.ActionKey!), [new RenderedPushAction(test.ActionKey!, Label: test.ButtonLabel)]);
message.Data = payload;
}
return message;
}
public async Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials,
CancellationToken cancellationToken)
@@ -7,7 +7,7 @@ namespace HrynCo.NotificationService.Worker.Services.Push;
public interface IFirebasePushClient
{
Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data,
Task<PushDeliveryOutcome> SendTestAsync(PushChannelTestMessage message, FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
Task<PushDeliveryOutcome> SendAsync(Guid messageId, RenderedPushMessageData data,
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
}
@@ -0,0 +1,31 @@
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed record PushChannelTestMessage
{
public required string Token { get; init; }
public int Version { get; init; } = 1;
public Guid? BindingId { get; init; }
public string? ActionKey { get; init; }
public string? ButtonLabel { get; init; }
public string? Validate()
{
if (!PushMessageValidator.IsSupportedVersion(Version)) return "Unsupported push payload version. Supported versions are 1 and 2.";
if (string.IsNullOrWhiteSpace(Token) || Token.Length > 4096 || Token.Any(char.IsWhiteSpace))
return "Enter a valid recipient FCM token.";
if (Version == 1) return null;
if (BindingId is null || BindingId == Guid.Empty) return "Enter the device's current binding ID for the button-format test.";
try
{
PushTemplateActionValidation.Validate(new PushTemplateActions
{
DefaultAction = ActionKey,
Buttons = [new PushTemplateButton { Action = ActionKey!, Label = ButtonLabel! }]
});
return null;
}
catch (EmailTemplateWriteException) { return "Enter a valid action key and a button label (1–100 characters)."; }
}
}
@@ -9,13 +9,17 @@ public sealed class PushChannelTestService(IPushChannelRepository channels, IFir
{
public async Task<PushDeliveryOutcome> TestAsync(PushChannelTestSettings settings, bool send, CancellationToken ct)
{
if (send && !ValidToken(settings.RecipientToken))
return new PushDeliveryOutcome("Enter a valid recipient FCM token.");
var message = new PushChannelTestMessage
{
Token = settings.RecipientToken?.Trim() ?? "", Version = settings.Version,
BindingId = settings.BindingId, ActionKey = settings.ActionKey, ButtonLabel = settings.ButtonLabel
};
if (send && message.Validate() is { } error) return new PushDeliveryOutcome(error);
var project = settings.FirebaseProjectId?.Trim() ?? "";
var json = await ResolveCredentialsAsync(settings, project, ct);
if (string.IsNullOrWhiteSpace(json))
return new PushDeliveryOutcome("Provide credentials for this project. Saved credentials can only be used with the unchanged project.");
return await ExecuteAsync(json, project, settings.RecipientToken, send, ct);
return await ExecuteAsync(json, project, message, send, ct);
}
private async Task<string?> ResolveCredentialsAsync(PushChannelTestSettings settings, string project, CancellationToken ct)
@@ -26,12 +30,12 @@ public sealed class PushChannelTestService(IPushChannelRepository channels, IFir
return saved?.FirebaseProjectId == project ? await channels.GetCredentialsAsync(id, ct) : null;
}
private async Task<PushDeliveryOutcome> ExecuteAsync(string json, string project, string? token, bool send, CancellationToken ct)
private async Task<PushDeliveryOutcome> ExecuteAsync(string json, string project, PushChannelTestMessage message, bool send, CancellationToken ct)
{
try
{
var credentials = FirebaseServiceAccountCredentials.Parse(json, project);
return send ? await firebase.SendTestAsync(token!.Trim(), credentials, ct)
return send ? await firebase.SendTestAsync(message, credentials, ct)
: await firebase.CheckAuthorizationAsync(credentials, ct);
}
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
@@ -44,6 +48,4 @@ public sealed class PushChannelTestService(IPushChannelRepository channels, IFir
}
}
private static bool ValidToken(string? token) => !string.IsNullOrWhiteSpace(token)
&& token.Trim().Length <= 4096 && !token.Trim().Any(char.IsWhiteSpace);
}
@@ -6,4 +6,8 @@ public sealed class PushChannelTestSettings
public string? FirebaseProjectId { get; set; }
public string? CredentialsJson { get; set; }
public string? RecipientToken { get; set; }
public int Version { get; set; } = 1;
public Guid? BindingId { get; set; }
public string? ActionKey { get; set; }
public string? ButtonLabel { get; set; }
}
@@ -61,7 +61,7 @@ public sealed class PushDeliveryService(IPushDeliveryRepository deliveries, IPus
CorrelationContext = message.CorrelationContext with { ReplyTo = null },
Data = new NotificationResultData
{
ServiceName = message.Data.ServiceName, RecipientEmail = "", TemplateKey = "PushDiagnostic",
ServiceName = message.Data.ServiceName, RecipientEmail = "", TemplateKey = message.Data.Version == 1 ? "PushDiagnostic" : message.Data.TemplateKey!,
Timestamp = DateTimeOffset.UtcNow, ErrorMessage = outcome.Error,
PushRegistrationId = message.Data.RegistrationId, PushBindingId = message.Data.BindingId,
PushTokenHash = message.Data.TokenHash, InvalidPushRegistration = outcome.InvalidRegistration
@@ -8,11 +8,27 @@ public static class PushMessageValidator
{
if (message?.Data is not { } data || message.CorrelationContext is null) return false;
return Guid.TryParse(message.CorrelationContext.CorrelationId, out _) &&
data.Version == 1 && data.RegistrationId != Guid.Empty && data.BindingId != Guid.Empty &&
data.RegistrationId != Guid.Empty && data.BindingId != Guid.Empty &&
data.ServiceName is { Length: > 0 and <= 100 } && data.UserId != Guid.Empty &&
data.Token is { Length: > 0 and <= 2048 } && data.TokenHash is { Length: 64 } &&
data.Title is { Length: > 0 and <= 100 } && data.Body is { Length: > 0 and <= 500 } &&
data.Route == "/home" && data.ExpiresAt <= DateTimeOffset.UtcNow.AddMinutes(15);
IsValidContent(data) && data.ExpiresAt <= DateTimeOffset.UtcNow.AddMinutes(15);
}
public static bool IsSupportedVersion(int version) => version is 1 or 2;
public static bool IsValidContent(SendPushMessageData data) => data.Version switch
{
1 => data.Title is { Length: > 0 and <= 100 } && data.Body is { Length: > 0 and <= 500 } && data.Route == "/home",
2 => ValidTemplateData(data),
_ => false
};
private static bool ValidTemplateData(SendPushMessageData data) =>
data.TemplateKey is { Length: > 0 and <= 100 } &&
data.LanguageCode is { Length: > 0 and <= 10 } &&
data.TemplateData?.Bindings is not null && data.TemplateData.Actions is not null &&
data.TemplateData.Bindings.All(pair => !string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null) &&
data.TemplateData.Actions.All(pair => !string.IsNullOrWhiteSpace(pair.Key) && pair.Value is not null &&
(pair.Value.ObjectId is null || pair.Value.ObjectId is { Length: > 0 and <= 256 }));
}
@@ -0,0 +1,56 @@
using System.Globalization;
using System.Text.RegularExpressions;
using HrynCo.NotificationService.Contracts.Messages;
using HrynCo.NotificationService.DAL.Abstract.Templates;
namespace HrynCo.NotificationService.Worker.Services.Push;
public static partial class PushTemplateRenderer
{
public static RenderedPushMessageData Render(PushTemplateDefinition template, SendPushMessageData message)
{
if (message.Version != 2 || !PushMessageValidator.IsValidContent(message))
throw new EmailTemplateWriteException("Invalid version 2 push template data.", false);
var data = message.TemplateData!;
var language = template.Languages.SingleOrDefault(l => l.LanguageCode == message.LanguageCode)
?? template.Languages.SingleOrDefault(l => l.LanguageCode == "en")
?? throw new EmailTemplateWriteException("Push template language and English fallback are missing.", false);
PushTemplateValidation.Validate(template);
ValidateRequiredBindings(template, data.Bindings);
var actions = PushTemplateActionValidation.Parse(language.ActionsJson);
return new RenderedPushMessageData(message,
RenderText(language.Title, data.Bindings, 100),
RenderText(language.Body, data.Bindings, 500),
actions.DefaultAction is null ? null : ResolveAction(actions.DefaultAction, null, data),
actions.Buttons.Select(b => ResolveAction(b.Action,
RenderText(b.Label, data.Bindings, 100), data)).ToList());
}
private static void ValidateRequiredBindings(PushTemplateDefinition template, IReadOnlyDictionary<string, string> bindings)
{
foreach (var variable in template.Variables.Where(v => v.Required))
if (!bindings.TryGetValue(variable.Name, out var value) || string.IsNullOrWhiteSpace(value))
throw new EmailTemplateWriteException("A required push binding is missing.", false);
}
private static RenderedPushAction ResolveAction(string key, string? label, PushTemplateData data)
{
if (!data.Actions.TryGetValue(key, out var metadata) || metadata is null)
throw new EmailTemplateWriteException("A configured push action is missing from message metadata.", false);
return new RenderedPushAction(key, metadata.ObjectId, label);
}
private static string RenderText(string template, IReadOnlyDictionary<string, string> bindings, int limit)
{
var value = Placeholder().Replace(template, match => bindings.GetValueOrDefault(match.Groups[1].Value) ?? "");
if (string.IsNullOrWhiteSpace(value))
throw new EmailTemplateWriteException("Rendered push text is empty.", false);
if (value.Length <= limit) return value;
var starts = StringInfo.ParseCombiningCharacters(value);
var end = starts.LastOrDefault(offset => offset <= limit - 1);
return value[..end] + "…";
}
[GeneratedRegex(@"\{\{([^{}]+)\}\}")]
private static partial Regex Placeholder();
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed record RenderedPushAction(string Action, string? ObjectId = null, string? Label = null);
@@ -0,0 +1,7 @@
using HrynCo.NotificationService.Contracts.Messages;
namespace HrynCo.NotificationService.Worker.Services.Push;
public sealed record RenderedPushMessageData(
SendPushMessageData Delivery, string Title, string Body,
RenderedPushAction? DefaultAction, IReadOnlyList<RenderedPushAction> Buttons);