diff options
Diffstat (limited to 'src/Enmarcha.Backend')
22 files changed, 983 insertions, 11 deletions
diff --git a/src/Enmarcha.Backend/Configuration/AppConfiguration.cs b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs index 8c6e411..ca2425b 100644 --- a/src/Enmarcha.Backend/Configuration/AppConfiguration.cs +++ b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs @@ -7,6 +7,7 @@ public class AppConfiguration public string NominatimBaseUrl { get; set; } = "https://nominatim.openstreetmap.org"; public string[] OtpFeeds { get; set; } = []; public OpenTelemetryConfiguration? OpenTelemetry { get; set; } + public VapidConfiguration? Vapid { get; set; } } public class OpenTelemetryConfiguration @@ -14,3 +15,22 @@ public class OpenTelemetryConfiguration public string? Endpoint { get; set; } public string? Headers { get; set; } } + +public class VapidConfiguration +{ + /// <summary> + /// VAPID subject — typically "mailto:admin@yourdomain.com" or a URL. + /// </summary> + public required string Subject { get; set; } + + /// <summary> + /// Base64url-encoded VAPID public key. Safe to expose to browsers. + /// </summary> + public required string PublicKey { get; set; } + + /// <summary> + /// Base64url-encoded VAPID private key. Store in user secrets or environment variables only. + /// Generate a key pair with: VapidHelper.GenerateVapidKeys() from the WebPush NuGet package. + /// </summary> + public required string PrivateKey { get; set; } +} diff --git a/src/Enmarcha.Backend/Controllers/AlertsController.cs b/src/Enmarcha.Backend/Controllers/AlertsController.cs new file mode 100644 index 0000000..4860399 --- /dev/null +++ b/src/Enmarcha.Backend/Controllers/AlertsController.cs @@ -0,0 +1,40 @@ +using Enmarcha.Backend.Data; +using Enmarcha.Backend.Data.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Enmarcha.Backend.Controllers; + +[Route("api/alerts")] +[ApiController] +public class AlertsController(AppDbContext db) : ControllerBase +{ + /// <summary> + /// Returns all service alerts that are currently published and not yet hidden. + /// Includes PreNotice, Active, and Finished phases. + /// </summary> + [HttpGet] + public async Task<IActionResult> GetAlerts() + { + var now = DateTime.UtcNow; + var alerts = await db.ServiceAlerts + .Where(a => a.PublishDate <= now && a.HiddenDate > now) + .OrderByDescending(a => a.EventStartDate) + .ToListAsync(); + + return Ok(alerts.Select(a => new + { + id = a.Id, + version = a.Version, + phase = a.GetPhase(now).ToString(), + cause = a.Cause.ToString(), + effect = a.Effect.ToString(), + header = (Dictionary<string, string>)a.Header, + description = (Dictionary<string, string>)a.Description, + selectors = a.Selectors.Select(s => s.Raw).ToList(), + infoUrls = a.InfoUrls, + eventStartDate = a.EventStartDate, + eventEndDate = a.EventEndDate, + })); + } +} diff --git a/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs b/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs index 4e83abc..3fa499e 100644 --- a/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs +++ b/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs @@ -1,4 +1,5 @@ using Enmarcha.Backend.Data; +using Enmarcha.Backend.Services; using Enmarcha.Backend.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -8,7 +9,11 @@ namespace Enmarcha.Backend.Controllers.Backoffice; [Route("backoffice/alerts")] [Authorize(AuthenticationSchemes = "Backoffice")] -public class AlertsController(AppDbContext db) : Controller +public class AlertsController( + AppDbContext db, + IPushNotificationService pushService, + ILogger<AlertsController> logger +) : Controller { [HttpGet("")] public async Task<IActionResult> Index() @@ -28,6 +33,7 @@ public class AlertsController(AppDbContext db) : Controller { if (!ModelState.IsValid) { + logger.LogWarning("Invalid model state when creating alert: {Errors}", ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)); return View("Edit", model); } @@ -50,6 +56,7 @@ public class AlertsController(AppDbContext db) : Controller { if (!ModelState.IsValid) { + logger.LogWarning("Invalid model state when editing alert {Id}: {Errors}", id, ModelState.Values.SelectMany(v => v.Errors).Select(e => e.ErrorMessage)); return View("Edit", model); } @@ -80,4 +87,16 @@ public class AlertsController(AppDbContext db) : Controller await db.SaveChangesAsync(); return RedirectToAction(nameof(Index)); } + + [HttpPost("{id}/push")] + [ValidateAntiForgeryToken] + public async Task<IActionResult> SendPush(string id) + { + var alert = await db.ServiceAlerts.FindAsync(id); + if (alert is null) return NotFound(); + + await pushService.SendAlertAsync(alert); + TempData["SuccessMessage"] = $"Notificación enviada (v{alert.Version})"; + return RedirectToAction(nameof(Index)); + } } diff --git a/src/Enmarcha.Backend/Controllers/PushController.cs b/src/Enmarcha.Backend/Controllers/PushController.cs new file mode 100644 index 0000000..9df6b48 --- /dev/null +++ b/src/Enmarcha.Backend/Controllers/PushController.cs @@ -0,0 +1,50 @@ +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Enmarcha.Backend.Controllers; + +[Route("api/push")] +[ApiController] +public class PushController(IPushNotificationService pushService, IOptions<AppConfiguration> config) : ControllerBase +{ + /// <summary>Returns the VAPID public key for the browser to use when subscribing.</summary> + [HttpGet("vapid-public-key")] + public IActionResult GetVapidPublicKey() + { + var vapid = config.Value.Vapid; + if (vapid is null) + return StatusCode(StatusCodes.Status503ServiceUnavailable, "Push notifications are not configured on this server."); + + return Ok(new { publicKey = vapid.PublicKey }); + } + + /// <summary>Registers a new push subscription.</summary> + [HttpPost("subscribe")] + public async Task<IActionResult> Subscribe([FromBody] SubscribeRequest request) + { + if (!Uri.TryCreate(request.Endpoint, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps) + return BadRequest("Invalid push endpoint: must be an absolute HTTPS URL."); + + if (string.IsNullOrWhiteSpace(request.P256Dh) || string.IsNullOrWhiteSpace(request.Auth)) + return BadRequest("Missing encryption keys."); + + await pushService.SubscribeAsync(request.Endpoint, request.P256Dh, request.Auth); + return NoContent(); + } + + /// <summary>Removes a push subscription.</summary> + [HttpDelete("unsubscribe")] + public async Task<IActionResult> Unsubscribe([FromBody] UnsubscribeRequest request) + { + if (string.IsNullOrWhiteSpace(request.Endpoint)) + return BadRequest("Endpoint is required."); + + await pushService.UnsubscribeAsync(request.Endpoint); + return NoContent(); + } +} + +public record SubscribeRequest(string Endpoint, string P256Dh, string Auth); +public record UnsubscribeRequest(string Endpoint); diff --git a/src/Enmarcha.Backend/Data/AppDbContext.cs b/src/Enmarcha.Backend/Data/AppDbContext.cs index d5a29ee..e191b26 100644 --- a/src/Enmarcha.Backend/Data/AppDbContext.cs +++ b/src/Enmarcha.Backend/Data/AppDbContext.cs @@ -14,6 +14,7 @@ public class AppDbContext : IdentityDbContext<IdentityUser> } public DbSet<ServiceAlert> ServiceAlerts { get; set; } + public DbSet<PushSubscription> PushSubscriptions { get; set; } protected override void OnModelCreating(ModelBuilder builder) { @@ -72,5 +73,11 @@ public class AppDbContext : IdentityDbContext<IdentityUser> v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>(), JsonComparer<List<string>>()); }); + + builder.Entity<PushSubscription>(b => + { + b.HasKey(x => x.Id); + b.HasIndex(x => x.Endpoint).IsUnique(); + }); } } diff --git a/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.Designer.cs b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.Designer.cs new file mode 100644 index 0000000..5bedd08 --- /dev/null +++ b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.Designer.cs @@ -0,0 +1,440 @@ +// <auto-generated /> +using System; +using Enmarcha.Backend.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260401135403_AddPushNotifications")] + partial class AddPushNotifications + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.3") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Enmarcha.Backend.Data.Models.PushSubscription", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("AuthKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("auth_key"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Endpoint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property<string>("P256DhKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("p256dh_key"); + + b.HasKey("Id") + .HasName("pK_push_subscriptions"); + + b.HasIndex("Endpoint") + .IsUnique() + .HasDatabaseName("iX_push_subscriptions_endpoint"); + + b.ToTable("push_subscriptions", (string)null); + }); + + modelBuilder.Entity("Enmarcha.Backend.Data.Models.ServiceAlert", b => + { + b.Property<string>("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property<DateTime?>("ActiveNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("active_notified_at"); + + b.Property<int>("Cause") + .HasColumnType("integer") + .HasColumnName("cause"); + + b.Property<string>("Description") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("description"); + + b.Property<int>("Effect") + .HasColumnType("integer") + .HasColumnName("effect"); + + b.Property<DateTime>("EventEndDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("event_end_date"); + + b.Property<DateTime>("EventStartDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("event_start_date"); + + b.Property<string>("Header") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("header"); + + b.Property<DateTime>("HiddenDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("hidden_date"); + + b.Property<string>("InfoUrls") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("info_urls"); + + b.Property<DateTime>("InsertedDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("inserted_date"); + + b.Property<DateTime?>("PreNoticeNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("pre_notice_notified_at"); + + b.Property<DateTime>("PublishDate") + .HasColumnType("timestamp with time zone") + .HasColumnName("publish_date"); + + b.Property<string>("Selectors") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("selectors"); + + b.Property<int>("Version") + .HasColumnType("integer") + .HasColumnName("version"); + + b.HasKey("Id") + .HasName("pK_service_alerts"); + + b.ToTable("service_alerts", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property<string>("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrencyStamp"); + + b.Property<string>("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("name"); + + b.Property<string>("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalizedName"); + + b.HasKey("Id") + .HasName("pK_roles"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text") + .HasColumnName("claimType"); + + b.Property<string>("ClaimValue") + .HasColumnType("text") + .HasColumnName("claimValue"); + + b.Property<string>("RoleId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("roleId"); + + b.HasKey("Id") + .HasName("pK_role_claims"); + + b.HasIndex("RoleId") + .HasDatabaseName("iX_role_claims_roleId"); + + b.ToTable("role_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUser", b => + { + b.Property<string>("Id") + .HasColumnType("text") + .HasColumnName("id"); + + b.Property<int>("AccessFailedCount") + .HasColumnType("integer") + .HasColumnName("accessFailedCount"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text") + .HasColumnName("concurrencyStamp"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("email"); + + b.Property<bool>("EmailConfirmed") + .HasColumnType("boolean") + .HasColumnName("emailConfirmed"); + + b.Property<bool>("LockoutEnabled") + .HasColumnType("boolean") + .HasColumnName("lockoutEnabled"); + + b.Property<DateTimeOffset?>("LockoutEnd") + .HasColumnType("timestamp with time zone") + .HasColumnName("lockoutEnd"); + + b.Property<string>("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalizedEmail"); + + b.Property<string>("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("normalizedUserName"); + + b.Property<string>("PasswordHash") + .HasColumnType("text") + .HasColumnName("passwordHash"); + + b.Property<string>("PhoneNumber") + .HasColumnType("text") + .HasColumnName("phoneNumber"); + + b.Property<bool>("PhoneNumberConfirmed") + .HasColumnType("boolean") + .HasColumnName("phoneNumberConfirmed"); + + b.Property<string>("SecurityStamp") + .HasColumnType("text") + .HasColumnName("securityStamp"); + + b.Property<bool>("TwoFactorEnabled") + .HasColumnType("boolean") + .HasColumnName("twoFactorEnabled"); + + b.Property<string>("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("userName"); + + b.HasKey("Id") + .HasName("pK_users"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text") + .HasColumnName("claimType"); + + b.Property<string>("ClaimValue") + .HasColumnType("text") + .HasColumnName("claimValue"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("Id") + .HasName("pK_user_claims"); + + b.HasIndex("UserId") + .HasDatabaseName("iX_user_claims_userId"); + + b.ToTable("user_claims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.Property<string>("LoginProvider") + .HasColumnType("text") + .HasColumnName("loginProvider"); + + b.Property<string>("ProviderKey") + .HasColumnType("text") + .HasColumnName("providerKey"); + + b.Property<string>("ProviderDisplayName") + .HasColumnType("text") + .HasColumnName("providerDisplayName"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("userId"); + + b.HasKey("LoginProvider", "ProviderKey") + .HasName("pK_user_logins"); + + b.HasIndex("UserId") + .HasDatabaseName("iX_user_logins_userId"); + + b.ToTable("user_logins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("text") + .HasColumnName("userId"); + + b.Property<string>("RoleId") + .HasColumnType("text") + .HasColumnName("roleId"); + + b.HasKey("UserId", "RoleId") + .HasName("pK_user_roles"); + + b.HasIndex("RoleId") + .HasDatabaseName("iX_user_roles_roleId"); + + b.ToTable("user_roles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("text") + .HasColumnName("userId"); + + b.Property<string>("LoginProvider") + .HasColumnType("text") + .HasColumnName("loginProvider"); + + b.Property<string>("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property<string>("Value") + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("UserId", "LoginProvider", "Name") + .HasName("pK_user_tokens"); + + b.ToTable("user_tokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fK_role_claims_roles_roleId"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fK_user_claims_users_userId"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fK_user_logins_users_userId"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fK_user_roles_roles_roleId"); + + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fK_user_roles_users_userId"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fK_user_tokens_users_userId"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs new file mode 100644 index 0000000..964a86f --- /dev/null +++ b/src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Data.Migrations +{ + /// <inheritdoc /> + public partial class AddPushNotifications : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<DateTime>( + name: "active_notified_at", + table: "service_alerts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn<DateTime>( + name: "pre_notice_notified_at", + table: "service_alerts", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn<int>( + name: "version", + table: "service_alerts", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "push_subscriptions", + columns: table => new + { + id = table.Column<Guid>(type: "uuid", nullable: false), + endpoint = table.Column<string>(type: "text", nullable: false), + p256dh_key = table.Column<string>(type: "text", nullable: false), + auth_key = table.Column<string>(type: "text", nullable: false), + created_at = table.Column<DateTime>(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pK_push_subscriptions", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "iX_push_subscriptions_endpoint", + table: "push_subscriptions", + column: "endpoint", + unique: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "push_subscriptions"); + + migrationBuilder.DropColumn( + name: "active_notified_at", + table: "service_alerts"); + + migrationBuilder.DropColumn( + name: "pre_notice_notified_at", + table: "service_alerts"); + + migrationBuilder.DropColumn( + name: "version", + table: "service_alerts"); + } + } +} diff --git a/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs index 88488bf..f02eb20 100644 --- a/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs @@ -23,12 +23,52 @@ namespace Data.Migrations NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Enmarcha.Backend.Data.Models.PushSubscription", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property<string>("AuthKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("auth_key"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property<string>("Endpoint") + .IsRequired() + .HasColumnType("text") + .HasColumnName("endpoint"); + + b.Property<string>("P256DhKey") + .IsRequired() + .HasColumnType("text") + .HasColumnName("p256dh_key"); + + b.HasKey("Id") + .HasName("pK_push_subscriptions"); + + b.HasIndex("Endpoint") + .IsUnique() + .HasDatabaseName("iX_push_subscriptions_endpoint"); + + b.ToTable("push_subscriptions", (string)null); + }); + modelBuilder.Entity("Enmarcha.Backend.Data.Models.ServiceAlert", b => { b.Property<string>("Id") .HasColumnType("text") .HasColumnName("id"); + b.Property<DateTime?>("ActiveNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("active_notified_at"); + b.Property<int>("Cause") .HasColumnType("integer") .HasColumnName("cause"); @@ -68,6 +108,10 @@ namespace Data.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("inserted_date"); + b.Property<DateTime?>("PreNoticeNotifiedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("pre_notice_notified_at"); + b.Property<DateTime>("PublishDate") .HasColumnType("timestamp with time zone") .HasColumnName("publish_date"); @@ -77,6 +121,10 @@ namespace Data.Migrations .HasColumnType("jsonb") .HasColumnName("selectors"); + b.Property<int>("Version") + .HasColumnType("integer") + .HasColumnName("version"); + b.HasKey("Id") .HasName("pK_service_alerts"); diff --git a/src/Enmarcha.Backend/Data/Models/AlertSelector.cs b/src/Enmarcha.Backend/Data/Models/AlertSelector.cs index 34b2de3..e2b01f1 100644 --- a/src/Enmarcha.Backend/Data/Models/AlertSelector.cs +++ b/src/Enmarcha.Backend/Data/Models/AlertSelector.cs @@ -13,7 +13,8 @@ public class AlertSelector public static AlertSelector FromStop(string feedId, string stopId) => new() { Raw = $"stop#{feedId}:{stopId}" }; public static AlertSelector FromRoute(string feedId, string routeId) => new() { Raw = $"route#{feedId}:{routeId}" }; - public static AlertSelector FromAgency(string feedId) => new() { Raw = $"agency#{feedId}" }; + /// <param name="agencyGtfsId">Full GTFS agency id in the form <c>feedId:agencyId</c> (e.g. <c>vitrasa:1</c>).</param> + public static AlertSelector FromAgency(string agencyGtfsId) => new() { Raw = $"agency#{agencyGtfsId}" }; public override string ToString() => Raw; } diff --git a/src/Enmarcha.Backend/Data/Models/PushSubscription.cs b/src/Enmarcha.Backend/Data/Models/PushSubscription.cs new file mode 100644 index 0000000..c72c40f --- /dev/null +++ b/src/Enmarcha.Backend/Data/Models/PushSubscription.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Enmarcha.Backend.Data.Models; + +[Table("push_subscriptions")] +public class PushSubscription +{ + public Guid Id { get; set; } + + /// <summary>Push endpoint URL provided by the browser's push service.</summary> + public string Endpoint { get; set; } = string.Empty; + + /// <summary>P-256 DH public key for payload encryption (base64url).</summary> + [Column("p256dh_key")] public string P256DhKey { get; set; } = string.Empty; + + /// <summary>Auth secret for payload encryption (base64url).</summary> + [Column("auth_key")] public string AuthKey { get; set; } = string.Empty; + + [Column("created_at")] public DateTime CreatedAt { get; set; } +} diff --git a/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs b/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs index 5f80e3c..39cf3fa 100644 --- a/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs +++ b/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs @@ -24,6 +24,15 @@ public class ServiceAlert [Column("event_end_date")] public DateTime EventEndDate { get; set; } [Column("hidden_date")] public DateTime HiddenDate { get; set; } + /// <summary>Incremented each time a push notification is sent for this alert.</summary> + public int Version { get; set; } = 1; + + /// <summary>Set when a push notification was sent for the PreNotice phase.</summary> + [Column("pre_notice_notified_at")] public DateTime? PreNoticeNotifiedAt { get; set; } + + /// <summary>Set when a push notification was sent for the Active phase.</summary> + [Column("active_notified_at")] public DateTime? ActiveNotifiedAt { get; set; } + public AlertPhase GetPhase(DateTime? now = null) { now ??= DateTime.UtcNow; diff --git a/src/Enmarcha.Backend/Enmarcha.Backend.csproj b/src/Enmarcha.Backend/Enmarcha.Backend.csproj index c70624c..e1b4074 100644 --- a/src/Enmarcha.Backend/Enmarcha.Backend.csproj +++ b/src/Enmarcha.Backend/Enmarcha.Backend.csproj @@ -30,6 +30,7 @@ <PackageReference Include="CsvHelper" /> <PackageReference Include="FuzzySharp" /> + <PackageReference Include="WebPush" /> <PackageReference Include="OpenTelemetry.Extensions.Hosting" /> <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" /> diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs index 7ca0b34..8f21eed 100644 --- a/src/Enmarcha.Backend/Program.cs +++ b/src/Enmarcha.Backend/Program.cs @@ -202,6 +202,9 @@ builder.Services.AddSingleton<ShapeTraversalService>(); builder.Services.AddSingleton<FeedService>(); builder.Services.AddSingleton<FareService>(); +builder.Services.AddScoped<IPushNotificationService, PushNotificationService>(); +builder.Services.AddHostedService<AlertPhaseNotificationHostedService>(); + builder.Services.AddScoped<IArrivalsProcessor, VitrasaRealTimeProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, CorunaRealTimeProcessor>(); builder.Services.AddScoped<IArrivalsProcessor, TussaRealTimeProcessor>(); diff --git a/src/Enmarcha.Backend/Services/AlertPhaseNotificationHostedService.cs b/src/Enmarcha.Backend/Services/AlertPhaseNotificationHostedService.cs new file mode 100644 index 0000000..5cbbaee --- /dev/null +++ b/src/Enmarcha.Backend/Services/AlertPhaseNotificationHostedService.cs @@ -0,0 +1,77 @@ +using Enmarcha.Backend.Data; +using Microsoft.EntityFrameworkCore; + +namespace Enmarcha.Backend.Services; + +/// <summary> +/// Background service that automatically sends push notifications when a service alert +/// transitions into the PreNotice or Active phase without having been notified yet. +/// Runs every 60 seconds and also immediately on startup to handle any missed transitions. +/// </summary> +public class AlertPhaseNotificationHostedService( + IServiceScopeFactory scopeFactory, + ILogger<AlertPhaseNotificationHostedService> logger) : IHostedService, IDisposable +{ + private Timer? _timer; + + public Task StartAsync(CancellationToken cancellationToken) + { + // Run immediately, then every 60 seconds + _timer = new Timer(_ => _ = RunAsync(), null, TimeSpan.Zero, TimeSpan.FromSeconds(60)); + return Task.CompletedTask; + } + + private async Task RunAsync() + { + try + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); + var pushService = scope.ServiceProvider.GetRequiredService<IPushNotificationService>(); + + var now = DateTime.UtcNow; + + // Find alerts that are published and not yet hidden, but haven't been notified + // for their current phase (PreNotice: published but event not yet started; + // Active: event in progress). + var alertsToNotify = await db.ServiceAlerts + .Where(a => + a.PublishDate <= now && a.HiddenDate > now && + ( + // PreNotice: published, event hasn't started, no prenotice notification sent yet + (a.EventStartDate > now && a.PreNoticeNotifiedAt == null) || + // Active: event started and not finished, no active notification sent yet + (a.EventStartDate <= now && a.EventEndDate > now && a.ActiveNotifiedAt == null) + )) + .ToListAsync(); + + if (alertsToNotify.Count == 0) return; + + logger.LogInformation("Sending push notifications for {Count} alert(s)", alertsToNotify.Count); + + foreach (var alert in alertsToNotify) + { + try + { + await pushService.SendAlertAsync(alert); + } + catch (Exception ex) + { + logger.LogError(ex, "Error sending push notification for alert {AlertId}", alert.Id); + } + } + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error in {ServiceName}", nameof(AlertPhaseNotificationHostedService)); + } + } + + public Task StopAsync(CancellationToken cancellationToken) + { + _timer?.Change(Timeout.Infinite, 0); + return Task.CompletedTask; + } + + public void Dispose() => _timer?.Dispose(); +} diff --git a/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs b/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs index d09e207..b949aa7 100644 --- a/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs +++ b/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs @@ -42,14 +42,16 @@ public class BackofficeSelectorService( { var (feedId, routeId) = SplitGtfsId(r.GtfsId); var color = NormalizeColor(r.Color); - return new SelectorRouteItem(feedId, r.GtfsId, $"route#{feedId}:{routeId}", r.ShortName, r.LongName, r.Agency?.Name, color); + return new SelectorRouteItem(feedId, r.GtfsId, $"route#{feedId}:{routeId}", r.ShortName, r.LongName, r.Agency?.Name, r.Agency?.GtfsId, color); }) .OrderBy(r => r.ShortName) .ToList(); + // Group by the full agency gtfsId (feedId:agencyId) so that feeds with + // multiple agencies each get their own entry. var agencyDtos = routeDtos - .Where(r => r.AgencyName is not null) - .GroupBy(r => r.FeedId) + .Where(r => r.AgencyGtfsId is not null && r.AgencyName is not null) + .GroupBy(r => r.AgencyGtfsId!) .Select(g => new SelectorAgencyItem(g.Key, $"agency#{g.Key}", g.First().AgencyName!)) .ToList(); @@ -82,7 +84,7 @@ public class BackofficeSelectorService( var routeItems = (s.Routes ?? []).Select(r => { var (rf, ri) = SplitGtfsId(r.GtfsId); - return new SelectorRouteItem(rf, r.GtfsId, $"route#{rf}:{ri}", r.ShortName, null, null, NormalizeColor(r.Color)); + return new SelectorRouteItem(rf, r.GtfsId, $"route#{rf}:{ri}", r.ShortName, null, null, null, NormalizeColor(r.Color)); }).ToList(); return new SelectorStopItem(s.GtfsId, $"stop#{feedId}:{stopId}", s.Name, s.Code, s.Lat, s.Lon, routeItems); }) @@ -112,6 +114,7 @@ public class BackofficeSelectorService( } public record SelectorTransitData(List<SelectorAgencyItem> Agencies, List<SelectorRouteItem> Routes); -public record SelectorAgencyItem(string FeedId, string Selector, string Name); -public record SelectorRouteItem(string FeedId, string GtfsId, string Selector, string? ShortName, string? LongName, string? AgencyName, string? Color); +/// <param name="AgencyGtfsId">Full GTFS agency id in the form <c>feedId:agencyId</c>.</param> +public record SelectorAgencyItem(string AgencyGtfsId, string Selector, string Name); +public record SelectorRouteItem(string FeedId, string GtfsId, string Selector, string? ShortName, string? LongName, string? AgencyName, string? AgencyGtfsId, string? Color); public record SelectorStopItem(string GtfsId, string Selector, string Name, string? Code, double Lat, double Lon, List<SelectorRouteItem> Routes); diff --git a/src/Enmarcha.Backend/Services/OtpService.cs b/src/Enmarcha.Backend/Services/OtpService.cs index 16ba029..a7054b6 100644 --- a/src/Enmarcha.Backend/Services/OtpService.cs +++ b/src/Enmarcha.Backend/Services/OtpService.cs @@ -54,6 +54,7 @@ public class OtpService TextColor = textColor, SortOrder = route.SortOrder, AgencyName = route.Agency?.Name, + AgencyId = route.Agency?.GtfsId, TripCount = route.Patterns.Sum(p => p.TripsForDate.Count) }; } diff --git a/src/Enmarcha.Backend/Services/PushNotificationService.cs b/src/Enmarcha.Backend/Services/PushNotificationService.cs new file mode 100644 index 0000000..c9d79dc --- /dev/null +++ b/src/Enmarcha.Backend/Services/PushNotificationService.cs @@ -0,0 +1,134 @@ +using Enmarcha.Backend.Configuration; +using Enmarcha.Backend.Data; +using Enmarcha.Backend.Data.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; +using System.Text.Json; +using WebPush; + +namespace Enmarcha.Backend.Services; + +public interface IPushNotificationService +{ + Task SubscribeAsync(string endpoint, string p256dh, string auth); + Task UnsubscribeAsync(string endpoint); + Task SendAlertAsync(ServiceAlert alert); +} + +public class PushNotificationService( + AppDbContext db, + IOptions<AppConfiguration> options, + ILogger<PushNotificationService> logger +) : IPushNotificationService +{ + private readonly WebPushClient? _client = BuildClient(options.Value.Vapid); + + private static WebPushClient? BuildClient(VapidConfiguration? vapid) + { + if (vapid is null) return null; + var client = new WebPushClient(); + client.SetVapidDetails(vapid.Subject, vapid.PublicKey, vapid.PrivateKey); + return client; + } + + public async Task SubscribeAsync(string endpoint, string p256dh, string auth) + { + var existing = await db.PushSubscriptions + .FirstOrDefaultAsync(s => s.Endpoint == endpoint); + + if (existing is null) + { + db.PushSubscriptions.Add(new Data.Models.PushSubscription + { + Id = Guid.NewGuid(), + Endpoint = endpoint, + P256DhKey = p256dh, + AuthKey = auth, + CreatedAt = DateTime.UtcNow, + }); + } + else + { + // Refresh keys in case they changed (e.g. after re-subscription) + existing.P256DhKey = p256dh; + existing.AuthKey = auth; + } + + await db.SaveChangesAsync(); + } + + public async Task UnsubscribeAsync(string endpoint) + { + var subscription = await db.PushSubscriptions + .FirstOrDefaultAsync(s => s.Endpoint == endpoint); + + if (subscription is not null) + { + db.PushSubscriptions.Remove(subscription); + await db.SaveChangesAsync(); + } + } + + public async Task SendAlertAsync(ServiceAlert alert) + { + if (_client is null) + { + logger.LogWarning("VAPID not configured — skipping push notification for alert {AlertId}", alert.Id); + return; + } + + var now = DateTime.UtcNow; + var phase = alert.GetPhase(now); + + alert.Version++; + + if (phase == AlertPhase.PreNotice) + alert.PreNoticeNotifiedAt = now; + else if (phase == AlertPhase.Active) + alert.ActiveNotifiedAt = now; + + var payload = JsonSerializer.Serialize(new + { + alertId = alert.Id, + version = alert.Version, + phase = phase.ToString(), + cause = alert.Cause.ToString(), + effect = alert.Effect.ToString(), + header = (Dictionary<string, string>)alert.Header, + description = (Dictionary<string, string>)alert.Description, + selectors = alert.Selectors.Select(s => s.Raw).ToList(), + eventStart = alert.EventStartDate, + eventEnd = alert.EventEndDate, + }); + + var subscriptions = await db.PushSubscriptions.ToListAsync(); + var expired = new List<Data.Models.PushSubscription>(); + + foreach (var sub in subscriptions) + { + try + { + var pushSub = new WebPush.PushSubscription(sub.Endpoint, sub.P256DhKey, sub.AuthKey); + await _client.SendNotificationAsync(pushSub, payload); + } + catch (WebPushException ex) when ( + ex.StatusCode is System.Net.HttpStatusCode.Gone or System.Net.HttpStatusCode.NotFound) + { + // Subscription expired or was revoked — remove it + expired.Add(sub); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to deliver push notification to endpoint {Endpoint}", sub.Endpoint[..Math.Min(40, sub.Endpoint.Length)]); + } + } + + if (expired.Count > 0) + { + db.PushSubscriptions.RemoveRange(expired); + logger.LogInformation("Removed {Count} expired push subscription(s)", expired.Count); + } + + await db.SaveChangesAsync(); + } +} diff --git a/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs b/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs index 8427ec7..8ce2f90 100644 --- a/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs +++ b/src/Enmarcha.Backend/Types/Transit/RouteDtos.cs @@ -9,6 +9,7 @@ public class RouteDto public string? TextColor { get; set; } public int? SortOrder { get; set; } public string? AgencyName { get; set; } + public string? AgencyId { get; set; } public int TripCount { get; set; } } @@ -19,6 +20,7 @@ public class RouteDetailsDto public string? Color { get; set; } public string? TextColor { get; set; } public string? AgencyName { get; set; } + public string? AgencyId { get; set; } public List<PatternDto> Patterns { get; set; } = []; } diff --git a/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs b/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs index e1e068e..6514a5a 100644 --- a/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs +++ b/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs @@ -17,7 +17,7 @@ public class AlertFormViewModel [Display(Name = "Selectores (uno por línea)")] public string SelectorsRaw { get; set; } = ""; - [Display(Name = "URLs de información (una por línea)")] + [Display(Name = "URLs de información (una por línea)"), Required(AllowEmptyStrings = true)] public string InfoUrlsRaw { get; set; } = ""; [Display(Name = "Causa")] diff --git a/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml b/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml index 57e853d..63852d7 100644 --- a/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml +++ b/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml @@ -57,6 +57,8 @@ </a> </div> +<div asp-validation-summary="ModelOnly" class="alert alert-danger"></div> + <form action="@formAction" method="post" novalidate> @Html.AntiForgeryToken() @if (!isCreate) @@ -407,7 +409,7 @@ } for (const a of agencies) el.appendChild(makeTransitItem(a.selector, () => `<i class="bi bi-building me-2"></i><span class="flex-grow-1">${escHtml(a.name)}</span>` + - `<code class="text-muted small">${escHtml(a.feedId)}</code>` + `<code class="text-muted small">${escHtml(a.agencyGtfsId)}</code>` )); } diff --git a/src/Enmarcha.Backend/Views/Alerts/Index.cshtml b/src/Enmarcha.Backend/Views/Alerts/Index.cshtml index d541ccc..114f3a5 100644 --- a/src/Enmarcha.Backend/Views/Alerts/Index.cshtml +++ b/src/Enmarcha.Backend/Views/Alerts/Index.cshtml @@ -5,6 +5,14 @@ ViewData["Title"] = "Alertas de servicio"; } +@if (TempData["SuccessMessage"] is string successMsg) +{ + <div class="alert alert-success alert-dismissible fade show d-flex align-items-center gap-2 mb-4" role="alert"> + <i class="bi bi-check-circle-fill"></i> @successMsg + <button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button> + </div> +} + <div class="d-flex justify-content-between align-items-center mb-4"> <h1 class="h3 mb-0"> <i class="bi bi-exclamation-triangle me-2 text-warning"></i>Alertas de servicio @@ -66,6 +74,12 @@ else title="Editar"> <i class="bi bi-pencil"></i> </a> + <form action="/backoffice/alerts/@alert.Id/push" method="post" class="d-inline ms-1"> + @Html.AntiForgeryToken() + <button type="submit" class="btn btn-sm btn-outline-primary" title="Enviar notificación push"> + <i class="bi bi-bell"></i> + </button> + </form> <a href="/backoffice/alerts/@alert.Id/delete" class="btn btn-sm btn-outline-danger ms-1" title="Eliminar"> diff --git a/src/Enmarcha.Backend/appsettings.json b/src/Enmarcha.Backend/appsettings.json index d09e564..3b9f47f 100644 --- a/src/Enmarcha.Backend/appsettings.json +++ b/src/Enmarcha.Backend/appsettings.json @@ -12,5 +12,12 @@ } } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "App": { + "Vapid": { + "Subject": "mailto:admin@example.com", + "PublicKey": "", + "PrivateKey": "" + } + } } |
