From 1b4f4a674ac533c0b51260ba35ab91dd2cf9486d Mon Sep 17 00:00:00 2001 From: Ariel Costas Guerrero Date: Thu, 2 Apr 2026 12:38:10 +0200 Subject: Basic push notification system for service alerts Co-authored-by: Copilot --- .../Configuration/AppConfiguration.cs | 20 + .../Controllers/AlertsController.cs | 40 ++ .../Controllers/Backoffice/AlertsController.cs | 21 +- src/Enmarcha.Backend/Controllers/PushController.cs | 50 +++ src/Enmarcha.Backend/Data/AppDbContext.cs | 7 + ...20260401135403_AddPushNotifications.Designer.cs | 440 +++++++++++++++++++++ .../20260401135403_AddPushNotifications.cs | 74 ++++ .../Data/Migrations/AppDbContextModelSnapshot.cs | 48 +++ src/Enmarcha.Backend/Data/Models/AlertSelector.cs | 3 +- .../Data/Models/PushSubscription.cs | 20 + src/Enmarcha.Backend/Data/Models/ServiceAlert.cs | 9 + src/Enmarcha.Backend/Enmarcha.Backend.csproj | 1 + src/Enmarcha.Backend/Program.cs | 3 + .../AlertPhaseNotificationHostedService.cs | 77 ++++ .../Services/BackofficeSelectorService.cs | 15 +- src/Enmarcha.Backend/Services/OtpService.cs | 1 + .../Services/PushNotificationService.cs | 134 +++++++ src/Enmarcha.Backend/Types/Transit/RouteDtos.cs | 2 + .../ViewModels/AlertFormViewModel.cs | 2 +- src/Enmarcha.Backend/Views/Alerts/Edit.cshtml | 4 +- src/Enmarcha.Backend/Views/Alerts/Index.cshtml | 14 + src/Enmarcha.Backend/appsettings.json | 9 +- .../Queries/RoutesListContent.cs | 2 + src/frontend/app/api/schema.ts | 1 + .../app/components/PushNotificationSettings.tsx | 198 ++++++++++ src/frontend/app/components/ServiceAlerts.tsx | 115 +++++- src/frontend/app/data/StopDataProvider.ts | 8 + src/frontend/app/hooks/useFavorites.ts | 6 + src/frontend/app/routes/favourites.tsx | 5 +- src/frontend/app/routes/routes.tsx | 35 +- src/frontend/app/routes/settings.tsx | 8 +- src/frontend/app/utils/idb.ts | 83 ++++ src/frontend/public/pwa-worker.js | 247 ++++++++++++ 33 files changed, 1671 insertions(+), 31 deletions(-) create mode 100644 src/Enmarcha.Backend/Controllers/AlertsController.cs create mode 100644 src/Enmarcha.Backend/Controllers/PushController.cs create mode 100644 src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.Designer.cs create mode 100644 src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs create mode 100644 src/Enmarcha.Backend/Data/Models/PushSubscription.cs create mode 100644 src/Enmarcha.Backend/Services/AlertPhaseNotificationHostedService.cs create mode 100644 src/Enmarcha.Backend/Services/PushNotificationService.cs create mode 100644 src/frontend/app/components/PushNotificationSettings.tsx create mode 100644 src/frontend/app/utils/idb.ts (limited to 'src') 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 +{ + /// + /// VAPID subject — typically "mailto:admin@yourdomain.com" or a URL. + /// + public required string Subject { get; set; } + + /// + /// Base64url-encoded VAPID public key. Safe to expose to browsers. + /// + public required string PublicKey { get; set; } + + /// + /// 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. + /// + 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 +{ + /// + /// Returns all service alerts that are currently published and not yet hidden. + /// Includes PreNotice, Active, and Finished phases. + /// + [HttpGet] + public async Task 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)a.Header, + description = (Dictionary)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 logger +) : Controller { [HttpGet("")] public async Task 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 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 config) : ControllerBase +{ + /// Returns the VAPID public key for the browser to use when subscribing. + [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 }); + } + + /// Registers a new push subscription. + [HttpPost("subscribe")] + public async Task 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(); + } + + /// Removes a push subscription. + [HttpDelete("unsubscribe")] + public async Task 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 } public DbSet ServiceAlerts { get; set; } + public DbSet PushSubscriptions { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -72,5 +73,11 @@ public class AppDbContext : IdentityDbContext v => JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null) ?? new List(), JsonComparer>()); }); + + builder.Entity(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 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("auth_key"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property("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("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ActiveNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("active_notified_at"); + + b.Property("Cause") + .HasColumnType("integer") + .HasColumnName("cause"); + + b.Property("Description") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("description"); + + b.Property("Effect") + .HasColumnType("integer") + .HasColumnName("effect"); + + b.Property("EventEndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("event_end_date"); + + b.Property("EventStartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("event_start_date"); + + b.Property("Header") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("header"); + + b.Property("HiddenDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("hidden_date"); + + b.Property("InfoUrls") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("info_urls"); + + b.Property("InsertedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("inserted_date"); + + b.Property("PreNoticeNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("pre_notice_notified_at"); + + b.Property("PublishDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("publish_date"); + + b.Property("Selectors") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("selectors"); + + b.Property("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("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrencyStamp"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claimType"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claimValue"); + + b.Property("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("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("accessFailedCount"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrencyStamp"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("emailConfirmed"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockoutEnabled"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockoutEnd"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalizedEmail"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalizedUserName"); + + b.Property("PasswordHash") + .HasColumnType("text") + .HasColumnName("passwordHash"); + + b.Property("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phoneNumber"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phoneNumberConfirmed"); + + b.Property("SecurityStamp") + .HasColumnType("text") + .HasColumnName("securityStamp"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("twoFactorEnabled"); + + b.Property("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", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text") + .HasColumnName("claimType"); + + b.Property("ClaimValue") + .HasColumnType("text") + .HasColumnName("claimValue"); + + b.Property("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", b => + { + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("loginProvider"); + + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("providerKey"); + + b.Property("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("providerDisplayName"); + + b.Property("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", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("userId"); + + b.Property("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", b => + { + b.Property("UserId") + .HasColumnType("text") + .HasColumnName("userId"); + + b.Property("LoginProvider") + .HasColumnType("text") + .HasColumnName("loginProvider"); + + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("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", 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", 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", 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", 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", 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 +{ + /// + public partial class AddPushNotifications : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "active_notified_at", + table: "service_alerts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "pre_notice_notified_at", + table: "service_alerts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "version", + table: "service_alerts", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "push_subscriptions", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + endpoint = table.Column(type: "text", nullable: false), + p256dh_key = table.Column(type: "text", nullable: false), + auth_key = table.Column(type: "text", nullable: false), + created_at = table.Column(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); + } + + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AuthKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("auth_key"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("Endpoint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property("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("Id") .HasColumnType("text") .HasColumnName("id"); + b.Property("ActiveNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("active_notified_at"); + b.Property("Cause") .HasColumnType("integer") .HasColumnName("cause"); @@ -68,6 +108,10 @@ namespace Data.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("inserted_date"); + b.Property("PreNoticeNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("pre_notice_notified_at"); + b.Property("PublishDate") .HasColumnType("timestamp with time zone") .HasColumnName("publish_date"); @@ -77,6 +121,10 @@ namespace Data.Migrations .HasColumnType("jsonb") .HasColumnName("selectors"); + b.Property("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}" }; + /// Full GTFS agency id in the form feedId:agencyId (e.g. vitrasa:1). + 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; } + + /// Push endpoint URL provided by the browser's push service. + public string Endpoint { get; set; } = string.Empty; + + /// P-256 DH public key for payload encryption (base64url). + [Column("p256dh_key")] public string P256DhKey { get; set; } = string.Empty; + + /// Auth secret for payload encryption (base64url). + [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; } + /// Incremented each time a push notification is sent for this alert. + public int Version { get; set; } = 1; + + /// Set when a push notification was sent for the PreNotice phase. + [Column("pre_notice_notified_at")] public DateTime? PreNoticeNotifiedAt { get; set; } + + /// Set when a push notification was sent for the Active phase. + [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 @@ + 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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); 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; + +/// +/// 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. +/// +public class AlertPhaseNotificationHostedService( + IServiceScopeFactory scopeFactory, + ILogger 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(); + var pushService = scope.ServiceProvider.GetRequiredService(); + + 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 Agencies, List 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); +/// Full GTFS agency id in the form feedId:agencyId. +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 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 options, + ILogger logger +) : IPushNotificationService +{ + private readonly WebPushClient? _client = BuildClient(options.Value.Vapid); + + private static WebPushClient? BuildClient(VapidConfiguration? vapid) + { + if (vapid is null) return null; + var client = new WebPushClient(); + client.SetVapidDetails(vapid.Subject, vapid.PublicKey, vapid.PrivateKey); + return client; + } + + public async Task SubscribeAsync(string endpoint, string p256dh, string auth) + { + var existing = await db.PushSubscriptions + .FirstOrDefaultAsync(s => s.Endpoint == endpoint); + + if (existing is null) + { + db.PushSubscriptions.Add(new Data.Models.PushSubscription + { + Id = Guid.NewGuid(), + Endpoint = endpoint, + P256DhKey = p256dh, + AuthKey = auth, + CreatedAt = DateTime.UtcNow, + }); + } + else + { + // Refresh keys in case they changed (e.g. after re-subscription) + existing.P256DhKey = p256dh; + existing.AuthKey = auth; + } + + await db.SaveChangesAsync(); + } + + public async Task UnsubscribeAsync(string endpoint) + { + var subscription = await db.PushSubscriptions + .FirstOrDefaultAsync(s => s.Endpoint == endpoint); + + if (subscription is not null) + { + db.PushSubscriptions.Remove(subscription); + await db.SaveChangesAsync(); + } + } + + public async Task SendAlertAsync(ServiceAlert alert) + { + if (_client is null) + { + logger.LogWarning("VAPID not configured — skipping push notification for alert {AlertId}", alert.Id); + return; + } + + var now = DateTime.UtcNow; + var phase = alert.GetPhase(now); + + alert.Version++; + + if (phase == AlertPhase.PreNotice) + alert.PreNoticeNotifiedAt = now; + else if (phase == AlertPhase.Active) + alert.ActiveNotifiedAt = now; + + var payload = JsonSerializer.Serialize(new + { + alertId = alert.Id, + version = alert.Version, + phase = phase.ToString(), + cause = alert.Cause.ToString(), + effect = alert.Effect.ToString(), + header = (Dictionary)alert.Header, + description = (Dictionary)alert.Description, + selectors = alert.Selectors.Select(s => s.Raw).ToList(), + eventStart = alert.EventStartDate, + eventEnd = alert.EventEndDate, + }); + + var subscriptions = await db.PushSubscriptions.ToListAsync(); + var expired = new List(); + + foreach (var sub in subscriptions) + { + try + { + var pushSub = new WebPush.PushSubscription(sub.Endpoint, sub.P256DhKey, sub.AuthKey); + await _client.SendNotificationAsync(pushSub, payload); + } + catch (WebPushException ex) when ( + ex.StatusCode is System.Net.HttpStatusCode.Gone or System.Net.HttpStatusCode.NotFound) + { + // Subscription expired or was revoked — remove it + expired.Add(sub); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to deliver push notification to endpoint {Endpoint}", sub.Endpoint[..Math.Min(40, sub.Endpoint.Length)]); + } + } + + if (expired.Count > 0) + { + db.PushSubscriptions.RemoveRange(expired); + logger.LogInformation("Removed {Count} expired push subscription(s)", expired.Count); + } + + await db.SaveChangesAsync(); + } +} 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 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 @@ +
+
@Html.AntiForgeryToken() @if (!isCreate) @@ -407,7 +409,7 @@ } for (const a of agencies) el.appendChild(makeTransitItem(a.selector, () => `${escHtml(a.name)}` + - `${escHtml(a.feedId)}` + `${escHtml(a.agencyGtfsId)}` )); } 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) +{ + +} +

Alertas de servicio @@ -66,6 +74,12 @@ else title="Editar"> + + @Html.AntiForgeryToken() + + 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 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("loading"); + const [working, setWorking] = useState(false); + + useEffect(() => { + checkStatus().then(setStatus); + }, []); + + async function checkStatus(): Promise { + 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 ( +
+

+ {t("settings.push_title", "Notificaciones")} +

+

+ {t( + "settings.push_description", + "Recibe notificaciones cuando haya alertas de servicio relevantes para tus paradas, líneas o operadores favoritos." + )} +

+ + {status === "loading" && ( +
+ + {t("common.loading", "Cargando...")} +
+ )} + + {status === "unsupported" && ( +

+ {t( + "settings.push_unsupported", + "Tu navegador no soporta notificaciones push. Prueba con Chrome, Edge o Firefox." + )} +

+ )} + + {status === "denied" && ( +

+ {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." + )} +

+ )} + + {(status === "subscribed" || status === "unsubscribed") && ( + + )} +
+ ); +} 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; + description: Record; + 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 = { + 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 = ({ selectorFilter }) => { + const { t, i18n } = useTranslation(); + const lang = i18n.language.slice(0, 2); + + const { data: alerts, isLoading } = useQuery({ + 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 (

{t("stoplist.service_alerts")}

-
); }; 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 (