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:
2026-10-24 12:31:35 +03:00
parent b30dba93e8
commit 1631786ce2
105 changed files with 3838 additions and 147 deletions
@@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging;
internal sealed class SendEmailService : ISendEmailService
{
private readonly IEmailChannelRepository _channelRepository;
private readonly INotificationServiceRepository _services;
private readonly IEmailChannelUsageRepository _usageRepository;
private readonly IEmailTemplateService _templateService;
private readonly IEmailTemplateRenderingService _templateRenderingService;
@@ -18,6 +19,7 @@ internal sealed class SendEmailService : ISendEmailService
public SendEmailService(
IEmailChannelRepository channelRepository,
INotificationServiceRepository services,
IEmailChannelUsageRepository usageRepository,
IEmailTemplateService templateService,
IEmailTemplateRenderingService templateRenderingService,
@@ -26,6 +28,7 @@ internal sealed class SendEmailService : ISendEmailService
ILogger<SendEmailService> logger)
{
_channelRepository = channelRepository;
_services = services;
_usageRepository = usageRepository;
_templateService = templateService;
_templateRenderingService = templateRenderingService;
@@ -93,7 +96,9 @@ internal sealed class SendEmailService : ISendEmailService
private async Task<EmailChannel> ResolveChannelAsync(string serviceName, CancellationToken ct)
{
var channels = await _channelRepository.GetByServiceAsync(serviceName, ct);
var service = await _services.GetByNameAsync(serviceName, ct)
?? throw new InvalidOperationException($"Service '{serviceName}' does not exist.");
var channels = await _channelRepository.GetByServiceAsync(service.Id, ct);
return channels
.Where(c => c.IsActive)
@@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="HrynCo.RabbitMq" />
<PackageReference Include="FirebaseAdmin" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
</ItemGroup>
@@ -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;
}