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