feat(push): add service-owned firebase channels and delivery
Add push contracts, provider delivery and channel diagnostics. Link email channels to services and document migration and validation. Ref: IT-1074
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
public sealed class FcmPushSender(
|
||||
INotificationServiceRepository services, IPushChannelRepository channels, IFirebasePushClient client) : IPushSender
|
||||
{
|
||||
public async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken)
|
||||
{
|
||||
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.");
|
||||
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);
|
||||
}
|
||||
|
||||
private async Task<PushDeliveryOutcome> SendWithCredentialsAsync(Guid messageId, SendPushMessageData data,
|
||||
string projectId, string? json, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var credentials = FirebaseServiceAccountCredentials.Parse(json ?? string.Empty, projectId);
|
||||
return await client.SendAsync(messageId, data, credentials, ct);
|
||||
}
|
||||
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
|
||||
{
|
||||
// Never propagate credential parser details into broker retry logs or replies.
|
||||
return new PushDeliveryOutcome("The push channel Firebase configuration is invalid.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
public sealed class FcmPushSettings
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
using FirebaseAdmin.Messaging;
|
||||
using FirebaseAdmin;
|
||||
using Google.Apis.Auth.OAuth2;
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
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,
|
||||
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
|
||||
{
|
||||
var remaining = data.ExpiresAt - DateTimeOffset.UtcNow;
|
||||
if (remaining <= TimeSpan.Zero) return new PushDeliveryOutcome("Push expired.");
|
||||
return await SendMessageAsync(new Message
|
||||
{
|
||||
Token = data.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)
|
||||
}
|
||||
}, credentials, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<PushDeliveryOutcome> SendTestAsync(string token, FirebaseServiceAccountCredentials credentials,
|
||||
CancellationToken cancellationToken) => SendMessageAsync(new Message
|
||||
{
|
||||
Token = token,
|
||||
Notification = new Notification
|
||||
{
|
||||
Title = "Notification Service test",
|
||||
Body = "This is a test notification from the channel administration page."
|
||||
},
|
||||
Android = new AndroidConfig { Priority = Priority.High, TimeToLive = TimeSpan.FromMinutes(5) }
|
||||
}, credentials, cancellationToken);
|
||||
|
||||
public async Task<PushDeliveryOutcome> CheckAuthorizationAsync(FirebaseServiceAccountCredentials credentials,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
try
|
||||
{
|
||||
var credential = CreateCredential(credentials).CreateScoped("https://www.googleapis.com/auth/firebase.messaging");
|
||||
await credential.UnderlyingCredential.GetAccessTokenForRequestAsync(cancellationToken: timeout.Token);
|
||||
return new PushDeliveryOutcome(null);
|
||||
}
|
||||
catch (Google.Apis.Auth.OAuth2.Responses.TokenResponseException)
|
||||
{
|
||||
return new PushDeliveryOutcome("Firebase OAuth authorization failed.");
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new PushDeliveryOutcome("Firebase authorization check timed out.");
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return new PushDeliveryOutcome("Could not reach Firebase authorization service.");
|
||||
}
|
||||
}
|
||||
|
||||
private static GoogleCredential CreateCredential(FirebaseServiceAccountCredentials credentials) =>
|
||||
GoogleCredential.FromServiceAccountCredential(new ServiceAccountCredential(
|
||||
new ServiceAccountCredential.Initializer(credentials.ClientEmail)
|
||||
{ ProjectId = credentials.ProjectId }.FromPrivateKey(credentials.PrivateKey)));
|
||||
|
||||
private static async Task<PushDeliveryOutcome> SendMessageAsync(Message message,
|
||||
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
FirebaseApp? app = null;
|
||||
try
|
||||
{
|
||||
app = FirebaseApp.Create(new AppOptions
|
||||
{
|
||||
ProjectId = credentials.ProjectId,
|
||||
Credential = CreateCredential(credentials)
|
||||
}, "push-" + Guid.NewGuid().ToString("N"));
|
||||
await FirebaseMessaging.GetMessaging(app).SendAsync(message, timeout.Token);
|
||||
return new PushDeliveryOutcome(null);
|
||||
}
|
||||
catch (FirebaseMessagingException exception)
|
||||
{
|
||||
var header = exception.HttpResponse?.Headers.RetryAfter;
|
||||
var retryAfter = header?.Delta ?? (header?.Date - DateTimeOffset.UtcNow) ?? TimeSpan.Zero;
|
||||
return Classify(exception.MessagingErrorCode, retryAfter);
|
||||
}
|
||||
catch (Google.Apis.Auth.OAuth2.Responses.TokenResponseException)
|
||||
{
|
||||
return new PushDeliveryOutcome("FCM authorization failed.");
|
||||
}
|
||||
catch (FirebaseException)
|
||||
{
|
||||
return new PushDeliveryOutcome("FCM configuration failed.");
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new PushRetryException(TimeSpan.FromMinutes(1));
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
throw new PushRetryException(TimeSpan.FromMinutes(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
app?.Delete();
|
||||
}
|
||||
}
|
||||
|
||||
internal static PushDeliveryOutcome Classify(MessagingErrorCode? code, TimeSpan retryAfter = default) => code switch
|
||||
{
|
||||
MessagingErrorCode.Unregistered => new PushDeliveryOutcome("FCM registration is no longer valid.", true),
|
||||
MessagingErrorCode.Unavailable or MessagingErrorCode.Internal or MessagingErrorCode.QuotaExceeded =>
|
||||
throw new PushRetryException(retryAfter),
|
||||
MessagingErrorCode.SenderIdMismatch => new PushDeliveryOutcome("FCM sender configuration does not match."),
|
||||
MessagingErrorCode.InvalidArgument => new PushDeliveryOutcome("FCM rejected the push payload."),
|
||||
_ => new PushDeliveryOutcome("FCM authorization or configuration failure.")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
|
||||
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,
|
||||
FirebaseServiceAccountCredentials credentials, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||
|
||||
public interface IPushSender
|
||||
{
|
||||
Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
public sealed class PushChannelTestService(IPushChannelRepository channels, IFirebasePushClient firebase)
|
||||
{
|
||||
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 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);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveCredentialsAsync(PushChannelTestSettings settings, string project, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(settings.CredentialsJson)) return settings.CredentialsJson;
|
||||
if (settings.ChannelId is not Guid id) return null;
|
||||
var saved = await channels.GetByIdAsync(id, ct);
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
var credentials = FirebaseServiceAccountCredentials.Parse(json, project);
|
||||
return send ? await firebase.SendTestAsync(token!.Trim(), credentials, ct)
|
||||
: await firebase.CheckAuthorizationAsync(credentials, ct);
|
||||
}
|
||||
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
|
||||
{
|
||||
return new PushDeliveryOutcome("Provide a valid Firebase service account JSON for this project.");
|
||||
}
|
||||
catch (PushRetryException)
|
||||
{
|
||||
return new PushDeliveryOutcome("Firebase is temporarily unavailable or throttled. Try again later; acceptance was not confirmed.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ValidToken(string? token) => !string.IsNullOrWhiteSpace(token)
|
||||
&& token.Trim().Length <= 4096 && !token.Trim().Any(char.IsWhiteSpace);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
public sealed class PushChannelTestSettings
|
||||
{
|
||||
public Guid? ChannelId { get; set; }
|
||||
public string? FirebaseProjectId { get; set; }
|
||||
public string? CredentialsJson { get; set; }
|
||||
public string? RecipientToken { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
public static class PushDeliveryMetrics
|
||||
{
|
||||
private static readonly Meter Meter = new("HrynCo.NotificationService.Push", "1.0");
|
||||
private static readonly Counter<long> Outcomes = Meter.CreateCounter<long>("push.provider.outcomes");
|
||||
private static readonly Counter<long> Retries = Meter.CreateCounter<long>("push.provider.retries");
|
||||
|
||||
public static void RecordOutcome(bool succeeded) =>
|
||||
Outcomes.Add(1, new KeyValuePair<string, object?>("outcome", succeeded ? "accepted" : "failed"));
|
||||
|
||||
public static void RecordRetry() => Retries.Add(1);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Push;
|
||||
using Hrynco.RabbitMq;
|
||||
|
||||
public sealed class PushDeliveryService(IPushDeliveryRepository deliveries, IPushSender sender, IRabbitMqPublisher publisher)
|
||||
{
|
||||
public async Task ProcessAsync(Guid messageId, SendPushMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
PushDeliveryOutcome outcome = await DeliverWithRetryAsync(messageId, message.Data, cancellationToken);
|
||||
await PublishResultAsync(message, outcome, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PushDeliveryOutcome> DeliverWithRetryAsync(Guid messageId, SendPushMessageData data,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await deliveries.DeliverOnceAsync(messageId, data.ServiceName,
|
||||
ct => SendAsync(messageId, data, ct), cancellationToken);
|
||||
}
|
||||
catch (PushRetryException exception)
|
||||
{
|
||||
var delay = RetryDelay(attempt, exception.RetryAfter);
|
||||
if (attempt == 2 || DateTimeOffset.UtcNow.Add(delay) >= data.ExpiresAt) break;
|
||||
PushDeliveryMetrics.RecordRetry();
|
||||
// The failed transaction has been disposed; do not hold a DB lock while backing off.
|
||||
await Task.Delay(delay, cancellationToken);
|
||||
}
|
||||
}
|
||||
return await deliveries.DeliverOnceAsync(messageId, data.ServiceName,
|
||||
_ => Task.FromResult(new PushDeliveryOutcome("Push delivery retries exhausted or message expired.")), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<PushDeliveryOutcome> SendAsync(Guid messageId, SendPushMessageData data, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await sender.SendAsync(messageId, data, cancellationToken);
|
||||
PushDeliveryMetrics.RecordOutcome(outcome.Error is null);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
internal static TimeSpan RetryDelay(int attempt, TimeSpan retryAfter) =>
|
||||
TimeSpan.FromSeconds(Math.Max(60 * Math.Pow(2, attempt), retryAfter.TotalSeconds) + Random.Shared.NextDouble() * 5);
|
||||
|
||||
public async Task CompleteFailureAsync(Guid messageId, SendPushMessage message, CancellationToken cancellationToken)
|
||||
{
|
||||
var outcome = await deliveries.DeliverOnceAsync(messageId, message.Data.ServiceName,
|
||||
_ => Task.FromResult(new PushDeliveryOutcome("Push processing retries exhausted.")), cancellationToken);
|
||||
await PublishResultAsync(message, outcome, cancellationToken);
|
||||
}
|
||||
|
||||
public Task PublishResultAsync(SendPushMessage message, PushDeliveryOutcome outcome, CancellationToken cancellationToken)
|
||||
{
|
||||
var replyTo = message.CorrelationContext.ReplyTo;
|
||||
if (string.IsNullOrWhiteSpace(replyTo)) return Task.CompletedTask;
|
||||
return publisher.PublishAsync(replyTo, new NotificationResultMessage
|
||||
{
|
||||
CorrelationContext = message.CorrelationContext with { ReplyTo = null },
|
||||
Data = new NotificationResultData
|
||||
{
|
||||
ServiceName = message.Data.ServiceName, RecipientEmail = "", TemplateKey = "PushDiagnostic",
|
||||
Timestamp = DateTimeOffset.UtcNow, ErrorMessage = outcome.Error,
|
||||
PushRegistrationId = message.Data.RegistrationId, PushBindingId = message.Data.BindingId,
|
||||
PushTokenHash = message.Data.TokenHash, InvalidPushRegistration = outcome.InvalidRegistration
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
using HrynCo.NotificationService.Contracts.Messages;
|
||||
|
||||
public static class PushMessageValidator
|
||||
{
|
||||
public static bool IsValid(SendPushMessage? message)
|
||||
{
|
||||
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.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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace HrynCo.NotificationService.Worker.Services.Push;
|
||||
|
||||
public sealed class PushRetryException(TimeSpan retryAfter)
|
||||
: Exception("FCM temporarily unavailable; retry required.")
|
||||
{
|
||||
public TimeSpan RetryAfter { get; } = retryAfter;
|
||||
}
|
||||
Reference in New Issue
Block a user