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:
@@ -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);
|
||||
Reference in New Issue
Block a user