@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user