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:
+1
-1
@@ -5,7 +5,7 @@ using HrynCo.NotificationService.Services.Core;
|
||||
namespace HrynCo.NotificationService.Services.EmailChannels.Create;
|
||||
|
||||
public sealed record CreateEmailChannelCommand(
|
||||
string ServiceName,
|
||||
Guid ServiceId,
|
||||
int Priority,
|
||||
EmailChannelType ChannelType,
|
||||
EmailChannelSettings Settings,
|
||||
|
||||
+8
-3
@@ -1,4 +1,3 @@
|
||||
using HrynCo.DAL.Abstract;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Providers;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.Services.Core;
|
||||
@@ -11,21 +10,27 @@ internal sealed class CreateEmailChannelHandler
|
||||
: RequestHandler<CreateEmailChannelCommand, ServiceResult<Guid>>
|
||||
{
|
||||
private readonly IEmailChannelRepository _channels;
|
||||
private readonly INotificationServiceRepository _services;
|
||||
|
||||
public CreateEmailChannelHandler(
|
||||
IContextualSerilogLogger<CreateEmailChannelCommand> logger,
|
||||
IEmailChannelRepository channels)
|
||||
IEmailChannelRepository channels,
|
||||
INotificationServiceRepository services)
|
||||
: base(logger)
|
||||
{
|
||||
_channels = channels;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
protected override async Task<ServiceResult<Guid>> DoOnHandle(
|
||||
CreateEmailChannelCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null)
|
||||
return Failure<Guid>("Select an existing service.", ServiceErrorCode.InvalidRequest);
|
||||
|
||||
var channel = new EmailChannel
|
||||
{
|
||||
ServiceName = request.ServiceName,
|
||||
ServiceId = request.ServiceId,
|
||||
Priority = request.Priority,
|
||||
EmailChannelType = request.ChannelType,
|
||||
Settings = request.Settings,
|
||||
|
||||
+8
-2
@@ -10,19 +10,25 @@ internal sealed class GetEmailChannelsHandler
|
||||
: RequestHandler<GetEmailChannelsQuery, ServiceResult<IReadOnlyList<EmailChannel>>>
|
||||
{
|
||||
private readonly IEmailChannelRepository _channels;
|
||||
private readonly INotificationServiceRepository _services;
|
||||
|
||||
public GetEmailChannelsHandler(
|
||||
IContextualSerilogLogger<GetEmailChannelsQuery> logger,
|
||||
IEmailChannelRepository channels)
|
||||
IEmailChannelRepository channels,
|
||||
INotificationServiceRepository services)
|
||||
: base(logger)
|
||||
{
|
||||
_channels = channels;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
protected override async Task<ServiceResult<IReadOnlyList<EmailChannel>>> DoOnHandle(
|
||||
GetEmailChannelsQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var channels = await _channels.GetByServiceAsync(request.ServiceName, cancellationToken);
|
||||
var service = await _services.GetByNameAsync(request.ServiceName, cancellationToken);
|
||||
if (service is null)
|
||||
return Success<IReadOnlyList<EmailChannel>>([]);
|
||||
var channels = await _channels.GetByServiceAsync(service.Id, cancellationToken);
|
||||
return Success(channels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace HrynCo.NotificationService.Services.EmailChannels.Update;
|
||||
|
||||
public sealed record UpdateEmailChannelCommand(
|
||||
Guid Id,
|
||||
Guid ServiceId,
|
||||
int Priority,
|
||||
EmailChannelSettings Settings,
|
||||
int? DailyLimit,
|
||||
|
||||
+8
-2
@@ -1,4 +1,3 @@
|
||||
using HrynCo.DAL.Abstract;
|
||||
using HrynCo.NotificationService.DAL.Abstract.Repositories;
|
||||
using HrynCo.NotificationService.Services.Core;
|
||||
using HrynCo.NotificationService.Services.Logging;
|
||||
@@ -10,13 +9,16 @@ internal sealed class UpdateEmailChannelHandler
|
||||
: RequestHandler<UpdateEmailChannelCommand, ServiceResult<Unit>>
|
||||
{
|
||||
private readonly IEmailChannelRepository _channels;
|
||||
private readonly INotificationServiceRepository _services;
|
||||
|
||||
public UpdateEmailChannelHandler(
|
||||
IContextualSerilogLogger<UpdateEmailChannelCommand> logger,
|
||||
IEmailChannelRepository channels)
|
||||
IEmailChannelRepository channels,
|
||||
INotificationServiceRepository services)
|
||||
: base(logger)
|
||||
{
|
||||
_channels = channels;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
protected override async Task<ServiceResult<Unit>> DoOnHandle(
|
||||
@@ -27,6 +29,10 @@ internal sealed class UpdateEmailChannelHandler
|
||||
if (channel is null)
|
||||
return Failure<Unit>("Email channel not found.", ServiceErrorCode.NotFound);
|
||||
|
||||
if (request.ServiceId == Guid.Empty || await _services.GetAsync(request.ServiceId, cancellationToken) is null)
|
||||
return Failure<Unit>("Select an existing service.", ServiceErrorCode.InvalidRequest);
|
||||
|
||||
channel.ServiceId = request.ServiceId;
|
||||
channel.Priority = request.Priority;
|
||||
channel.Settings = request.Settings;
|
||||
channel.DailyLimit = request.DailyLimit;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
using HrynCo.NotificationService.Services.Core;
|
||||
using MediatR;
|
||||
|
||||
namespace HrynCo.NotificationService.Services.PushChannels;
|
||||
|
||||
// A class deliberately avoids record-generated ToString exposing the submitted secret.
|
||||
public sealed class SavePushChannelCommand : IRequest<ServiceResult<Guid>>
|
||||
{
|
||||
public Guid Id { get; init; }
|
||||
public Guid ServiceId { get; init; }
|
||||
public required string FirebaseProjectId { get; init; }
|
||||
public string? CredentialsJson { get; init; }
|
||||
public bool IsActive { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
|
||||
using HrynCo.NotificationService.Services.Core;
|
||||
using HrynCo.NotificationService.Services.Logging;
|
||||
using static HrynCo.NotificationService.Services.Core.ServiceResultHelper;
|
||||
|
||||
namespace HrynCo.NotificationService.Services.PushChannels;
|
||||
|
||||
internal sealed class SavePushChannelHandler(
|
||||
IContextualSerilogLogger<SavePushChannelCommand> logger, IPushChannelRepository channels)
|
||||
: RequestHandler<SavePushChannelCommand, ServiceResult<Guid>>(logger)
|
||||
{
|
||||
protected override async Task<ServiceResult<Guid>> DoOnHandle(SavePushChannelCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await channels.GetByIdAsync(request.Id, cancellationToken);
|
||||
if (request.Id != Guid.Empty && existing is null)
|
||||
return Failure<Guid>("Push channel not found.", ServiceErrorCode.NotFound);
|
||||
var projectId = request.FirebaseProjectId.Trim();
|
||||
var credentials = await ResolveCredentialsAsync(request, existing, projectId, cancellationToken);
|
||||
if (!ValidCredentials(credentials, projectId))
|
||||
return Failure<Guid>("Provide a valid Firebase service account JSON for this project. Changing project requires new credentials.", ServiceErrorCode.InvalidRequest);
|
||||
var channel = new PushChannel
|
||||
{
|
||||
Id = existing?.Id ?? Guid.NewGuid(), ServiceId = request.ServiceId,
|
||||
FirebaseProjectId = projectId, IsActive = request.IsActive
|
||||
};
|
||||
return await SaveAsync(channel, credentials!, existing is null, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveCredentialsAsync(SavePushChannelCommand request, PushChannel? existing,
|
||||
string projectId, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(request.CredentialsJson))
|
||||
return request.CredentialsJson;
|
||||
if (existing is null || existing.FirebaseProjectId != projectId)
|
||||
return null;
|
||||
return await channels.GetCredentialsAsync(existing.Id, ct);
|
||||
}
|
||||
|
||||
private static bool ValidCredentials(string? json, string projectId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return false;
|
||||
try
|
||||
{
|
||||
FirebaseServiceAccountCredentials.Parse(json, projectId);
|
||||
return true;
|
||||
}
|
||||
catch (Exception error) when (error is JsonException or FormatException or ArgumentException or CryptographicException)
|
||||
{
|
||||
// Neither parser exceptions nor submitted JSON are sent to logs or clients.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ServiceResult<Guid>> SaveAsync(PushChannel channel, string credentials, bool isNew, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await channels.SaveAsync(channel, credentials, isNew, ct);
|
||||
return Success(channel.Id);
|
||||
}
|
||||
catch (PushChannelWriteException error)
|
||||
{
|
||||
return Failure<Guid>(error.Message, error.IsConflict ? ServiceErrorCode.Conflict : ServiceErrorCode.InvalidRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user