From 1b4f4a674ac533c0b51260ba35ab91dd2cf9486d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Thu, 2 Apr 2026 12:38:10 +0200 Subject: Basic push notification system for service alerts Co-authored-by: Copilot --- .../Services/PushNotificationService.cs | 134 +++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 src/Enmarcha.Backend/Services/PushNotificationService.cs (limited to 'src/Enmarcha.Backend/Services/PushNotificationService.cs') diff --git a/src/Enmarcha.Backend/Services/PushNotificationService.cs b/src/Enmarcha.Backend/Services/PushNotificationService.cs new file mode 100644 index 0000000..c9d79dc --- /dev/null +++ b/src/Enmarcha.Backend/Services/PushNotificationService.cs @@ -0,0 +1,134 @@ +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Data; +using Enmarcha.Backend.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using System.Text.Json; +using WebPush; + +namespace Enmarcha.Backend.Services; + +public interface IPushNotificationService +{ + Task SubscribeAsync(string endpoint, string p256dh, string auth); + Task UnsubscribeAsync(string endpoint); + Task SendAlertAsync(ServiceAlert alert); +} + +public class PushNotificationService( + AppDbContext db, + IOptions options, + ILogger logger +) : IPushNotificationService +{ + private readonly WebPushClient? _client = BuildClient(options.Value.Vapid); + + private static WebPushClient? BuildClient(VapidConfiguration? vapid) + { + if (vapid is null) return null; + var client = new WebPushClient(); + client.SetVapidDetails(vapid.Subject, vapid.PublicKey, vapid.PrivateKey); + return client; + } + + public async Task SubscribeAsync(string endpoint, string p256dh, string auth) + { + var existing = await db.PushSubscriptions + .FirstOrDefaultAsync(s => s.Endpoint == endpoint); + + if (existing is null) + { + db.PushSubscriptions.Add(new Data.Models.PushSubscription + { + Id = Guid.NewGuid(), + Endpoint = endpoint, + P256DhKey = p256dh, + AuthKey = auth, + CreatedAt = DateTime.UtcNow, + }); + } + else + { + // Refresh keys in case they changed (e.g. after re-subscription) + existing.P256DhKey = p256dh; + existing.AuthKey = auth; + } + + await db.SaveChangesAsync(); + } + + public async Task UnsubscribeAsync(string endpoint) + { + var subscription = await db.PushSubscriptions + .FirstOrDefaultAsync(s => s.Endpoint == endpoint); + + if (subscription is not null) + { + db.PushSubscriptions.Remove(subscription); + await db.SaveChangesAsync(); + } + } + + public async Task SendAlertAsync(ServiceAlert alert) + { + if (_client is null) + { + logger.LogWarning("VAPID not configured — skipping push notification for alert {AlertId}", alert.Id); + return; + } + + var now = DateTime.UtcNow; + var phase = alert.GetPhase(now); + + alert.Version++; + + if (phase == AlertPhase.PreNotice) + alert.PreNoticeNotifiedAt = now; + else if (phase == AlertPhase.Active) + alert.ActiveNotifiedAt = now; + + var payload = JsonSerializer.Serialize(new + { + alertId = alert.Id, + version = alert.Version, + phase = phase.ToString(), + cause = alert.Cause.ToString(), + effect = alert.Effect.ToString(), + header = (Dictionary)alert.Header, + description = (Dictionary)alert.Description, + selectors = alert.Selectors.Select(s => s.Raw).ToList(), + eventStart = alert.EventStartDate, + eventEnd = alert.EventEndDate, + }); + + var subscriptions = await db.PushSubscriptions.ToListAsync(); + var expired = new List(); + + foreach (var sub in subscriptions) + { + try + { + var pushSub = new WebPush.PushSubscription(sub.Endpoint, sub.P256DhKey, sub.AuthKey); + await _client.SendNotificationAsync(pushSub, payload); + } + catch (WebPushException ex) when ( + ex.StatusCode is System.Net.HttpStatusCode.Gone or System.Net.HttpStatusCode.NotFound) + { + // Subscription expired or was revoked — remove it + expired.Add(sub); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to deliver push notification to endpoint {Endpoint}", sub.Endpoint[..Math.Min(40, sub.Endpoint.Length)]); + } + } + + if (expired.Count > 0) + { + db.PushSubscriptions.RemoveRange(expired); + logger.LogInformation("Removed {Count} expired push subscription(s)", expired.Count); + } + + await db.SaveChangesAsync(); + } +} -- cgit v1.3