support PWA notification.
ci-cd-vm / ci-cd (push) Failing after 1m34s

This commit is contained in:
Chris Chen
2026-06-29 22:20:15 -07:00
parent 45d910b554
commit b9210f2501
32 changed files with 1054 additions and 12 deletions
@@ -0,0 +1,19 @@
namespace ROLAC.API.Services.Notifications;
/// <summary>The three fields a browser hands over when it creates a push subscription.</summary>
public sealed record WebPushTarget(string Endpoint, string P256dh, string Auth);
/// <summary>
/// Outcome of pushing to one subscription. <see cref="IsExpired"/> is true when the push service
/// reported the subscription is gone (HTTP 404/410) so the caller can prune it.
/// </summary>
public sealed record WebPushSendResult(bool Success, bool IsExpired, string? Error);
/// <summary>
/// Thin wrapper over the WebPush library's encrypt-and-POST so that <see cref="WebPushService"/>'s
/// recipient resolution, pruning, and logging can be unit-tested without a real push service.
/// </summary>
public interface IWebPushSender
{
Task<WebPushSendResult> SendAsync(WebPushTarget target, string payloadJson, CancellationToken ct = default);
}
@@ -0,0 +1,13 @@
namespace ROLAC.API.Services.Notifications;
/// <summary>
/// The Web Push channel — peer to <see cref="IEmailService"/> and <see cref="ILineNotificationService"/>.
/// Resolves every device subscription of the target members and pushes one notification to each,
/// pruning subscriptions the push service reports as gone and writing a NotificationLog per send.
/// </summary>
public interface IWebPushService
{
Task<NotificationResult> SendToMembersAsync(
IReadOnlyCollection<int> memberIds, WebPushPayload payload,
string sentByUserId, CancellationToken ct = default);
}
@@ -3,8 +3,9 @@ namespace ROLAC.API.Services.Notifications;
/// <summary>Canonical channel discriminators stored in NotificationLog.Channel.</summary>
public static class NotificationChannels
{
public const string Email = "email";
public const string Line = "line";
public const string Email = "email";
public const string Line = "line";
public const string WebPush = "webpush";
}
/// <summary>Canonical target-type discriminators stored in NotificationLog.TargetType.</summary>
@@ -36,6 +37,12 @@ public sealed record NotificationResult(
/// <summary>A file attached to an outbound email.</summary>
public sealed record EmailAttachment(string FileName, string ContentType, byte[] Content);
/// <summary>
/// The content of a single Web Push notification. <see cref="Url"/> is the in-app path to open when
/// the notification is clicked; <see cref="Tag"/> lets a newer notification replace an older one.
/// </summary>
public sealed record WebPushPayload(string Title, string Body, string? Url = null, string? Tag = null);
/// <summary>
/// A request to send one email to a set of members (resolved via Member.Email) and/or raw
/// addresses. The caller supplies the final HTML body — no templating in this phase.
@@ -18,3 +18,11 @@ public sealed class LineOptions
public string ChannelAccessToken { get; set; } = "";
public string ChannelSecret { get; set; } = "";
}
/// <summary>VAPID settings for Web Push. Sourced from the NotificationSetting row at runtime.</summary>
public sealed class WebPushOptions
{
public string PublicKey { get; set; } = "";
public string PrivateKey { get; set; } = "";
public string Subject { get; set; } = "";
}
@@ -15,6 +15,7 @@ public interface INotificationSettingsService
{
SmtpOptions GetSmtp();
LineOptions GetLine();
WebPushOptions GetWebPush();
void Reload();
}
@@ -27,6 +28,7 @@ public sealed class NotificationSettingsService : INotificationSettingsService
private SmtpOptions? _smtp;
private LineOptions? _line;
private WebPushOptions? _webPush;
public NotificationSettingsService(
IServiceScopeFactory scopeFactory,
@@ -50,12 +52,19 @@ public sealed class NotificationSettingsService : INotificationSettingsService
return _line!;
}
public WebPushOptions GetWebPush()
{
EnsureLoaded();
return _webPush!;
}
public void Reload()
{
lock (_gate)
{
_smtp = null;
_line = null;
_webPush = null;
}
}
@@ -63,7 +72,7 @@ public sealed class NotificationSettingsService : INotificationSettingsService
{
lock (_gate)
{
if (_smtp is not null && _line is not null)
if (_smtp is not null && _line is not null && _webPush is not null)
return;
using var scope = _scopeFactory.CreateScope();
@@ -72,9 +81,11 @@ public sealed class NotificationSettingsService : INotificationSettingsService
if (row is null)
{
// Not seeded yet — use the appsettings values so sends still work.
// Not seeded yet — use the appsettings values so sends still work. Web push has no
// appsettings fallback; it stays disabled until the row exists with VAPID keys.
_smtp = _smtpFallback.Value;
_line = _lineFallback.Value;
_webPush = new WebPushOptions();
return;
}
@@ -93,6 +104,12 @@ public sealed class NotificationSettingsService : INotificationSettingsService
ChannelAccessToken = row.LineChannelAccessToken,
ChannelSecret = row.LineChannelSecret,
};
_webPush = new WebPushOptions
{
PublicKey = row.VapidPublicKey,
PrivateKey = row.VapidPrivateKey,
Subject = row.VapidSubject,
};
}
}
}
@@ -0,0 +1,40 @@
using System.Net;
using WebPush;
namespace ROLAC.API.Services.Notifications;
/// <summary>
/// Sends one encrypted Web Push message via the WebPush library, signing with the VAPID keys from
/// the notification settings. Translates a "subscription gone" (404/410) response into
/// <see cref="WebPushSendResult.IsExpired"/> so the caller prunes the dead subscription.
/// </summary>
public sealed class WebPushSender : IWebPushSender
{
private readonly INotificationSettingsService _settings;
private readonly WebPushClient _client = new();
public WebPushSender(INotificationSettingsService settings) => _settings = settings;
public async Task<WebPushSendResult> SendAsync(
WebPushTarget target, string payloadJson, CancellationToken ct = default)
{
var options = _settings.GetWebPush();
if (string.IsNullOrWhiteSpace(options.PublicKey) || string.IsNullOrWhiteSpace(options.PrivateKey))
return new WebPushSendResult(Success: false, IsExpired: false, Error: "Web Push is not configured (missing VAPID keys).");
var subject = string.IsNullOrWhiteSpace(options.Subject) ? "mailto:admin@rolac.local" : options.Subject;
var vapid = new VapidDetails(subject, options.PublicKey, options.PrivateKey);
var subscription = new PushSubscription(target.Endpoint, target.P256dh, target.Auth);
try
{
await _client.SendNotificationAsync(subscription, payloadJson, vapid, ct);
return new WebPushSendResult(Success: true, IsExpired: false, Error: null);
}
catch (WebPushException ex)
{
var expired = ex.StatusCode is HttpStatusCode.NotFound or HttpStatusCode.Gone;
return new WebPushSendResult(Success: false, IsExpired: expired, Error: ex.Message);
}
}
}
@@ -0,0 +1,111 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using ROLAC.API.Data;
using ROLAC.API.Entities.Notifications;
using ROLAC.API.Services.Logging;
namespace ROLAC.API.Services.Notifications;
/// <summary>
/// Resolves the target members' device subscriptions, pushes one notification to each via the
/// <see cref="IWebPushSender"/>, prunes any subscription the push service reports as gone (404/410),
/// and writes a NotificationLog row per send. A single failure never aborts the batch — it is
/// recorded and reported in the summary (mirrors <see cref="EmailService"/>).
/// </summary>
public sealed class WebPushService : IWebPushService
{
// The endpoint URL can be long; NotificationLog.TargetExternalId is capped at 200.
private const int TargetExternalIdMaxLength = 200;
private readonly AppDbContext _db;
private readonly IWebPushSender _sender;
private readonly CurrentUserAccessor _currentUser;
public WebPushService(AppDbContext db, IWebPushSender sender, CurrentUserAccessor currentUser)
{
_db = db;
_sender = sender;
_currentUser = currentUser;
}
public async Task<NotificationResult> SendToMembersAsync(
IReadOnlyCollection<int> memberIds, WebPushPayload payload,
string sentByUserId, CancellationToken ct = default)
{
if (memberIds.Count == 0) return NotificationResult.Empty;
var subscriptions = await _db.WebPushSubscriptions
.Where(subscription => memberIds.Contains(subscription.MemberId))
.ToListAsync(ct);
if (subscriptions.Count == 0) return NotificationResult.Empty;
var sentBy = string.IsNullOrEmpty(sentByUserId) ? _currentUser.UserIdOrSystem : sentByUserId;
var payloadJson = BuildPayloadJson(payload);
var failures = new List<NotificationFailure>();
var expired = new List<WebPushSubscription>();
var sentCount = 0;
foreach (var subscription in subscriptions)
{
var log = new NotificationLog
{
Channel = NotificationChannels.WebPush,
TargetType = NotificationTargetTypes.User,
TargetExternalId = TruncateEndpoint(subscription.Endpoint),
Subject = payload.Title,
MemberId = subscription.MemberId,
Body = NotificationLogText.Truncate(payload.Body),
SentByUserId = sentBy,
SentAt = DateTime.UtcNow,
};
var result = await _sender.SendAsync(
new WebPushTarget(subscription.Endpoint, subscription.P256dh, subscription.Auth), payloadJson, ct);
if (result.Success)
{
log.Status = NotificationStatuses.Sent;
subscription.LastUsedAt = DateTime.UtcNow;
sentCount++;
}
else
{
log.Status = NotificationStatuses.Failed;
log.Error = result.Error;
failures.Add(new NotificationFailure(log.TargetExternalId, result.Error ?? "unknown error"));
if (result.IsExpired) expired.Add(subscription);
}
_db.NotificationLogs.Add(log);
}
// Drop subscriptions the push service says are gone so they don't linger and fail forever.
if (expired.Count > 0) _db.WebPushSubscriptions.RemoveRange(expired);
await _db.SaveChangesAsync(ct);
return new NotificationResult(sentCount, failures.Count, failures);
}
// Shape expected by @angular/service-worker (ngsw): a top-level "notification" object whose
// "data.url" the app reads on click to navigate. "tag" lets a newer message replace an older one.
private static string BuildPayloadJson(WebPushPayload payload)
{
var notification = new Dictionary<string, object?>
{
["title"] = payload.Title,
["body"] = payload.Body,
["icon"] = "assets/AppLogo-192.png",
["data"] = new Dictionary<string, object?>
{
["url"] = payload.Url,
["tag"] = payload.Tag,
},
};
if (!string.IsNullOrWhiteSpace(payload.Tag)) notification["tag"] = payload.Tag;
return JsonSerializer.Serialize(new Dictionary<string, object?> { ["notification"] = notification });
}
private static string TruncateEndpoint(string endpoint) =>
endpoint.Length <= TargetExternalIdMaxLength ? endpoint : endpoint[..TargetExternalIdMaxLength];
}