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 | |
| parent | 749e04d6fc2304bb29920db297d1fa4d73b57648 (diff) | |
Basic push notification system for service alerts
Co-authored-by: Copilot <copilot@github.com>
Diffstat (limited to 'src/Enmarcha.Backend/Services')
4 files changed, 221 insertions, 6 deletions
diff --git a/src/Enmarcha.Backend/Services/AlertPhaseNotificationHostedService.cs b/src/Enmarcha.Backend/Services/AlertPhaseNotificationHostedService.cs new file mode 100644 index 0000000..5cbbaee --- /dev/null +++ b/src/Enmarcha.Backend/Services/AlertPhaseNotificationHostedService.cs @@ -0,0 +1,77 @@ +using Enmarcha.Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Enmarcha.Backend.Services; + +/// <summary> +/// Background service that automatically sends push notifications when a service alert +/// transitions into the PreNotice or Active phase without having been notified yet. +/// Runs every 60 seconds and also immediately on startup to handle any missed transitions. +/// </summary> +public class AlertPhaseNotificationHostedService( + IServiceScopeFactory scopeFactory, + ILogger<AlertPhaseNotificationHostedService> logger) : IHostedService, IDisposable +{ + private Timer? _timer; + + public Task StartAsync(CancellationToken cancellationToken) + { + // Run immediately, then every 60 seconds + _timer = new Timer(_ => _ = RunAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(60)); + return Task.CompletedTask; + } + + private async Task RunAsync() + { + try + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); + var pushService = scope.ServiceProvider.GetRequiredService<IPushNotificationService>(); + + var now = DateTime.UtcNow; + + // Find alerts that are published and not yet hidden, but haven't been notified + // for their current phase (PreNotice: published but event not yet started; + // Active: event in progress). + var alertsToNotify = await db.ServiceAlerts + .Where(a => + a.PublishDate <= now && a.HiddenDate > now && + ( + // PreNotice: published, event hasn't started, no prenotice notification sent yet + (a.EventStartDate > now && a.PreNoticeNotifiedAt == null) || + // Active: event started and not finished, no active notification sent yet + (a.EventStartDate <= now && a.EventEndDate > now && a.ActiveNotifiedAt == null) + )) + .ToListAsync(); + + if (alertsToNotify.Count == 0) return; + + logger.LogInformation("Sending push notifications for {Count} alert(s)", alertsToNotify.Count); + + foreach (var alert in alertsToNotify) + { + try + { + await pushService.SendAlertAsync(alert); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending push notification for alert {AlertId}", alert.Id); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error in {ServiceName}", nameof(AlertPhaseNotificationHostedService)); + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _timer?.Change(Timeout.Infinite, 0); + return Task.CompletedTask; + } + + public void Dispose() => _timer?.Dispose(); +} diff --git a/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs b/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs index d09e207..b949aa7 100644 --- a/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs +++ b/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs @@ -42,14 +42,16 @@ public class BackofficeSelectorService( { var (feedId, routeId) = SplitGtfsId(r.GtfsId); var color = NormalizeColor(r.Color); - return new SelectorRouteItem(feedId, r.GtfsId, $"route#{feedId}:{routeId}", r.ShortName, r.LongName, r.Agency?.Name, color); + return new SelectorRouteItem(feedId, r.GtfsId, $"route#{feedId}:{routeId}", r.ShortName, r.LongName, r.Agency?.Name, r.Agency?.GtfsId, color); }) .OrderBy(r => r.ShortName) .ToList(); + // Group by the full agency gtfsId (feedId:agencyId) so that feeds with + // multiple agencies each get their own entry. var agencyDtos = routeDtos - .Where(r => r.AgencyName is not null) - .GroupBy(r => r.FeedId) + .Where(r => r.AgencyGtfsId is not null && r.AgencyName is not null) + .GroupBy(r => r.AgencyGtfsId!) .Select(g => new SelectorAgencyItem(g.Key, $"agency#{g.Key}", g.First().AgencyName!)) .ToList(); @@ -82,7 +84,7 @@ public class BackofficeSelectorService( var routeItems = (s.Routes ?? []).Select(r => { var (rf, ri) = SplitGtfsId(r.GtfsId); - return new SelectorRouteItem(rf, r.GtfsId, $"route#{rf}:{ri}", r.ShortName, null, null, NormalizeColor(r.Color)); + return new SelectorRouteItem(rf, r.GtfsId, $"route#{rf}:{ri}", r.ShortName, null, null, null, NormalizeColor(r.Color)); }).ToList(); return new SelectorStopItem(s.GtfsId, $"stop#{feedId}:{stopId}", s.Name, s.Code, s.Lat, s.Lon, routeItems); }) @@ -112,6 +114,7 @@ public class BackofficeSelectorService( } public record SelectorTransitData(List<SelectorAgencyItem> Agencies, List<SelectorRouteItem> Routes); -public record SelectorAgencyItem(string FeedId, string Selector, string Name); -public record SelectorRouteItem(string FeedId, string GtfsId, string Selector, string? ShortName, string? LongName, string? AgencyName, string? Color); +/// <param name="AgencyGtfsId">Full GTFS agency id in the form <c>feedId:agencyId</c>.</param> +public record SelectorAgencyItem(string AgencyGtfsId, string Selector, string Name); +public record SelectorRouteItem(string FeedId, string GtfsId, string Selector, string? ShortName, string? LongName, string? AgencyName, string? AgencyGtfsId, string? Color); public record SelectorStopItem(string GtfsId, string Selector, string Name, string? Code, double Lat, double Lon, List<SelectorRouteItem> Routes); diff --git a/src/Enmarcha.Backend/Services/OtpService.cs b/src/Enmarcha.Backend/Services/OtpService.cs index 16ba029..a7054b6 100644 --- a/src/Enmarcha.Backend/Services/OtpService.cs +++ b/src/Enmarcha.Backend/Services/OtpService.cs @@ -54,6 +54,7 @@ public class OtpService TextColor = textColor, SortOrder = route.SortOrder, AgencyName = route.Agency?.Name, + AgencyId = route.Agency?.GtfsId, TripCount = route.Patterns.Sum(p => p.TripsForDate.Count) }; } 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(); + } +} |
