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