aboutsummaryrefslogtreecommitdiff
path: root/src/Enmarcha.Backend/Services
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-04-02 12:38:10 +0200
committerAriel Costas Guerrero <ariel@costas.dev>2026-04-02 12:45:33 +0200
commit1b4f4a674ac533c0b51260ba35ab91dd2cf9486d (patch)
tree9fdaf418bef86c51737bcf203483089c9e2b908b /src/Enmarcha.Backend/Services
parent749e04d6fc2304bb29920db297d1fa4d73b57648 (diff)
Basic push notification system for service alerts
Co-authored-by: Copilot <copilot@github.com>
Diffstat (limited to 'src/Enmarcha.Backend/Services')
-rw-r--r--src/Enmarcha.Backend/Services/AlertPhaseNotificationHostedService.cs77
-rw-r--r--src/Enmarcha.Backend/Services/BackofficeSelectorService.cs15
-rw-r--r--src/Enmarcha.Backend/Services/OtpService.cs1
-rw-r--r--src/Enmarcha.Backend/Services/PushNotificationService.cs134
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();
+ }
+}