112 lines
4.5 KiB
C#
112 lines
4.5 KiB
C#
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];
|
|
}
|