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
@@ -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)