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