diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-04-02 12:38:10 +0200 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-04-02 12:45:33 +0200 |
| commit | 1b4f4a674ac533c0b51260ba35ab91dd2cf9486d (patch) | |
| tree | 9fdaf418bef86c51737bcf203483089c9e2b908b /src/Enmarcha.Backend/Services/PushNotificationService.cs | |
| parent | 749e04d6fc2304bb29920db297d1fa4d73b57648 (diff) | |
Basic push notification system for service alerts
Co-authored-by: Copilot <copilot@github.com>
Diffstat (limited to 'src/Enmarcha.Backend/Services/PushNotificationService.cs')
| -rw-r--r-- | src/Enmarcha.Backend/Services/PushNotificationService.cs | 134 |
1 files changed, 134 insertions, 0 deletions
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<AppConfiguration> options, + ILogger<PushNotificationService> 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<string, string>)alert.Header, + description = (Dictionary<string, string>)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<Data.Models.PushSubscription>(); + + 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(); + } +} |
