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
@@ -14,7 +14,7 @@ using Microsoft.AspNetCore.Mvc;
namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/channels")]
public class AdminChannelsController(IMediator mediator) : Controller
public class AdminChannelsController(IMediator mediator, INotificationServiceRepository services) : Controller
{
// GET /admin/channels
@@ -33,10 +33,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
// GET /admin/channels/create
[HttpGet("create")]
public IActionResult Create()
{
return View("Edit", new EmailChannelEditViewModel());
}
public Task<IActionResult> Create(CancellationToken ct) => EditorViewAsync(new EmailChannelEditViewModel(), ct);
// GET /admin/channels/{id}
[HttpGet("{id:guid}")]
@@ -55,7 +52,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
var vm = new EmailChannelEditViewModel
{
Id = channel.Id,
ServiceName = channel.ServiceName,
ServiceId = channel.ServiceId,
Priority = channel.Priority,
EmailChannelType = channel.EmailChannelType,
DailyLimit = channel.DailyLimit,
@@ -70,7 +67,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
FromName = smtp.FromName
};
return View(vm);
return await EditorViewAsync(vm, ct);
}
// POST /admin/channels/save
@@ -79,13 +76,13 @@ public class AdminChannelsController(IMediator mediator) : Controller
public async Task<IActionResult> Save(EmailChannelEditViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid)
return EditWithErrors(model);
return await EditorViewAsync(model, ct);
var (password, passwordError) = await ResolvePasswordForSave(model, ct);
if (passwordError is not null)
{
ModelState.AddModelError(nameof(model.Password), passwordError);
return EditWithErrors(model);
return await EditorViewAsync(model, ct);
}
var smtpSettings = new SmtpChannelSettings
@@ -102,7 +99,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
if (model.IsNew)
{
var command = new CreateEmailChannelCommand(
model.ServiceName,
model.ServiceId!.Value,
model.Priority,
EmailChannelType.Smtp,
smtpSettings,
@@ -115,13 +112,14 @@ public class AdminChannelsController(IMediator mediator) : Controller
if (!result.IsSuccess)
{
ModelState.AddModelError("", result.Error?.Message ?? "Failed to create channel.");
return EditWithErrors(model);
return await EditorViewAsync(model, ct);
}
}
else
{
var command = new UpdateEmailChannelCommand(
model.Id,
model.ServiceId!.Value,
model.Priority,
smtpSettings,
model.DailyLimit,
@@ -133,7 +131,7 @@ public class AdminChannelsController(IMediator mediator) : Controller
if (!result.IsSuccess)
{
ModelState.AddModelError("", result.Error?.Message ?? "Failed to update channel.");
return EditWithErrors(model);
return await EditorViewAsync(model, ct);
}
}
@@ -157,9 +155,12 @@ public class AdminChannelsController(IMediator mediator) : Controller
return (saved.Password, null);
}
private IActionResult EditWithErrors(EmailChannelEditViewModel model)
private async Task<IActionResult> EditorViewAsync(EmailChannelEditViewModel model, CancellationToken ct)
{
model.Password = null;
model.Services = (await services.GetAllAsync(ct))
.Select(service => new Microsoft.AspNetCore.Mvc.Rendering.SelectListItem(service.Name, service.Id.ToString()))
.ToList();
return View("Edit", model);
}
@@ -227,5 +228,3 @@ public class AdminChannelsController(IMediator mediator) : Controller
return RedirectToAction(nameof(Index));
}
}
public record TestChannelRequest(string ToEmail);
@@ -0,0 +1,81 @@
using HrynCo.NotificationService.DAL.Abstract.PushChannels;
using HrynCo.NotificationService.DAL.Abstract.Repositories;
using HrynCo.NotificationService.Services.PushChannels;
using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
using MediatR;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using HrynCo.NotificationService.Worker.Services.Push;
namespace HrynCo.NotificationService.Web.Controllers.Admin;
[Route("admin/push-channels")]
public sealed class AdminPushChannelsController(
IPushChannelRepository channels, INotificationServiceRepository services, IMediator mediator,
PushChannelTestService tester) : Controller
{
[HttpPost("test-authorization")]
[ValidateAntiForgeryToken]
[RequestSizeLimit(131072)]
public async Task<IActionResult> TestAuthorization([FromBody] PushChannelTestSettings? settings, CancellationToken ct)
{
if (settings is null || !ModelState.IsValid) return BadRequest(new { success = false, message = "Invalid test settings." });
var result = await tester.TestAsync(settings, false, ct);
return Json(new { success = result.Error is null, message = result.Error ??
"OAuth access token obtained. FCM send permission and device delivery have not been verified. No push was sent." });
}
[HttpPost("test-send")]
[ValidateAntiForgeryToken]
[RequestSizeLimit(131072)]
public async Task<IActionResult> TestSend([FromBody] PushChannelTestSettings? settings, CancellationToken ct)
{
if (settings is null || !ModelState.IsValid) return BadRequest(new { success = false, message = "Invalid test settings." });
var result = await tester.TestAsync(settings, true, ct);
return Json(new { success = result.Error is null, message = result.Error ??
"Firebase accepted the test message. This does not confirm delivery or display on the device." });
}
[HttpGet("")]
public async Task<IActionResult> Index(CancellationToken ct) => View(await channels.GetAllAsync(ct));
[HttpGet("create")]
public Task<IActionResult> Create(CancellationToken ct) => EditorViewAsync(new PushChannelEditViewModel(), ct);
[HttpGet("{id:guid}")]
public async Task<IActionResult> Edit(Guid id, CancellationToken ct)
{
var channel = await channels.GetByIdAsync(id, ct);
if (channel is null) return NotFound();
return await EditorViewAsync(new PushChannelEditViewModel
{
Id = channel.Id, ServiceId = channel.ServiceId,
FirebaseProjectId = channel.FirebaseProjectId, IsActive = channel.IsActive
}, ct);
}
[HttpPost("save")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Save(PushChannelEditViewModel model, CancellationToken ct)
{
if (!ModelState.IsValid) return await EditorViewAsync(model, ct);
var result = await mediator.Send(new SavePushChannelCommand
{
Id = model.Id, ServiceId = model.ServiceId!.Value, FirebaseProjectId = model.FirebaseProjectId,
CredentialsJson = model.CredentialsJson, IsActive = model.IsActive
}, ct);
if (result.IsSuccess) return RedirectToAction(nameof(Index));
ModelState.AddModelError(string.Empty, result.Error?.Message ?? "Could not save the push channel.");
return await EditorViewAsync(model, ct);
}
private async Task<IActionResult> EditorViewAsync(PushChannelEditViewModel model, CancellationToken ct)
{
model.CredentialsJson = null;
// Razor tag helpers prefer ModelState over the model, so clear the attempted secret too.
ModelState.Remove(nameof(model.CredentialsJson));
model.Services = (await services.GetAllAsync(ct))
.Select(s => new SelectListItem(s.Name, s.Id.ToString())).ToList();
return View("Edit", model);
}
}
@@ -0,0 +1,3 @@
namespace HrynCo.NotificationService.Web.Controllers.Admin;
public sealed record TestChannelRequest(string ToEmail);
@@ -9,7 +9,9 @@ public class EmailChannelEditViewModel
// ── Channel fields ─────────────────────────────────────────────────
[Required]
public string ServiceName { get; set; } = "";
public Guid? ServiceId { get; set; }
public IReadOnlyList<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> Services { get; set; } = [];
public int Priority { get; set; } = 1;
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace HrynCo.NotificationService.Web.Controllers.Admin.ViewModels;
public sealed class PushChannelEditViewModel
{
public Guid Id { get; set; }
[Required]
public Guid? ServiceId { get; set; }
[Required, StringLength(30)]
public string FirebaseProjectId { get; set; } = string.Empty;
[StringLength(32768)]
public string? CredentialsJson { get; set; }
public bool IsActive { get; set; } = true;
[BindNever]
public IReadOnlyList<SelectListItem> Services { get; set; } = [];
public bool IsNew => Id == Guid.Empty;
public string PageTitle => IsNew ? "Create Push Channel" : "Edit Push Channel";
}
@@ -1,10 +1,9 @@
using System.ComponentModel.DataAnnotations;
using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record CreateEmailChannelRequest(
[Required, StringLength(100)] string ServiceName,
Guid ServiceId,
int Priority,
EmailChannelType ChannelType,
SmtpChannelSettingsDto Settings,
@@ -2,6 +2,6 @@ using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record EmailChannelDto(Guid Id, string ServiceName, int Priority, EmailChannelType EmailChannelType,
public sealed record EmailChannelDto(Guid Id, Guid ServiceId, string ServiceName, int Priority, EmailChannelType EmailChannelType,
SmtpChannelSettingsDto Settings, int? DailyLimit, int? MonthlyLimit, int WarnThresholdPercent,
bool IsActive, DateTimeOffset Created, DateTimeOffset? Updated);
@@ -13,7 +13,7 @@ internal static class EmailChannelMapping
public static EmailChannelDto Map(EmailChannel channel)
{
var smtp = (SmtpChannelSettings)channel.Settings;
return new EmailChannelDto(channel.Id, channel.ServiceName, channel.Priority, channel.EmailChannelType,
return new EmailChannelDto(channel.Id, channel.ServiceId, channel.ServiceName, channel.Priority, channel.EmailChannelType,
new SmtpChannelSettingsDto
{
Host = smtp.Host, Port = smtp.Port, Username = smtp.Username, Password = smtp.Password,
@@ -48,7 +48,7 @@ public sealed class EmailChannelsController : ApiControllerBase
if (request.ChannelType != EmailChannelType.Smtp)
return MapServiceError(new ServiceError("Only SMTP channels are supported.", ServiceErrorCode.InvalidRequest));
var command = new CreateEmailChannelCommand(
request.ServiceName,
request.ServiceId,
request.Priority,
request.ChannelType,
EmailChannelMapping.ToSettings(request.Settings),
@@ -72,6 +72,7 @@ public sealed class EmailChannelsController : ApiControllerBase
{
var command = new UpdateEmailChannelCommand(
id,
request.ServiceId,
request.Priority,
EmailChannelMapping.ToSettings(request.Settings),
request.DailyLimit,
@@ -3,6 +3,7 @@ using HrynCo.NotificationService.DAL.Abstract.Providers;
namespace HrynCo.NotificationService.Web.Controllers.Api.EmailChannels;
public sealed record UpdateEmailChannelRequest(
Guid ServiceId,
int Priority,
SmtpChannelSettingsDto Settings,
int? DailyLimit,