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 +- 22 files changed, 983 insertions(+), 11 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 (limited to 'src/Enmarcha.Backend') 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) +{ + +} +