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;
///
/// Resolves the target members' device subscriptions, pushes one notification to each via the
/// , 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 ).
///
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 SendToMembersAsync(
IReadOnlyCollection 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();
var expired = new List();
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
{
["title"] = payload.Title,
["body"] = payload.Body,
["icon"] = "assets/AppLogo-192.png",
["data"] = new Dictionary
{
["url"] = payload.Url,
["tag"] = payload.Tag,
},
};
if (!string.IsNullOrWhiteSpace(payload.Tag)) notification["tag"] = payload.Tag;
return JsonSerializer.Serialize(new Dictionary { ["notification"] = notification });
}
private static string TruncateEndpoint(string endpoint) =>
endpoint.Length <= TargetExternalIdMaxLength ? endpoint : endpoint[..TargetExternalIdMaxLength];
}