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,
@@ -23,6 +23,7 @@
<ItemGroup>
<ProjectReference Include="..\HrynCo.NotificationService.Services\HrynCo.NotificationService.Services.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.Worker.Services\HrynCo.NotificationService.Worker.Services.csproj" />
<ProjectReference Include="..\HrynCo.NotificationService.DAL.EF\HrynCo.NotificationService.DAL.EF.csproj" />
</ItemGroup>
@@ -17,6 +17,9 @@ builder.Services.AddControllersWithViews()
.AddRazorRuntimeCompilation();
builder.Services.AddNotificationDataAccess(appSettings.ConnectionString);
builder.Services.AddNotificationServices();
builder.Services.AddTransient<HrynCo.NotificationService.Worker.Services.Push.IFirebasePushClient,
HrynCo.NotificationService.Worker.Services.Push.FirebasePushClient>();
builder.Services.AddTransient<HrynCo.NotificationService.Worker.Services.Push.PushChannelTestService>();
WebApplication app = builder.Build();
@@ -7,6 +7,24 @@
ViewData["EditorTitle"] = Model.PageTitle;
}
<style>
@@media (min-width: 768px) {
#channelForm .channel-compact-field {
flex: 0 0 auto;
width: calc(var(--channel-field-width) + var(--bs-gutter-x));
}
#channelForm .channel-service-field { --channel-field-width: 280px; }
#channelForm .channel-type-field { --channel-field-width: 200px; }
#channelForm .channel-priority-field { --channel-field-width: 110px; }
#channelForm .channel-limit-field { --channel-field-width: 160px; }
#channelForm .channel-warning-field { --channel-field-width: 130px; }
#channelForm .channel-smtp-text-field { --channel-field-width: 245px; }
#channelForm .channel-port-field { --channel-field-width: 120px; }
#channelForm .channel-from-email-field { --channel-field-width: 325px; }
}
</style>
<form id="channelForm" asp-action="Save" asp-controller="AdminChannels" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" />
@@ -24,25 +42,26 @@
@* Channel Settings section *@
<div class="form-section-title"><i class="bi bi-sliders me-1"></i> Channel Settings</div>
<div class="mb-4">
<label asp-for="ServiceName" class="form-label fw-semibold">Service Name</label>
<input asp-for="ServiceName" class="form-control" readonly="@(!Model.IsNew)" />
<span asp-validation-for="ServiceName" class="text-danger small"></span>
</div>
<div class="row mb-4">
<div class="col-md-4">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-service-field">
<label asp-for="ServiceId" class="form-label fw-semibold">Service</label>
<select asp-for="ServiceId" asp-items="Model.Services" class="form-select">
<option value="">Select a service</option>
</select>
<span asp-validation-for="ServiceId" class="text-danger small"></span>
</div>
<div class="col-6 channel-compact-field channel-priority-field">
<label asp-for="Priority" class="form-label fw-semibold">Priority</label>
<input asp-for="Priority" class="form-control" type="number" min="1" />
<span asp-validation-for="Priority" class="text-danger small"></span>
</div>
<div class="col-md-4">
<div class="col-6 channel-compact-field channel-type-field">
<label asp-for="EmailChannelType" class="form-label fw-semibold">Channel Type</label>
<select asp-for="EmailChannelType" class="form-select"
asp-items="Html.GetEnumSelectList<EmailChannelType>()">
</select>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="col-12 col-md-auto d-flex align-items-end">
<div class="form-check mb-2">
<input asp-for="IsActive" class="form-check-input" type="checkbox" />
<label asp-for="IsActive" class="form-check-label fw-semibold">Active</label>
@@ -50,18 +69,18 @@
</div>
</div>
<div class="row mb-4">
<div class="col-md-4">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-limit-field">
<label asp-for="DailyLimit" class="form-label fw-semibold">Daily Limit</label>
<input asp-for="DailyLimit" class="form-control" type="number" min="0" placeholder="Unlimited" />
<span asp-validation-for="DailyLimit" class="text-danger small"></span>
</div>
<div class="col-md-4">
<div class="col-12 channel-compact-field channel-limit-field">
<label asp-for="MonthlyLimit" class="form-label fw-semibold">Monthly Limit</label>
<input asp-for="MonthlyLimit" class="form-control" type="number" min="0" placeholder="Unlimited" />
<span asp-validation-for="MonthlyLimit" class="text-danger small"></span>
</div>
<div class="col-md-4">
<div class="col-12 channel-compact-field channel-warning-field">
<label asp-for="WarnThresholdPercent" class="form-label fw-semibold">Warn Threshold (%)</label>
<input asp-for="WarnThresholdPercent" class="form-control" type="number" min="1" max="100" />
<span asp-validation-for="WarnThresholdPercent" class="text-danger small"></span>
@@ -71,26 +90,26 @@
@* SMTP Settings section *@
<div class="form-section-title mt-4"><i class="bi bi-envelope-at me-1"></i> SMTP Settings</div>
<div class="row mb-4">
<div class="col-md-9">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-smtp-text-field">
<label asp-for="Host" class="form-label fw-semibold">Host</label>
<input asp-for="Host" class="form-control" placeholder="smtp.example.com" />
<span asp-validation-for="Host" class="text-danger small"></span>
</div>
<div class="col-md-3">
<div class="col-12 channel-compact-field channel-port-field">
<label asp-for="Port" class="form-label fw-semibold">Port</label>
<input asp-for="Port" class="form-control" type="number" min="1" max="65535" />
<span asp-validation-for="Port" class="text-danger small"></span>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-smtp-text-field">
<label asp-for="Username" class="form-label fw-semibold">Username</label>
<input asp-for="Username" class="form-control" autocomplete="new-password" />
<span asp-validation-for="Username" class="text-danger small"></span>
</div>
<div class="col-md-6">
<div class="col-12 channel-compact-field channel-smtp-text-field">
<label asp-for="Password" class="form-label fw-semibold">Password</label>
<input asp-for="Password" class="form-control" type="password" autocomplete="new-password" />
<span asp-validation-for="Password" class="text-danger small"></span>
@@ -108,13 +127,13 @@
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<div class="row g-3 mb-4">
<div class="col-12 channel-compact-field channel-from-email-field">
<label asp-for="FromEmail" class="form-label fw-semibold">From Email</label>
<input asp-for="FromEmail" class="form-control" type="email" placeholder="noreply@example.com" />
<span asp-validation-for="FromEmail" class="text-danger small"></span>
</div>
<div class="col-md-6">
<div class="col-12 channel-compact-field channel-smtp-text-field">
<label asp-for="FromName" class="form-label fw-semibold">From Name</label>
<input asp-for="FromName" class="form-control" placeholder="My Service" />
<span asp-validation-for="FromName" class="text-danger small"></span>
@@ -0,0 +1,161 @@
@using HrynCo.NotificationService.Web.Controllers.Admin.ViewModels
@model PushChannelEditViewModel
@{
Layout = "~/Views/Shared/_EditorLayout.cshtml";
ViewData["Title"] = Model.PageTitle;
ViewData["EditorTitle"] = Model.PageTitle;
}
<style>
@@media (min-width: 768px) {
#pushChannelForm .push-service-field { flex: 0 0 auto; width: calc(280px + var(--bs-gutter-x)); }
#pushChannelForm .push-project-field { flex: 0 0 auto; width: calc(280px + var(--bs-gutter-x)); }
#pushChannelForm .push-credentials-field { flex: 0 0 auto; width: calc(440px + var(--bs-gutter-x)); }
}
</style>
<form id="pushChannelForm" asp-action="Save" asp-controller="AdminPushChannels" method="post">
@Html.AntiForgeryToken()
<input asp-for="Id" type="hidden" />
@if (!ViewData.ModelState.IsValid)
{
<div asp-validation-summary="All" class="alert alert-danger"></div>
}
<div class="row g-3 mb-4">
<div class="col-12 push-service-field">
<label asp-for="ServiceId" class="form-label fw-semibold">Service</label>
<select asp-for="ServiceId" asp-items="Model.Services" class="form-select">
<option value="">Select a service</option>
</select>
<span asp-validation-for="ServiceId" class="text-danger small"></span>
</div>
<div class="col-12 push-project-field">
<label asp-for="FirebaseProjectId" class="form-label fw-semibold">Firebase Project ID</label>
<input asp-for="FirebaseProjectId" class="form-control" maxlength="30" />
<span asp-validation-for="FirebaseProjectId" class="text-danger small"></span>
</div>
<div class="col-12 col-md-auto d-flex align-items-end">
<div class="form-check mb-2">
<input asp-for="IsActive" class="form-check-input" />
<label asp-for="IsActive" class="form-check-label fw-semibold">Active</label>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 push-credentials-field">
<label asp-for="CredentialsJson" class="form-label fw-semibold">Firebase service account JSON</label>
<textarea asp-for="CredentialsJson" class="form-control font-monospace" rows="7" maxlength="32768" autocomplete="off" spellcheck="false" aria-describedby="credentialsHelp"></textarea>
<div id="credentialsHelp" class="form-text">
@if (Model.IsNew)
{
<span>Required. Use the service account JSON for this Firebase project.</span>
}
else
{
<span>Credentials are saved. Leave blank to keep them, or paste a replacement. Changing the project requires new credentials.</span>
}
</div>
<span asp-validation-for="CredentialsJson" class="text-danger small"></span>
<div class="mt-3">
<button type="button" class="btn btn-outline-success" id="pushAuthorizationBtn">Check Firebase authorization</button>
</div>
<div id="pushAuthorizationResult" class="alert d-none mt-3" role="status" aria-live="polite"></div>
</div>
</div>
</form>
<div class="modal fade" id="pushTestModal" tabindex="-1" aria-labelledby="pushTestTitle">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="pushTestTitle">Send Test Push</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted small">Tests the current form settings without saving. Blank credentials use the saved credentials only when the Firebase project is unchanged.</p>
<p class="text-muted small">Sends a neutral notification directly through Firebase. This tests the channel, not Invemory account binding, navigation or background handling. Use your own test device only.</p>
<label for="pushRecipientToken" class="form-label fw-semibold">Recipient token</label>
<textarea id="pushRecipientToken" class="form-control font-monospace" rows="3" maxlength="4096" autocomplete="off" spellcheck="false"></textarea>
<div class="form-text">Not saved as channel configuration. Cleared after the test or when this dialog closes.</div>
<div id="pushSendResult" class="alert d-none mt-3" role="status" aria-live="polite"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" id="pushSendBtn">Send test push</button>
</div>
</div>
</div>
</div>
@section FormActions {
<button type="submit" form="pushChannelForm" class="btn btn-primary"><i class="bi bi-floppy me-1"></i> Save</button>
<button type="button" class="btn btn-success" id="pushTestModalBtn"><i class="bi bi-send me-1"></i> Send test push…</button>
<a href="/admin/push-channels" class="btn btn-secondary">Cancel</a>
}
<script>
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('pushChannelForm');
const modal = document.getElementById('pushTestModal');
const token = document.getElementById('pushRecipientToken');
const authorizationButton = document.getElementById('pushAuthorizationBtn');
const sendButton = document.getElementById('pushSendBtn');
const modalButton = document.getElementById('pushTestModalBtn');
let busy = false;
function showResult(target, success, message) {
target.className = 'alert mt-3 ' + (success ? 'alert-success' : 'alert-danger');
target.textContent = message;
}
function currentSettings(send) {
const id = form.querySelector('[name="Id"]').value;
return {
channelId: id === '00000000-0000-0000-0000-000000000000' ? null : id,
firebaseProjectId: form.querySelector('[name="FirebaseProjectId"]').value,
credentialsJson: form.querySelector('[name="CredentialsJson"]').value,
recipientToken: send ? token.value.trim() : null
};
}
async function runTest(send) {
if (busy) return;
const result = document.getElementById(send ? 'pushSendResult' : 'pushAuthorizationResult');
const payload = currentSettings(send);
if (send && !payload.recipientToken) {
showResult(result, false, 'Enter a recipient FCM token.');
return;
}
busy = true;
authorizationButton.disabled = sendButton.disabled = modalButton.disabled = true;
showResult(result, true, send ? 'Sending test message…' : 'Checking OAuth authorization…');
try {
const response = await fetch('/admin/push-channels/' + (send ? 'test-send' : 'test-authorization'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': form.querySelector('[name="__RequestVerificationToken"]').value
},
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Request failed.');
const data = await response.json();
showResult(result, data.success, data.message);
} catch {
showResult(result, false, 'The test request failed. Check connectivity and try again. Acceptance was not confirmed.');
} finally {
busy = false;
authorizationButton.disabled = sendButton.disabled = modalButton.disabled = false;
if (send) token.value = '';
}
}
authorizationButton.addEventListener('click', () => runTest(false));
sendButton.addEventListener('click', () => runTest(true));
modalButton.addEventListener('click', () => {
document.getElementById('pushSendResult').className = 'alert d-none';
bootstrap.Modal.getOrCreateInstance(modal).show();
});
modal.addEventListener('hidden.bs.modal', () => { token.value = ''; });
});
</script>
@@ -0,0 +1,40 @@
@using HrynCo.NotificationService.DAL.Abstract.PushChannels
@model IReadOnlyList<PushChannel>
@{ ViewData["Title"] = "Push Channels"; }
<div class="page-header">
<h2><i class="bi bi-phone"></i> Push Channels</h2>
<a href="/admin/push-channels/create" class="btn btn-primary btn-sm">Create New Channel</a>
</div>
@if (Model.Count == 0)
{
<div class="card shadow-sm table-card">
<div class="empty-state">
<i class="bi bi-phone"></i>
<p class="mt-2 mb-0">No push channels found.</p>
<a href="/admin/push-channels/create" class="btn btn-primary btn-sm mt-3">Create First Channel</a>
</div>
</div>
}
else
{
<div class="card shadow-sm table-card">
<div class="table-responsive">
<table class="table table-hover admin-data-table table-sm mb-0">
<thead><tr><th scope="col">Service</th><th scope="col">Firebase Project ID</th><th scope="col">Status</th><th scope="col">Actions</th></tr></thead>
<tbody>
@foreach (var channel in Model)
{
<tr>
<td>@channel.ServiceName</td>
<td>@channel.FirebaseProjectId</td>
<td><span class="badge @(channel.IsActive ? "bg-success" : "bg-secondary")">@(channel.IsActive ? "Active" : "Inactive")</span></td>
<td><a href="/admin/push-channels/@channel.Id" class="btn btn-sm btn-link" aria-label="Edit push channel for @channel.ServiceName">Edit</a></td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
@@ -14,7 +14,7 @@ else
{
<div class="alert alert-danger">This permanently deletes the service and its language catalog.</div>
}
<p>Email channels are retained. Their service names are independent and are not renamed or deleted.</p>
<p>Reassign this service's push channel and reassign or remove its email channels before deleting the service. Channels are never deleted automatically.</p>
<form asp-action="DeleteService" asp-route-serviceId="@Model.ServiceId" method="post">
@Html.AntiForgeryToken()
<input asp-for="Revision" type="hidden" />
@@ -15,7 +15,7 @@
<label asp-for="Name" class="form-label fw-semibold">Service name</label>
<input asp-for="Name" class="form-control mb-3" maxlength="100" />
<h3 class="h5">Language codes</h3>
<p class="text-muted">Codes are case-sensitive and belong to this service. Existing template versions use these codes when sending. Renaming a service or code changes sender lookup; align publishers and email channel service names.</p>
<p class="text-muted">Codes are case-sensitive and belong to this service. Existing template versions use these codes when sending. Renaming a service or code changes sender lookup; update publishers to use the current names. Email channels remain linked to this service automatically.</p>
<div class="table-responsive">
<table class="table admin-data-table service-language-table"><thead><tr><th scope="col">Code</th><th scope="col">Versions</th><th scope="col" class="service-language-actions">Actions</th></tr></thead><tbody id="serviceLanguages">
@for (var i = 0; i < Model.Languages.Count; i++)
@@ -2,6 +2,7 @@
var currentController = ViewContext.RouteData.Values["controller"]?.ToString() ?? "";
bool isServices = currentController.Equals("AdminServices", StringComparison.OrdinalIgnoreCase)
|| currentController.Equals("AdminTemplates", StringComparison.OrdinalIgnoreCase);
bool isPushChannels = currentController.Equals("AdminPushChannels", StringComparison.OrdinalIgnoreCase);
bool isChannels = currentController.Equals("AdminChannels", StringComparison.OrdinalIgnoreCase);
}
<!DOCTYPE html>
@@ -34,6 +35,11 @@
<i class="bi bi-broadcast"></i> Email Channels
</a>
</li>
<li class="nav-item">
<a class="nav-link @(isPushChannels ? "active" : "")" href="/admin/push-channels">
<i class="bi bi-phone"></i> Push Channels
</a>
</li>
</ul>
</nav>