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
@@ -4,7 +4,8 @@ namespace HrynCo.NotificationService.DAL.Abstract.Providers;
public class EmailChannel : Entity
{
public required string ServiceName { get; set; }
public required Guid ServiceId { get; set; }
public string ServiceName { get; set; } = string.Empty;
public int Priority { get; set; }
public EmailChannelType EmailChannelType { get; set; }
public required EmailChannelSettings Settings { get; set; }
@@ -0,0 +1,7 @@
namespace HrynCo.NotificationService.DAL.Abstract.Push;
public interface IPushDeliveryRepository
{
Task<PushDeliveryOutcome> DeliverOnceAsync(Guid messageId, string serviceName,
Func<CancellationToken, Task<PushDeliveryOutcome>> deliver, CancellationToken cancellationToken);
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.DAL.Abstract.Push;
public sealed record PushDeliveryOutcome(string? Error, bool InvalidRegistration = false);
@@ -0,0 +1,44 @@
using System.Net.Mail;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
// Parse only service-account fields; never honor caller-supplied OAuth endpoints.
public sealed class FirebaseServiceAccountCredentials
{
public required string ProjectId { get; init; }
public required string ClientEmail { get; init; }
public required string PrivateKey { get; init; }
public static bool IsValidProjectId(string projectId) => Regex.IsMatch(projectId,
"^[a-z][a-z0-9-]{4,28}[a-z0-9]$", RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
public static FirebaseServiceAccountCredentials Parse(string json, string projectId)
{
if (!IsValidProjectId(projectId) || json.Length > 32768)
throw new FormatException("Invalid Firebase configuration.");
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (Read(root, "type") != "service_account" || Read(root, "project_id") != projectId)
throw new FormatException("The service account must belong to the selected Firebase project.");
var email = Read(root, "client_email");
if (!MailAddress.TryCreate(email, out var address) || address.Address != email)
throw new FormatException("Invalid service account email.");
var key = Read(root, "private_key");
using var rsa = RSA.Create();
rsa.ImportFromPem(key);
// Exporting private parameters also rejects a public-key-only PEM.
rsa.ExportParameters(true);
return new FirebaseServiceAccountCredentials { ProjectId = projectId, ClientEmail = email, PrivateKey = key };
}
private static string Read(JsonElement root, string name)
{
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty(name, out var value)
|| value.ValueKind != JsonValueKind.String)
throw new FormatException("Missing service account fields.");
return value.GetString() ?? string.Empty;
}
}
@@ -0,0 +1,10 @@
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
public interface IPushChannelRepository
{
Task<IReadOnlyList<PushChannel>> GetAllAsync(CancellationToken ct = default);
Task<PushChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<PushChannel?> GetActiveForServiceAsync(Guid serviceId, CancellationToken ct = default);
Task<string?> GetCredentialsAsync(Guid id, CancellationToken ct = default);
Task SaveAsync(PushChannel channel, string credentialsJson, bool isNew, CancellationToken ct = default);
}
@@ -0,0 +1,12 @@
using HrynCo.DAL.Abstract.Entities;
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
// Safe administration/read model: credentials are fetched separately by server code.
public sealed class PushChannel : Entity
{
public required Guid ServiceId { get; set; }
public string ServiceName { get; set; } = string.Empty;
public required string FirebaseProjectId { get; set; }
public bool IsActive { get; set; }
}
@@ -0,0 +1,6 @@
namespace HrynCo.NotificationService.DAL.Abstract.PushChannels;
public sealed class PushChannelWriteException(string message, bool isConflict = true) : Exception(message)
{
public bool IsConflict { get; } = isConflict;
}
@@ -5,7 +5,7 @@ namespace HrynCo.NotificationService.DAL.Abstract.Repositories;
public interface IEmailChannelRepository
{
Task<IReadOnlyList<EmailChannel>> GetAllAsync(CancellationToken ct = default);
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(string serviceName, CancellationToken ct = default);
Task<IReadOnlyList<EmailChannel>> GetByServiceAsync(Guid serviceId, CancellationToken ct = default);
Task<EmailChannel?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<ChannelWithUsage>> GetAllWithUsageSummaryAsync(DateOnly today, CancellationToken ct = default);
Task AddAsync(EmailChannel channel, CancellationToken ct = default);