summaryrefslogtreecommitdiff
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
parent749e04d6fc2304bb29920db297d1fa4d73b57648 (diff)
Basic push notification system for service alerts
Co-authored-by: Copilot <copilot@github.com>
-rw-r--r--Directory.Packages.props1
-rw-r--r--Taskfile.yml45
-rw-r--r--justfile38
-rw-r--r--src/Enmarcha.Backend/Configuration/AppConfiguration.cs20
-rw-r--r--src/Enmarcha.Backend/Controllers/AlertsController.cs40
-rw-r--r--src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs21
-rw-r--r--src/Enmarcha.Backend/Controllers/PushController.cs50
-rw-r--r--src/Enmarcha.Backend/Data/AppDbContext.cs7
-rw-r--r--src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.Designer.cs440
-rw-r--r--src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs74
-rw-r--r--src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs48
-rw-r--r--src/Enmarcha.Backend/Data/Models/AlertSelector.cs3
-rw-r--r--src/Enmarcha.Backend/Data/Models/PushSubscription.cs20
-rw-r--r--src/Enmarcha.Backend/Data/Models/ServiceAlert.cs9
-rw-r--r--src/Enmarcha.Backend/Enmarcha.Backend.csproj1
-rw-r--r--src/Enmarcha.Backend/Program.cs3
-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
-rw-r--r--src/Enmarcha.Backend/Types/Transit/RouteDtos.cs2
-rw-r--r--src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs2
-rw-r--r--src/Enmarcha.Backend/Views/Alerts/Edit.cshtml4
-rw-r--r--src/Enmarcha.Backend/Views/Alerts/Index.cshtml14
-rw-r--r--src/Enmarcha.Backend/appsettings.json9
-rw-r--r--src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs2
-rw-r--r--src/frontend/app/api/schema.ts1
-rw-r--r--src/frontend/app/components/PushNotificationSettings.tsx198
-rw-r--r--src/frontend/app/components/ServiceAlerts.tsx115
-rw-r--r--src/frontend/app/data/StopDataProvider.ts8
-rw-r--r--src/frontend/app/hooks/useFavorites.ts6
-rw-r--r--src/frontend/app/routes/favourites.tsx5
-rw-r--r--src/frontend/app/routes/routes.tsx35
-rw-r--r--src/frontend/app/routes/settings.tsx8
-rw-r--r--src/frontend/app/utils/idb.ts83
-rw-r--r--src/frontend/public/pwa-worker.js247
36 files changed, 1710 insertions, 76 deletions
diff --git a/Directory.Packages.props b/Directory.Packages.props
index c59ee3c..14ea9e9 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,6 +24,7 @@
<PackageVersion Include="NodaTime" Version="3.3.1" />
<PackageVersion Include="CsvHelper" Version="33.1.0" />
<PackageVersion Include="FuzzySharp" Version="2.0.2" />
+ <PackageVersion Include="WebPush" Version="1.0.11" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
diff --git a/Taskfile.yml b/Taskfile.yml
deleted file mode 100644
index 6ed06f5..0000000
--- a/Taskfile.yml
+++ /dev/null
@@ -1,45 +0,0 @@
-version: "3"
-
-tasks:
- dev-backend:
- desc: Run backend in watch mode.
- cmds:
- - dotnet watch --project src/Enmarcha.Backend/Enmarcha.Backend.csproj
-
- dev-frontend:
- desc: Run frontend development server.
- cmds:
- - npm run dev --prefix src/frontend
-
- build-backend:
- desc: Publish backend in Release mode.
- cmds:
- - dotnet publish -c Release -o ./dist/backend src/Enmarcha.Backend/Enmarcha.Backend.csproj
-
- build-backend-prod:
- desc: Publish backend for Prod server
- cmds:
- - dotnet publish -c Release -r linux-arm64 --self-contained false src/Enmarcha.Backend/Enmarcha.Backend.csproj -o dist/backend
-
- build-frontend:
- desc: Build frontend bundle.
- cmds:
- - npm run build --prefix src/frontend
- - mkdir dist/frontend
- - cp -r src/frontend/build/client/* dist/frontend
-
- format:
- desc: Format backend solution.
- cmds:
- - dotnet format --verbosity diagnostic src/Enmarcha.Backend/Enmarcha.Backend.csproj
- - npx prettier --write "src/frontend/**/*.{ts,tsx,css}"
-
- dbmigrate:
- desc: Run database migrations.
- cmds:
- - dotnet ef migrations add --project src/Enmarcha.Backend/Enmarcha.Backend.csproj
-
- dbupdate:
- desc: Update database with latest migrations.
- cmds:
- - dotnet ef database update --project src/Enmarcha.Backend/Enmarcha.Backend.csproj
diff --git a/justfile b/justfile
new file mode 100644
index 0000000..811871b
--- /dev/null
+++ b/justfile
@@ -0,0 +1,38 @@
+# https://just.systems
+
+default:
+ just --list
+
+dev-backend:
+ dotnet watch --project src/Enmarcha.Backend/Enmarcha.Backend.csproj
+
+dev-frontend:
+ npm run dev --prefix src/frontend
+
+build-backend:
+ dotnet publish -c Release -o ./dist/backend src/Enmarcha.Backend/Enmarcha.Backend.csproj
+
+build-backend-prod:
+ dotnet publish -c Release -r linux-arm64 --self-contained false src/Enmarcha.Backend/Enmarcha.Backend.csproj -o dist/backend
+
+build-frontend:
+ npm run build --prefix src/frontend
+ mkdir dist/frontend
+ cp -r src/frontend/build/client/* dist/frontend
+
+format-backend:
+ dotnet format --verbosity diagnostic src/Enmarcha.Backend/Enmarcha.Backend.csproj
+
+format-frontend:
+ npx prettier --write "src/frontend/**/*.{ts,tsx,css}"
+
+format: format-backend format-frontend
+
+db-migrate NAME:
+ dotnet ef migrations add {{NAME}} --project src/Enmarcha.Backend/Enmarcha.Backend.csproj
+
+db-update:
+ dotnet ef database update --project src/Enmarcha.Backend/Enmarcha.Backend.csproj
+
+db-bundle:
+ dotnet ef migrations bundle --project src/Enmarcha.Backend/Enmarcha.Backend.csproj -o dist/dbbundle
diff --git a/src/Enmarcha.Backend/Configuration/AppConfiguration.cs b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs
index 8c6e411..ca2425b 100644
--- a/src/Enmarcha.Backend/Configuration/AppConfiguration.cs
+++ b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs
@@ -7,6 +7,7 @@ public class AppConfiguration
public string NominatimBaseUrl { get; set; } = "https://nominatim.openstreetmap.org";
public string[] OtpFeeds { get; set; } = [];
public OpenTelemetryConfiguration? OpenTelemetry { get; set; }
+ public VapidConfiguration? Vapid { get; set; }
}
public class OpenTelemetryConfiguration
@@ -14,3 +15,22 @@ public class OpenTelemetryConfiguration
public string? Endpoint { get; set; }
public string? Headers { get; set; }
}
+
+public class VapidConfiguration
+{
+ /// <summary>
+ /// VAPID subject — typically "mailto:admin@yourdomain.com" or a URL.
+ /// </summary>
+ public required string Subject { get; set; }
+
+ /// <summary>
+ /// Base64url-encoded VAPID public key. Safe to expose to browsers.
+ /// </summary>
+ public required string PublicKey { get; set; }
+
+ /// <summary>
+ /// Base64url-encoded VAPID private key. Store in user secrets or environment variables only.
+ /// Generate a key pair with: VapidHelper.GenerateVapidKeys() from the WebPush NuGet package.
+ /// </summary>
+ public required string PrivateKey { get; set; }
+}
diff --git a/src/Enmarcha.Backend/Controllers/AlertsController.cs b/src/Enmarcha.Backend/Controllers/AlertsController.cs
new file mode 100644
index 0000000..4860399
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/AlertsController.cs
@@ -0,0 +1,40 @@
+using Enmarcha.Backend.Data;
+using Enmarcha.Backend.Data.Models;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Enmarcha.Backend.Controllers;
+
+[Route("api/alerts")]
+[ApiController]
+public class AlertsController(AppDbContext db) : ControllerBase
+{
+ /// <summary>
+ /// Returns all service alerts that are currently published and not yet hidden.
+ /// Includes PreNotice, Active, and Finished phases.
+ /// </summary>
+ [HttpGet]
+ public async Task<IActionResult> GetAlerts()
+ {
+ var now = DateTime.UtcNow;
+ var alerts = await db.ServiceAlerts
+ .Where(a => a.PublishDate <= now && a.HiddenDate > now)
+ .OrderByDescending(a => a.EventStartDate)
+ .ToListAsync();
+
+ return Ok(alerts.Select(a => new
+ {
+ id = a.Id,
+ version = a.Version,
+ phase = a.GetPhase(now).ToString(),
+ cause = a.Cause.ToString(),
+ effect = a.Effect.ToString(),
+ header = (Dictionary<string, string>)a.Header,
+ description = (Dictionary<string, string>)a.Description,
+ selectors = a.Selectors.Select(s => s.Raw).ToList(),
+ infoUrls = a.InfoUrls,
+ eventStartDate = a.EventStartDate,
+ eventEndDate = a.EventEndDate,
+ }));
+ }
+}
diff --git a/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs b/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs
index 4e83abc..3fa499e 100644
--- a/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs
+++ b/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs
@@ -1,4 +1,5 @@
using Enmarcha.Backend.Data;
+using Enmarcha.Backend.Services;
using Enmarcha.Backend.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -8,7 +9,11 @@ namespace Enmarcha.Backend.Controllers.Backoffice;
[Route("backoffice/alerts")]
[Authorize(AuthenticationSchemes = "Backoffice")]
-public class AlertsController(AppDbContext db) : Controller
+public class AlertsController(
+ AppDbContext db,
+ IPushNotificationService pushService,
+ ILogger<AlertsController> logger
+) : Controller
{
[HttpGet("")]
public async Task<IActionResult> Index()
@@ -28,6 +33,7 @@ public class AlertsController(AppDbContext db) : Controller
{
if (!ModelState.IsValid)
{
+ logger.LogWarning("Invalid model state when creating alert: {Errors}", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
return View("Edit", model);
}
@@ -50,6 +56,7 @@ public class AlertsController(AppDbContext db) : Controller
{
if (!ModelState.IsValid)
{
+ logger.LogWarning("Invalid model state when editing alert {Id}: {Errors}", id, ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage));
return View("Edit", model);
}
@@ -80,4 +87,16 @@ public class AlertsController(AppDbContext db) : Controller
await db.SaveChangesAsync();
return RedirectToAction(nameof(Index));
}
+
+ [HttpPost("{id}/push")]
+ [ValidateAntiForgeryToken]
+ public async Task<IActionResult> SendPush(string id)
+ {
+ var alert = await db.ServiceAlerts.FindAsync(id);
+ if (alert is null) return NotFound();
+
+ await pushService.SendAlertAsync(alert);
+ TempData["SuccessMessage"] = $"Notificación enviada (v{alert.Version})";
+ return RedirectToAction(nameof(Index));
+ }
}
diff --git a/src/Enmarcha.Backend/Controllers/PushController.cs b/src/Enmarcha.Backend/Controllers/PushController.cs
new file mode 100644
index 0000000..9df6b48
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/PushController.cs
@@ -0,0 +1,50 @@
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Services;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Controllers;
+
+[Route("api/push")]
+[ApiController]
+public class PushController(IPushNotificationService pushService, IOptions<AppConfiguration> config) : ControllerBase
+{
+ /// <summary>Returns the VAPID public key for the browser to use when subscribing.</summary>
+ [HttpGet("vapid-public-key")]
+ public IActionResult GetVapidPublicKey()
+ {
+ var vapid = config.Value.Vapid;
+ if (vapid is null)
+ return StatusCode(StatusCodes.Status503ServiceUnavailable, "Push notifications are not configured on this server.");
+
+ return Ok(new { publicKey = vapid.PublicKey });
+ }
+
+ /// <summary>Registers a new push subscription.</summary>
+ [HttpPost("subscribe")]
+ public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request)
+ {
+ if (!Uri.TryCreate(request.Endpoint, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps)
+ return BadRequest("Invalid push endpoint: must be an absolute HTTPS URL.");
+
+ if (string.IsNullOrWhiteSpace(request.P256Dh) || string.IsNullOrWhiteSpace(request.Auth))
+ return BadRequest("Missing encryption keys.");
+
+ await pushService.SubscribeAsync(request.Endpoint, request.P256Dh, request.Auth);
+ return NoContent();
+ }
+
+ /// <summary>Removes a push subscription.</summary>
+ [HttpDelete("unsubscribe")]
+ public async Task<IActionResult> Unsubscribe([FromBody] UnsubscribeRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request.Endpoint))
+ return BadRequest("Endpoint is required.");
+
+ await pushService.UnsubscribeAsync(request.Endpoint);
+ return NoContent();
+ }
+}
+
+public record SubscribeRequest(string Endpoint, string P256Dh, string Auth);
+public record UnsubscribeRequest(string Endpoint);
diff --git a/src/Enmarcha.Backend/Data/AppDbContext.cs b/src/Enmarcha.Backend/Data/AppDbContext.cs
index d5a29ee..e191b26 100644
--- a/src/Enmarcha.Backend/Data/AppDbContext.cs
+++ b/src/Enmarcha.Backend/Data/AppDbContext.cs
@@ -14,6 +14,7 @@ public class AppDbContext : IdentityDbContext<IdentityUser>
}
public DbSet<ServiceAlert> ServiceAlerts { get; set; }
+ public DbSet<PushSubscription> PushSubscriptions { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
@@ -72,5 +73,11 @@ public class AppDbContext : IdentityDbContext<IdentityUser>
v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>(),
JsonComparer<List<string>>());
});
+
+ builder.Entity<PushSubscription>(b =>
+ {
+ b.HasKey(x => x.Id);
+ b.HasIndex(x => x.Endpoint).IsUnique();
+ });
}
}
diff --git a/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.Designer.cs b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.Designer.cs
new file mode 100644
index 0000000..5bedd08
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.Designer.cs
@@ -0,0 +1,440 @@
+// <auto-generated />
+using System;
+using Enmarcha.Backend.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260401135403_AddPushNotifications")]
+ partial class AddPushNotifications
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.3")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Enmarcha.Backend.Data.Models.PushSubscription", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("AuthKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("auth_key");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Endpoint")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("endpoint");
+
+ b.Property<string>("P256DhKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("p256dh_key");
+
+ b.HasKey("Id")
+ .HasName("pK_push_subscriptions");
+
+ b.HasIndex("Endpoint")
+ .IsUnique()
+ .HasDatabaseName("iX_push_subscriptions_endpoint");
+
+ b.ToTable("push_subscriptions", (string)null);
+ });
+
+ modelBuilder.Entity("Enmarcha.Backend.Data.Models.ServiceAlert", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("text")
+ .HasColumnName("id");
+
+ b.Property<DateTime?>("ActiveNotifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("active_notified_at");
+
+ b.Property<int>("Cause")
+ .HasColumnType("integer")
+ .HasColumnName("cause");
+
+ b.Property<string>("Description")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("description");
+
+ b.Property<int>("Effect")
+ .HasColumnType("integer")
+ .HasColumnName("effect");
+
+ b.Property<DateTime>("EventEndDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("event_end_date");
+
+ b.Property<DateTime>("EventStartDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("event_start_date");
+
+ b.Property<string>("Header")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("header");
+
+ b.Property<DateTime>("HiddenDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("hidden_date");
+
+ b.Property<string>("InfoUrls")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("info_urls");
+
+ b.Property<DateTime>("InsertedDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("inserted_date");
+
+ b.Property<DateTime?>("PreNoticeNotifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("pre_notice_notified_at");
+
+ b.Property<DateTime>("PublishDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("publish_date");
+
+ b.Property<string>("Selectors")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("selectors");
+
+ b.Property<int>("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.HasKey("Id")
+ .HasName("pK_service_alerts");
+
+ b.ToTable("service_alerts", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("text")
+ .HasColumnName("id");
+
+ b.Property<string>("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text")
+ .HasColumnName("concurrencyStamp");
+
+ b.Property<string>("Name")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property<string>("NormalizedName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("normalizedName");
+
+ b.HasKey("Id")
+ .HasName("pK_roles");
+
+ b.HasIndex("NormalizedName")
+ .IsUnique()
+ .HasDatabaseName("RoleNameIndex");
+
+ b.ToTable("roles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+ b.Property<string>("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claimType");
+
+ b.Property<string>("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claimValue");
+
+ b.Property<string>("RoleId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("roleId");
+
+ b.HasKey("Id")
+ .HasName("pK_role_claims");
+
+ b.HasIndex("RoleId")
+ .HasDatabaseName("iX_role_claims_roleId");
+
+ b.ToTable("role_claims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("text")
+ .HasColumnName("id");
+
+ b.Property<int>("AccessFailedCount")
+ .HasColumnType("integer")
+ .HasColumnName("accessFailedCount");
+
+ b.Property<string>("ConcurrencyStamp")
+ .IsConcurrencyToken()
+ .HasColumnType("text")
+ .HasColumnName("concurrencyStamp");
+
+ b.Property<string>("Email")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("email");
+
+ b.Property<bool>("EmailConfirmed")
+ .HasColumnType("boolean")
+ .HasColumnName("emailConfirmed");
+
+ b.Property<bool>("LockoutEnabled")
+ .HasColumnType("boolean")
+ .HasColumnName("lockoutEnabled");
+
+ b.Property<DateTimeOffset?>("LockoutEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("lockoutEnd");
+
+ b.Property<string>("NormalizedEmail")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("normalizedEmail");
+
+ b.Property<string>("NormalizedUserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("normalizedUserName");
+
+ b.Property<string>("PasswordHash")
+ .HasColumnType("text")
+ .HasColumnName("passwordHash");
+
+ b.Property<string>("PhoneNumber")
+ .HasColumnType("text")
+ .HasColumnName("phoneNumber");
+
+ b.Property<bool>("PhoneNumberConfirmed")
+ .HasColumnType("boolean")
+ .HasColumnName("phoneNumberConfirmed");
+
+ b.Property<string>("SecurityStamp")
+ .HasColumnType("text")
+ .HasColumnName("securityStamp");
+
+ b.Property<bool>("TwoFactorEnabled")
+ .HasColumnType("boolean")
+ .HasColumnName("twoFactorEnabled");
+
+ b.Property<string>("UserName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("userName");
+
+ b.HasKey("Id")
+ .HasName("pK_users");
+
+ b.HasIndex("NormalizedEmail")
+ .HasDatabaseName("EmailIndex");
+
+ b.HasIndex("NormalizedUserName")
+ .IsUnique()
+ .HasDatabaseName("UserNameIndex");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
+
+ b.Property<string>("ClaimType")
+ .HasColumnType("text")
+ .HasColumnName("claimType");
+
+ b.Property<string>("ClaimValue")
+ .HasColumnType("text")
+ .HasColumnName("claimValue");
+
+ b.Property<string>("UserId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("userId");
+
+ b.HasKey("Id")
+ .HasName("pK_user_claims");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("iX_user_claims_userId");
+
+ b.ToTable("user_claims", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
+ {
+ b.Property<string>("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("loginProvider");
+
+ b.Property<string>("ProviderKey")
+ .HasColumnType("text")
+ .HasColumnName("providerKey");
+
+ b.Property<string>("ProviderDisplayName")
+ .HasColumnType("text")
+ .HasColumnName("providerDisplayName");
+
+ b.Property<string>("UserId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("userId");
+
+ b.HasKey("LoginProvider", "ProviderKey")
+ .HasName("pK_user_logins");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("iX_user_logins_userId");
+
+ b.ToTable("user_logins", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
+ {
+ b.Property<string>("UserId")
+ .HasColumnType("text")
+ .HasColumnName("userId");
+
+ b.Property<string>("RoleId")
+ .HasColumnType("text")
+ .HasColumnName("roleId");
+
+ b.HasKey("UserId", "RoleId")
+ .HasName("pK_user_roles");
+
+ b.HasIndex("RoleId")
+ .HasDatabaseName("iX_user_roles_roleId");
+
+ b.ToTable("user_roles", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
+ {
+ b.Property<string>("UserId")
+ .HasColumnType("text")
+ .HasColumnName("userId");
+
+ b.Property<string>("LoginProvider")
+ .HasColumnType("text")
+ .HasColumnName("loginProvider");
+
+ b.Property<string>("Name")
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property<string>("Value")
+ .HasColumnType("text")
+ .HasColumnName("value");
+
+ b.HasKey("UserId", "LoginProvider", "Name")
+ .HasName("pK_user_tokens");
+
+ b.ToTable("user_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fK_role_claims_roles_roleId");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fK_user_claims_users_userId");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fK_user_logins_users_userId");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
+ .WithMany()
+ .HasForeignKey("RoleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fK_user_roles_roles_roleId");
+
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fK_user_roles_users_userId");
+ });
+
+ modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
+ {
+ b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fK_user_tokens_users_userId");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs
new file mode 100644
index 0000000..964a86f
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs
@@ -0,0 +1,74 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Data.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddPushNotifications : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<DateTime>(
+ name: "active_notified_at",
+ table: "service_alerts",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<DateTime>(
+ name: "pre_notice_notified_at",
+ table: "service_alerts",
+ type: "timestamp with time zone",
+ nullable: true);
+
+ migrationBuilder.AddColumn<int>(
+ name: "version",
+ table: "service_alerts",
+ type: "integer",
+ nullable: false,
+ defaultValue: 0);
+
+ migrationBuilder.CreateTable(
+ name: "push_subscriptions",
+ columns: table => new
+ {
+ id = table.Column<Guid>(type: "uuid", nullable: false),
+ endpoint = table.Column<string>(type: "text", nullable: false),
+ p256dh_key = table.Column<string>(type: "text", nullable: false),
+ auth_key = table.Column<string>(type: "text", nullable: false),
+ created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pK_push_subscriptions", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "iX_push_subscriptions_endpoint",
+ table: "push_subscriptions",
+ column: "endpoint",
+ unique: true);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "push_subscriptions");
+
+ migrationBuilder.DropColumn(
+ name: "active_notified_at",
+ table: "service_alerts");
+
+ migrationBuilder.DropColumn(
+ name: "pre_notice_notified_at",
+ table: "service_alerts");
+
+ migrationBuilder.DropColumn(
+ name: "version",
+ table: "service_alerts");
+ }
+ }
+}
diff --git a/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs
index 88488bf..f02eb20 100644
--- a/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -23,12 +23,52 @@ namespace Data.Migrations
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+ modelBuilder.Entity("Enmarcha.Backend.Data.Models.PushSubscription", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property<string>("AuthKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("auth_key");
+
+ b.Property<DateTime>("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property<string>("Endpoint")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("endpoint");
+
+ b.Property<string>("P256DhKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("p256dh_key");
+
+ b.HasKey("Id")
+ .HasName("pK_push_subscriptions");
+
+ b.HasIndex("Endpoint")
+ .IsUnique()
+ .HasDatabaseName("iX_push_subscriptions_endpoint");
+
+ b.ToTable("push_subscriptions", (string)null);
+ });
+
modelBuilder.Entity("Enmarcha.Backend.Data.Models.ServiceAlert", b =>
{
b.Property<string>("Id")
.HasColumnType("text")
.HasColumnName("id");
+ b.Property<DateTime?>("ActiveNotifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("active_notified_at");
+
b.Property<int>("Cause")
.HasColumnType("integer")
.HasColumnName("cause");
@@ -68,6 +108,10 @@ namespace Data.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("inserted_date");
+ b.Property<DateTime?>("PreNoticeNotifiedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("pre_notice_notified_at");
+
b.Property<DateTime>("PublishDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("publish_date");
@@ -77,6 +121,10 @@ namespace Data.Migrations
.HasColumnType("jsonb")
.HasColumnName("selectors");
+ b.Property<int>("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
b.HasKey("Id")
.HasName("pK_service_alerts");
diff --git a/src/Enmarcha.Backend/Data/Models/AlertSelector.cs b/src/Enmarcha.Backend/Data/Models/AlertSelector.cs
index 34b2de3..e2b01f1 100644
--- a/src/Enmarcha.Backend/Data/Models/AlertSelector.cs
+++ b/src/Enmarcha.Backend/Data/Models/AlertSelector.cs
@@ -13,7 +13,8 @@ public class AlertSelector
public static AlertSelector FromStop(string feedId, string stopId) => new() { Raw = $"stop#{feedId}:{stopId}" };
public static AlertSelector FromRoute(string feedId, string routeId) => new() { Raw = $"route#{feedId}:{routeId}" };
- public static AlertSelector FromAgency(string feedId) => new() { Raw = $"agency#{feedId}" };
+ /// <param name="agencyGtfsId">Full GTFS agency id in the form <c>feedId:agencyId</c> (e.g. <c>vitrasa:1</c>).</param>
+ public static AlertSelector FromAgency(string agencyGtfsId) => new() { Raw = $"agency#{agencyGtfsId}" };
public override string ToString() => Raw;
}
diff --git a/src/Enmarcha.Backend/Data/Models/PushSubscription.cs b/src/Enmarcha.Backend/Data/Models/PushSubscription.cs
new file mode 100644
index 0000000..c72c40f
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/Models/PushSubscription.cs
@@ -0,0 +1,20 @@
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Enmarcha.Backend.Data.Models;
+
+[Table("push_subscriptions")]
+public class PushSubscription
+{
+ public Guid Id { get; set; }
+
+ /// <summary>Push endpoint URL provided by the browser's push service.</summary>
+ public string Endpoint { get; set; } = string.Empty;
+
+ /// <summary>P-256 DH public key for payload encryption (base64url).</summary>
+ [Column("p256dh_key")] public string P256DhKey { get; set; } = string.Empty;
+
+ /// <summary>Auth secret for payload encryption (base64url).</summary>
+ [Column("auth_key")] public string AuthKey { get; set; } = string.Empty;
+
+ [Column("created_at")] public DateTime CreatedAt { get; set; }
+}
diff --git a/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs b/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs
index 5f80e3c..39cf3fa 100644
--- a/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs
+++ b/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs
@@ -24,6 +24,15 @@ public class ServiceAlert
[Column("event_end_date")] public DateTime EventEndDate { get; set; }
[Column("hidden_date")] public DateTime HiddenDate { get; set; }
+ /// <summary>Incremented each time a push notification is sent for this alert.</summary>
+ public int Version { get; set; } = 1;
+
+ /// <summary>Set when a push notification was sent for the PreNotice phase.</summary>
+ [Column("pre_notice_notified_at")] public DateTime? PreNoticeNotifiedAt { get; set; }
+
+ /// <summary>Set when a push notification was sent for the Active phase.</summary>
+ [Column("active_notified_at")] public DateTime? ActiveNotifiedAt { get; set; }
+
public AlertPhase GetPhase(DateTime? now = null)
{
now ??= DateTime.UtcNow;
diff --git a/src/Enmarcha.Backend/Enmarcha.Backend.csproj b/src/Enmarcha.Backend/Enmarcha.Backend.csproj
index c70624c..e1b4074 100644
--- a/src/Enmarcha.Backend/Enmarcha.Backend.csproj
+++ b/src/Enmarcha.Backend/Enmarcha.Backend.csproj
@@ -30,6 +30,7 @@
<PackageReference Include="CsvHelper" />
<PackageReference Include="FuzzySharp" />
+ <PackageReference Include="WebPush" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs
index 7ca0b34..8f21eed 100644
--- a/src/Enmarcha.Backend/Program.cs
+++ b/src/Enmarcha.Backend/Program.cs
@@ -202,6 +202,9 @@ builder.Services.AddSingleton<ShapeTraversalService>();
builder.Services.AddSingleton<FeedService>();
builder.Services.AddSingleton<FareService>();
+builder.Services.AddScoped<IPushNotificationService, PushNotificationService>();
+builder.Services.AddHostedService<AlertPhaseNotificationHostedService>();
+
builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, CorunaRealTimeProcessor>();
builder.Services.AddScoped<IArrivalsProcessor, TussaRealTimeProcessor>();
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();
+ }
+}
diff --git a/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs b/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs
index 8427ec7..8ce2f90 100644
--- a/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs
+++ b/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs
@@ -9,6 +9,7 @@ public class RouteDto
public string? TextColor { get; set; }
public int? SortOrder { get; set; }
public string? AgencyName { get; set; }
+ public string? AgencyId { get; set; }
public int TripCount { get; set; }
}
@@ -19,6 +20,7 @@ public class RouteDetailsDto
public string? Color { get; set; }
public string? TextColor { get; set; }
public string? AgencyName { get; set; }
+ public string? AgencyId { get; set; }
public List<PatternDto> Patterns { get; set; } = [];
}
diff --git a/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs b/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs
index e1e068e..6514a5a 100644
--- a/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs
+++ b/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs
@@ -17,7 +17,7 @@ public class AlertFormViewModel
[Display(Name = "Selectores (uno por línea)")]
public string SelectorsRaw { get; set; } = "";
- [Display(Name = "URLs de información (una por línea)")]
+ [Display(Name = "URLs de información (una por línea)"), Required(AllowEmptyStrings = true)]
public string InfoUrlsRaw { get; set; } = "";
[Display(Name = "Causa")]
diff --git a/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml b/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml
index 57e853d..63852d7 100644
--- a/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml
+++ b/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml
@@ -57,6 +57,8 @@
</a>
</div>
+<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div>
+
<form action="@formAction" method="post" novalidate>
@Html.AntiForgeryToken()
@if (!isCreate)
@@ -407,7 +409,7 @@
}
for (const a of agencies) el.appendChild(makeTransitItem(a.selector, () =>
`<i class="bi bi-building me-2"></i><span class="flex-grow-1">${escHtml(a.name)}</span>` +
- `<code class="text-muted small">${escHtml(a.feedId)}</code>`
+ `<code class="text-muted small">${escHtml(a.agencyGtfsId)}</code>`
));
}
diff --git a/src/Enmarcha.Backend/Views/Alerts/Index.cshtml b/src/Enmarcha.Backend/Views/Alerts/Index.cshtml
index d541ccc..114f3a5 100644
--- a/src/Enmarcha.Backend/Views/Alerts/Index.cshtml
+++ b/src/Enmarcha.Backend/Views/Alerts/Index.cshtml
@@ -5,6 +5,14 @@
ViewData["Title"] = "Alertas de servicio";
}
+@if (TempData["SuccessMessage"] is string successMsg)
+{
+ <div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 mb-4" role="alert">
+ <i class="bi bi-check-circle-fill"></i> @successMsg
+ <button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
+ </div>
+}
+
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="bi bi-exclamation-triangle me-2 text-warning"></i>Alertas de servicio
@@ -66,6 +74,12 @@ else
title="Editar">
<i class="bi bi-pencil"></i>
</a>
+ <form action="/backoffice/alerts/@alert.Id/push" method="post" class="d-inline ms-1">
+ @Html.AntiForgeryToken()
+ <button type="submit" class="btn btn-sm btn-outline-primary" title="Enviar notificación push">
+ <i class="bi bi-bell"></i>
+ </button>
+ </form>
<a href="/backoffice/alerts/@alert.Id/delete"
class="btn btn-sm btn-outline-danger ms-1"
title="Eliminar">
diff --git a/src/Enmarcha.Backend/appsettings.json b/src/Enmarcha.Backend/appsettings.json
index d09e564..3b9f47f 100644
--- a/src/Enmarcha.Backend/appsettings.json
+++ b/src/Enmarcha.Backend/appsettings.json
@@ -12,5 +12,12 @@
}
}
},
- "AllowedHosts": "*"
+ "AllowedHosts": "*",
+ "App": {
+ "Vapid": {
+ "Subject": "mailto:admin@example.com",
+ "PublicKey": "",
+ "PrivateKey": ""
+ }
+ }
}
diff --git a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs
index 9894f14..a4f37dd 100644
--- a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs
+++ b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs
@@ -21,6 +21,7 @@ public class RoutesListContent : IGraphRequest<RoutesListContent.Args>
textColor
sortOrder
agency {
+ gtfsId
name
}
patterns {
@@ -52,6 +53,7 @@ public class RoutesListResponse : AbstractGraphResponse
public class AgencyItem
{
+ [JsonPropertyName("gtfsId")] public string? GtfsId { get; set; }
[JsonPropertyName("name")] public string? Name { get; set; }
}
diff --git a/src/frontend/app/api/schema.ts b/src/frontend/app/api/schema.ts
index 20daede..40358a6 100644
--- a/src/frontend/app/api/schema.ts
+++ b/src/frontend/app/api/schema.ts
@@ -110,6 +110,7 @@ export const RouteSchema = z.object({
textColor: z.string().nullable(),
sortOrder: z.number().nullable(),
agencyName: z.string().nullable().optional(),
+ agencyId: z.string().nullable().optional(),
tripCount: z.number(),
});
diff --git a/src/frontend/app/components/PushNotificationSettings.tsx b/src/frontend/app/components/PushNotificationSettings.tsx
new file mode 100644
index 0000000..af3206a
--- /dev/null
+++ b/src/frontend/app/components/PushNotificationSettings.tsx
@@ -0,0 +1,198 @@
+import { BellOff, BellRing, Loader } from "lucide-react";
+import { useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { writeFavorites } from "~/utils/idb";
+
+/** Convert a base64url string (as returned by the VAPID endpoint) to a Uint8Array. */
+function urlBase64ToUint8Array(base64String: string): Uint8Array {
+ const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
+ const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
+ const rawData = atob(base64);
+ return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0)));
+}
+
+/** Sync all three favourites lists from localStorage to IndexedDB. */
+async function syncFavouritesToIdb() {
+ const keys = [
+ "favouriteStops",
+ "favouriteRoutes",
+ "favouriteAgencies",
+ ] as const;
+ await Promise.all(
+ keys.map((key) => {
+ const raw = localStorage.getItem(key);
+ const ids: string[] = raw ? JSON.parse(raw) : [];
+ return writeFavorites(key, ids);
+ })
+ );
+}
+
+type Status =
+ | "loading" // checking current state
+ | "unsupported" // browser does not support Push API
+ | "denied" // permission was explicitly blocked
+ | "subscribed" // user is actively subscribed
+ | "unsubscribed"; // user is not subscribed (or permission not yet granted)
+
+export function PushNotificationSettings() {
+ const { t } = useTranslation();
+ const [status, setStatus] = useState<Status>("loading");
+ const [working, setWorking] = useState(false);
+
+ useEffect(() => {
+ checkStatus().then(setStatus);
+ }, []);
+
+ async function checkStatus(): Promise<Status> {
+ if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
+ return "unsupported";
+ }
+ if (Notification.permission === "denied") return "denied";
+
+ try {
+ const reg = await navigator.serviceWorker.ready;
+ const sub = await reg.pushManager.getSubscription();
+ return sub ? "subscribed" : "unsubscribed";
+ } catch {
+ return "unsubscribed";
+ }
+ }
+
+ async function subscribe() {
+ setWorking(true);
+ try {
+ const permission = await Notification.requestPermission();
+ if (permission !== "granted") {
+ setStatus("denied");
+ return;
+ }
+
+ // Fetch the VAPID public key
+ const res = await fetch("/api/push/vapid-public-key");
+ if (!res.ok) {
+ console.error("Push notifications not configured on this server.");
+ return;
+ }
+ const { publicKey } = await res.json();
+
+ const reg = await navigator.serviceWorker.ready;
+ const subscription = await reg.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: urlBase64ToUint8Array(publicKey),
+ });
+
+ // Mirror favourites to IDB before registering so the SW has them from day 1
+ await syncFavouritesToIdb();
+
+ const json = subscription.toJSON();
+ await fetch("/api/push/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ endpoint: json.endpoint,
+ p256Dh: json.keys?.p256dh,
+ auth: json.keys?.auth,
+ }),
+ });
+
+ setStatus("subscribed");
+ } finally {
+ setWorking(false);
+ }
+ }
+
+ async function unsubscribe() {
+ setWorking(true);
+ try {
+ const reg = await navigator.serviceWorker.ready;
+ const sub = await reg.pushManager.getSubscription();
+ if (sub) {
+ const endpoint = sub.endpoint;
+ await sub.unsubscribe();
+ await fetch("/api/push/unsubscribe", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ endpoint }),
+ });
+ }
+ setStatus("unsubscribed");
+ } finally {
+ setWorking(false);
+ }
+ }
+
+ return (
+ <section className="mb-8">
+ <h2 className="text-xl font-semibold mb-2 text-text">
+ {t("settings.push_title", "Notificaciones")}
+ </h2>
+ <p className="text-sm text-muted mb-4">
+ {t(
+ "settings.push_description",
+ "Recibe notificaciones cuando haya alertas de servicio relevantes para tus paradas, líneas o operadores favoritos."
+ )}
+ </p>
+
+ {status === "loading" && (
+ <div className="flex items-center gap-2 text-muted text-sm">
+ <Loader className="w-4 h-4 animate-spin" />
+ {t("common.loading", "Cargando...")}
+ </div>
+ )}
+
+ {status === "unsupported" && (
+ <p className="text-sm text-muted p-4 rounded-lg border border-border bg-surface">
+ {t(
+ "settings.push_unsupported",
+ "Tu navegador no soporta notificaciones push. Prueba con Chrome, Edge o Firefox."
+ )}
+ </p>
+ )}
+
+ {status === "denied" && (
+ <p className="text-sm text-muted p-4 rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950 dark:border-amber-700">
+ {t(
+ "settings.push_permission_denied",
+ "Has bloqueado los permisos de notificación en este navegador. Para activarlos, ve a la configuración del sitio y permite las notificaciones."
+ )}
+ </p>
+ )}
+
+ {(status === "subscribed" || status === "unsubscribed") && (
+ <label className="flex items-center justify-between p-4 rounded-lg border border-border bg-surface cursor-pointer hover:bg-surface/50 transition-colors">
+ <span className="flex items-center gap-2 text-text font-medium">
+ {status === "subscribed" ? (
+ <BellRing className="w-5 h-5 text-primary" />
+ ) : (
+ <BellOff className="w-5 h-5 text-muted" />
+ )}
+ {status === "subscribed"
+ ? t("settings.push_subscribed", "Notificaciones activadas")
+ : t(
+ "settings.push_subscribe",
+ "Activar notificaciones de alertas"
+ )}
+ </span>
+ <button
+ onClick={status === "subscribed" ? unsubscribe : subscribe}
+ disabled={working}
+ aria-pressed={status === "subscribed"}
+ className={`
+ relative inline-flex h-6 w-11 items-center rounded-full transition-colors duration-200
+ focus:outline-none focus:ring-2 focus:ring-primary/50
+ disabled:opacity-50
+ ${status === "subscribed" ? "bg-primary" : "bg-border"}
+ `}
+ >
+ <span
+ className={`
+ inline-block h-4 w-4 rounded-full bg-white shadow transition-transform duration-200
+ ${status === "subscribed" ? "translate-x-6" : "translate-x-1"}
+ `}
+ />
+ </button>
+ </label>
+ )}
+ </section>
+ );
+}
diff --git a/src/frontend/app/components/ServiceAlerts.tsx b/src/frontend/app/components/ServiceAlerts.tsx
index a6a1ee8..168ea83 100644
--- a/src/frontend/app/components/ServiceAlerts.tsx
+++ b/src/frontend/app/components/ServiceAlerts.tsx
@@ -1,22 +1,117 @@
+import { useQuery } from "@tanstack/react-query";
import React from "react";
import { useTranslation } from "react-i18next";
import "./ServiceAlerts.css";
-const ServiceAlerts: React.FC = () => {
- const { t } = useTranslation();
+interface ServiceAlert {
+ id: string;
+ version: number;
+ phase: string;
+ cause: string;
+ effect: string;
+ header: Record<string, string>;
+ description: Record<string, string>;
+ selectors: string[];
+ infoUrls: string[];
+ eventStartDate: string;
+ eventEndDate: string;
+}
+
+/** Maps an alert effect to one of the three CSS severity classes. */
+function effectToSeverity(effect: string): "info" | "warning" | "error" {
+ if (["NoService", "SignificantDelays", "AccessibilityIssue"].includes(effect))
+ return "error";
+ if (
+ ["ReducedService", "Detour", "ModifiedService", "StopMoved"].includes(
+ effect
+ )
+ )
+ return "warning";
+ return "info";
+}
+
+/** Maps an effect to an emoji icon. */
+function effectToIcon(effect: string): string {
+ const map: Record<string, string> = {
+ NoService: "🚫",
+ ReducedService: "⚠️",
+ SignificantDelays: "🕐",
+ Detour: "↩️",
+ AdditionalService: "➕",
+ ModifiedService: "🔄",
+ StopMoved: "📍",
+ AccessibilityIssue: "♿",
+ };
+ return map[effect] ?? "ℹ️";
+}
+
+interface ServiceAlertsProps {
+ /** If provided, only alerts whose selectors overlap with this list are shown. */
+ selectorFilter?: string[];
+}
+
+const ServiceAlerts: React.FC<ServiceAlertsProps> = ({ selectorFilter }) => {
+ const { t, i18n } = useTranslation();
+ const lang = i18n.language.slice(0, 2);
+
+ const { data: alerts, isLoading } = useQuery<ServiceAlert[]>({
+ queryKey: ["service-alerts"],
+ queryFn: () => fetch("/api/alerts").then((r) => r.json()),
+ staleTime: 5 * 60 * 1000,
+ retry: false,
+ });
+
+ if (isLoading || !alerts) return null;
+
+ const visible = alerts.filter((alert) => {
+ if (!selectorFilter || selectorFilter.length === 0) return true;
+ return alert.selectors.some((s) => selectorFilter.includes(s));
+ });
+
+ if (visible.length === 0) return null;
return (
<div className="service-alerts-container stoplist-section">
<h2 className="page-subtitle">{t("stoplist.service_alerts")}</h2>
- <div className="service-alert info">
- <div className="alert-icon">ℹ️</div>
- <div className="alert-content">
- <div className="alert-title">{t("stoplist.alerts_coming_soon")}</div>
- <div className="alert-message">
- {t("stoplist.alerts_description")}
+ {visible.map((alert) => {
+ const severity = effectToSeverity(alert.effect);
+ const icon = effectToIcon(alert.effect);
+ const title =
+ alert.header[lang] ??
+ alert.header["es"] ??
+ Object.values(alert.header)[0] ??
+ "";
+ const body =
+ alert.description[lang] ??
+ alert.description["es"] ??
+ Object.values(alert.description)[0] ??
+ "";
+
+ return (
+ <div key={alert.id} className={`service-alert ${severity}`}>
+ <div className="alert-icon">{icon}</div>
+ <div className="alert-content">
+ <div className="alert-title">{title}</div>
+ {body && <div className="alert-message">{body}</div>}
+ {alert.infoUrls.length > 0 && (
+ <div className="alert-message" style={{ marginTop: "0.25rem" }}>
+ {alert.infoUrls.map((url, i) => (
+ <a
+ key={i}
+ href={url}
+ target="_blank"
+ rel="noopener noreferrer"
+ style={{ display: "block" }}
+ >
+ {url}
+ </a>
+ ))}
+ </div>
+ )}
+ </div>
</div>
- </div>
- </div>
+ );
+ })}
</div>
);
};
diff --git a/src/frontend/app/data/StopDataProvider.ts b/src/frontend/app/data/StopDataProvider.ts
index d8219c9..c60f9aa 100644
--- a/src/frontend/app/data/StopDataProvider.ts
+++ b/src/frontend/app/data/StopDataProvider.ts
@@ -1,3 +1,5 @@
+import { writeFavorites } from "~/utils/idb";
+
export interface Stop {
stopId: string;
stopCode?: string;
@@ -168,6 +170,9 @@ function addFavourite(stopId: string | number) {
if (!favouriteStops.includes(id)) {
favouriteStops.push(id);
localStorage.setItem(`favouriteStops`, JSON.stringify(favouriteStops));
+ writeFavorites("favouriteStops", favouriteStops).catch(() => {
+ /* best-effort */
+ });
}
}
@@ -183,6 +188,9 @@ function removeFavourite(stopId: string | number) {
const newFavouriteStops = favouriteStops.filter((sid) => sid !== id);
localStorage.setItem(`favouriteStops`, JSON.stringify(newFavouriteStops));
+ writeFavorites("favouriteStops", newFavouriteStops).catch(() => {
+ /* best-effort */
+ });
}
function isFavourite(stopId: string | number): boolean {
diff --git a/src/frontend/app/hooks/useFavorites.ts b/src/frontend/app/hooks/useFavorites.ts
index 962ac2d..0eceba9 100644
--- a/src/frontend/app/hooks/useFavorites.ts
+++ b/src/frontend/app/hooks/useFavorites.ts
@@ -1,7 +1,10 @@
import { useState } from "react";
+import { writeFavorites } from "~/utils/idb";
/**
* A simple hook for managing favorite items in localStorage.
+ * Also mirrors changes to IndexedDB so the service worker can filter push
+ * notifications by favourites without access to localStorage.
* @param key LocalStorage key to use
* @returns [favorites, toggleFavorite, isFavorite]
*/
@@ -18,6 +21,9 @@ export function useFavorites(key: string) {
? prev.filter((item) => item !== id)
: [...prev, id];
localStorage.setItem(key, JSON.stringify(next));
+ writeFavorites(key, next).catch(() => {
+ /* best-effort */
+ });
return next;
});
};
diff --git a/src/frontend/app/routes/favourites.tsx b/src/frontend/app/routes/favourites.tsx
index 3d786b6..1b1d09b 100644
--- a/src/frontend/app/routes/favourites.tsx
+++ b/src/frontend/app/routes/favourites.tsx
@@ -99,7 +99,10 @@ export default function Favourites() {
return routes.reduce(
(acc, route) => {
const agency = route.agencyName || t("routes.unknown_agency", "Otros");
- if (!isFavoriteAgency(agency)) {
+ // Match by the agency's own gtfsId (feedId:agencyId) — consistent with
+ // what routes.tsx stores and with the alert selector format.
+ const agencyId = route.agencyId ?? route.id.split(":")[0];
+ if (!isFavoriteAgency(agencyId)) {
return acc;
}
diff --git a/src/frontend/app/routes/routes.tsx b/src/frontend/app/routes/routes.tsx
index 57dfe00..f65adaa 100644
--- a/src/frontend/app/routes/routes.tsx
+++ b/src/frontend/app/routes/routes.tsx
@@ -63,16 +63,30 @@ export default function RoutesPage() {
const sortedAgencyEntries = useMemo(() => {
if (!routesByAgency) return [];
- return Object.entries(routesByAgency).sort(([a], [b]) => {
+ return Object.entries(routesByAgency).sort(([a, routesA], [b, routesB]) => {
+ // Use the agency's own gtfsId (feedId:agencyId) as the stable key — this
+ // matches the "agency#feedId:agencyId" alert selector format and correctly
+ // handles feeds that contain multiple agencies.
+ const agencyIdA =
+ routesA?.[0]?.agencyId ??
+ routesA?.[0]?.id.split(":")[0] ??
+ a.toLowerCase();
+ const agencyIdB =
+ routesB?.[0]?.agencyId ??
+ routesB?.[0]?.id.split(":")[0] ??
+ b.toLowerCase();
+ const feedIdA = agencyIdA.split(":")[0];
+ const feedIdB = agencyIdB.split(":")[0];
+
// First, sort by favorite status
- const isFavA = isFavoriteAgency(a);
- const isFavB = isFavoriteAgency(b);
+ const isFavA = isFavoriteAgency(agencyIdA);
+ const isFavB = isFavoriteAgency(agencyIdB);
if (isFavA && !isFavB) return -1;
if (!isFavA && isFavB) return 1;
// Then by fixed order
- const indexA = orderedAgencies.indexOf(a.toLowerCase());
- const indexB = orderedAgencies.indexOf(b.toLowerCase());
+ const indexA = orderedAgencies.indexOf(feedIdA);
+ const indexB = orderedAgencies.indexOf(feedIdB);
if (indexA === -1 && indexB === -1) {
return a.localeCompare(b);
}
@@ -156,10 +170,15 @@ export default function RoutesPage() {
)}
{sortedAgencyEntries.map(([agency, agencyRoutes]) => {
- const isFav = isFavoriteAgency(agency);
+ // Use the agency's own gtfsId (feedId:agencyId) as the stable favourite key.
+ const agencyId =
+ agencyRoutes?.[0]?.agencyId ??
+ agencyRoutes?.[0]?.id.split(":")[0] ??
+ agency.toLowerCase();
+ const isFav = isFavoriteAgency(agencyId);
const isExpanded = searchQuery
? true
- : (expandedAgencies[agency] ?? false);
+ : (expandedAgencies[agency] ?? isFav);
return (
<div
@@ -190,7 +209,7 @@ export default function RoutesPage() {
</button>
<button
type="button"
- onClick={() => toggleFavoriteAgency(agency)}
+ onClick={() => toggleFavoriteAgency(agencyId)}
className={`rounded-full p-2 transition-colors ${
isFav
? "text-yellow-500"
diff --git a/src/frontend/app/routes/settings.tsx b/src/frontend/app/routes/settings.tsx
index 0497f34..a716030 100644
--- a/src/frontend/app/routes/settings.tsx
+++ b/src/frontend/app/routes/settings.tsx
@@ -2,6 +2,7 @@ import { Computer, Moon, Sun, Trash2 } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
+import { PushNotificationSettings } from "~/components/PushNotificationSettings";
import { usePageTitle } from "~/contexts/PageTitleContext";
import { useApp, type Theme } from "../AppContext";
import "../tailwind-full.css";
@@ -178,7 +179,7 @@ export default function Settings() {
className="block text-lg font-medium text-text mb-3"
>
{t("about.language", "Idioma")}
- </label>
+ </label>{" "}
<select
id="language"
className="
@@ -197,6 +198,11 @@ export default function Settings() {
</select>
</section>
+ {/* Push Notifications */}
+ <div className="mt-8 pt-8 border-t border-border">
+ <PushNotificationSettings />
+ </div>
+
{/* Privacy / Clear data */}
<section className="mt-8 pt-8 border-t border-border">
<h2 className="text-xl font-semibold mb-4 text-text">
diff --git a/src/frontend/app/utils/idb.ts b/src/frontend/app/utils/idb.ts
new file mode 100644
index 0000000..4d0aba7
--- /dev/null
+++ b/src/frontend/app/utils/idb.ts
@@ -0,0 +1,83 @@
+/**
+ * IndexedDB helpers for sharing data with the service worker.
+ *
+ * The service worker is a classic script and cannot import ES modules, so it
+ * contains its own equivalent implementation. Keep the schema (DB name, version,
+ * store names, and key shapes) in sync with the inline IDB code in pwa-worker.js.
+ *
+ * DB: "enmarcha-sw", version 1
+ * Store "favorites" — { key: string, ids: string[] }
+ * Store "alertState" — { alertId: string, silenced: boolean, lastVersion: number }
+ */
+
+const DB_NAME = "enmarcha-sw";
+const DB_VERSION = 1;
+
+function openDb(): Promise<IDBDatabase> {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
+
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains("favorites")) {
+ db.createObjectStore("favorites", { keyPath: "key" });
+ }
+ if (!db.objectStoreNames.contains("alertState")) {
+ db.createObjectStore("alertState", { keyPath: "alertId" });
+ }
+ };
+
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+/** Persist a favourites list to IndexedDB so the service worker can read it. */
+export async function writeFavorites(
+ key: string,
+ ids: string[]
+): Promise<void> {
+ const db = await openDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("favorites", "readwrite");
+ tx.objectStore("favorites").put({ key, ids });
+ tx.oncomplete = () => {
+ db.close();
+ resolve();
+ };
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+/** Read a favourites list from IndexedDB. */
+export async function readFavorites(key: string): Promise<string[]> {
+ const db = await openDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("favorites", "readonly");
+ const req = tx.objectStore("favorites").get(key);
+ req.onsuccess = () => {
+ db.close();
+ resolve(
+ (req.result as { key: string; ids: string[] } | undefined)?.ids ?? []
+ );
+ };
+ req.onerror = () => reject(req.error);
+ });
+}
+
+/** Persist per-alert notification state (silenced flag and last notified version). */
+export async function writeAlertState(
+ alertId: string,
+ state: { silenced: boolean; lastVersion: number }
+): Promise<void> {
+ const db = await openDb();
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction("alertState", "readwrite");
+ tx.objectStore("alertState").put({ alertId, ...state });
+ tx.oncomplete = () => {
+ db.close();
+ resolve();
+ };
+ tx.onerror = () => reject(tx.error);
+ });
+}
diff --git a/src/frontend/public/pwa-worker.js b/src/frontend/public/pwa-worker.js
index 9427bd3..09755d3 100644
--- a/src/frontend/public/pwa-worker.js
+++ b/src/frontend/public/pwa-worker.js
@@ -84,3 +84,250 @@ async function handleStaticRequest(request) {
return null;
}
}
+
+// ---------------------------------------------------------------------------
+// IndexedDB helpers (inline — classic SW scripts cannot use ES module imports)
+// Schema must match app/utils/idb.ts
+// ---------------------------------------------------------------------------
+
+const IDB_NAME = "enmarcha-sw";
+const IDB_VERSION = 1;
+
+function idbOpen() {
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(IDB_NAME, IDB_VERSION);
+ req.onupgradeneeded = () => {
+ const db = req.result;
+ if (!db.objectStoreNames.contains("favorites")) {
+ db.createObjectStore("favorites", { keyPath: "key" });
+ }
+ if (!db.objectStoreNames.contains("alertState")) {
+ db.createObjectStore("alertState", { keyPath: "alertId" });
+ }
+ };
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function idbGet(db, storeName, key) {
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, "readonly");
+ const req = tx.objectStore(storeName).get(key);
+ req.onsuccess = () => resolve(req.result);
+ req.onerror = () => reject(req.error);
+ });
+}
+
+function idbPut(db, storeName, value) {
+ return new Promise((resolve, reject) => {
+ const tx = db.transaction(storeName, "readwrite");
+ tx.objectStore(storeName).put(value);
+ tx.oncomplete = () => resolve();
+ tx.onerror = () => reject(tx.error);
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Push notification handler
+// ---------------------------------------------------------------------------
+
+self.addEventListener("push", (event) => {
+ event.waitUntil(handlePush(event));
+});
+
+async function handlePush(event) {
+ let payload;
+ try {
+ payload = event.data.json();
+ } catch {
+ return;
+ }
+
+ const {
+ alertId,
+ version,
+ header,
+ description,
+ selectors = [],
+ effect,
+ } = payload;
+
+ const db = await idbOpen();
+
+ // Check per-alert state — skip if already shown at this version or silenced
+ const alertState = await idbGet(db, "alertState", alertId);
+ if (alertState) {
+ if (alertState.silenced) {
+ db.close();
+ return;
+ }
+ if (alertState.lastVersion >= version) {
+ db.close();
+ return;
+ }
+ }
+
+ // Read favourites from IDB
+ const stopRec = await idbGet(db, "favorites", "favouriteStops");
+ const routeRec = await idbGet(db, "favorites", "favouriteRoutes");
+ const agencyRec = await idbGet(db, "favorites", "favouriteAgencies");
+ db.close();
+
+ const favStops = stopRec?.ids ?? [];
+ const favRoutes = routeRec?.ids ?? [];
+ const favAgencies = agencyRec?.ids ?? [];
+
+ const hasAnyFavourites =
+ favStops.length > 0 || favRoutes.length > 0 || favAgencies.length > 0;
+
+ // If user has favourites, only show if a selector matches; otherwise show all (fail-open)
+ if (hasAnyFavourites) {
+ const matches = selectors.some((raw) => {
+ const hashIdx = raw.indexOf("#");
+ if (hashIdx === -1) return false;
+ const type = raw.slice(0, hashIdx);
+ const id = raw.slice(hashIdx + 1);
+ if (type === "stop") return favStops.includes(id);
+ if (type === "route") return favRoutes.includes(id);
+ if (type === "agency") return favAgencies.includes(id);
+ return false;
+ });
+ if (!matches) return;
+ }
+
+ // Determine notification title and body (prefer user's browser language, fallback to "es")
+ const lang = (self.navigator?.language ?? "es").slice(0, 2);
+ const title =
+ header[lang] ??
+ header["es"] ??
+ Object.values(header)[0] ??
+ "Alerta de servicio";
+ const body =
+ description[lang] ??
+ description["es"] ??
+ Object.values(description)[0] ??
+ "";
+
+ // Map effect to an emoji hint for better at-a-glance reading
+ const iconHint =
+ {
+ NoService: "🚫",
+ ReducedService: "⚠️",
+ SignificantDelays: "🕐",
+ Detour: "↩️",
+ AdditionalService: "➕",
+ StopMoved: "📍",
+ }[effect] ?? "ℹ️";
+
+ // Save the new version so we don't re-show the same notification
+ const db2 = await idbOpen();
+ await idbPut(db2, "alertState", {
+ alertId,
+ silenced: false,
+ lastVersion: version,
+ });
+ db2.close();
+
+ // Build a deep-link from the first selector
+ let firstLink = "/";
+ if (selectors.length > 0) {
+ const first = selectors[0];
+ const hashIdx = first.indexOf("#");
+ if (hashIdx !== -1) {
+ const type = first.slice(0, hashIdx);
+ const id = first.slice(hashIdx + 1);
+ if (type === "stop") firstLink = `/stops/${encodeURIComponent(id)}`;
+ else if (type === "route")
+ firstLink = `/routes/${encodeURIComponent(id)}`;
+ }
+ }
+
+ await self.registration.showNotification(`${iconHint} ${title}`, {
+ body,
+ icon: "/icon-192.png",
+ badge: "/icon-monochrome-256.png",
+ tag: alertId,
+ data: { alertId, version, link: firstLink },
+ actions: [
+ { action: "open", title: "Ver detalles" },
+ { action: "silence", title: "No mostrar más" },
+ ],
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Notification click handler
+// ---------------------------------------------------------------------------
+
+self.addEventListener("notificationclick", (event) => {
+ event.notification.close();
+
+ if (event.action === "silence") {
+ event.waitUntil(
+ (async () => {
+ const { alertId, version } = event.notification.data ?? {};
+ if (!alertId) return;
+ const db = await idbOpen();
+ await idbPut(db, "alertState", {
+ alertId,
+ silenced: true,
+ lastVersion: version ?? 0,
+ });
+ db.close();
+ })()
+ );
+ return;
+ }
+
+ // Default / "open" action — focus or open the app at the alert's deep link
+ const link = event.notification.data?.link ?? "/";
+ event.waitUntil(
+ self.clients
+ .matchAll({ type: "window", includeUncontrolled: true })
+ .then((clients) => {
+ for (const client of clients) {
+ if (client.url.includes(self.location.origin) && "focus" in client) {
+ client.navigate(link);
+ return client.focus();
+ }
+ }
+ return self.clients.openWindow(link);
+ })
+ );
+});
+
+// ---------------------------------------------------------------------------
+// Re-subscribe handler (fires when the push subscription is invalidated)
+// ---------------------------------------------------------------------------
+
+self.addEventListener("pushsubscriptionchange", (event) => {
+ event.waitUntil(
+ (async () => {
+ const newSubscription =
+ event.newSubscription ??
+ (await self.registration.pushManager.subscribe(
+ event.oldSubscription
+ ? {
+ userVisibleOnly: true,
+ applicationServerKey:
+ event.oldSubscription.options.applicationServerKey,
+ }
+ : { userVisibleOnly: true }
+ ));
+
+ if (!newSubscription) return;
+
+ const { endpoint, keys } = newSubscription.toJSON();
+ await fetch("/api/push/subscribe", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ endpoint,
+ p256Dh: keys?.p256dh,
+ auth: keys?.auth,
+ }),
+ });
+ })()
+ );
+});