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:
@@ -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);
|
||||
+3
-1
@@ -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;
|
||||
|
||||
|
||||
+21
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user