aboutsummaryrefslogtreecommitdiff
path: root/src/Enmarcha.Backend
diff options
context:
space:
mode:
Diffstat (limited to 'src/Enmarcha.Backend')
-rw-r--r--src/Enmarcha.Backend/Configuration/AppConfiguration.cs20
-rw-r--r--src/Enmarcha.Backend/Controllers/AlertsController.cs40
-rw-r--r--src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs21
-rw-r--r--src/Enmarcha.Backend/Controllers/PushController.cs50
-rw-r--r--src/Enmarcha.Backend/Data/AppDbContext.cs7
-rw-r--r--src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.Designer.cs440
-rw-r--r--src/Enmarcha.Backend/Data/Migrations/20260401135403_AddPushNotifications.cs74
-rw-r--r--src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs48
-rw-r--r--src/Enmarcha.Backend/Data/Models/AlertSelector.cs3
-rw-r--r--src/Enmarcha.Backend/Data/Models/PushSubscription.cs20
-rw-r--r--src/Enmarcha.Backend/Data/Models/ServiceAlert.cs9
-rw-r--r--src/Enmarcha.Backend/Enmarcha.Backend.csproj1
-rw-r--r--src/Enmarcha.Backend/Program.cs3
-rw-r--r--src/Enmarcha.Backend/Services/AlertPhaseNotificationHostedService.cs77
-rw-r--r--src/Enmarcha.Backend/Services/BackofficeSelectorService.cs15
-rw-r--r--src/Enmarcha.Backend/Services/OtpService.cs1
-rw-r--r--src/Enmarcha.Backend/Services/PushNotificationService.cs134
-rw-r--r--src/Enmarcha.Backend/Types/Transit/RouteDtos.cs2
-rw-r--r--src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs2
-rw-r--r--src/Enmarcha.Backend/Views/Alerts/Edit.cshtml4
-rw-r--r--src/Enmarcha.Backend/Views/Alerts/Index.cshtml14
-rw-r--r--src/Enmarcha.Backend/appsettings.json9
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": ""
+ }
+ }
}