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,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];
}