summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAriel Costas Guerrero <ariel@costas.dev>2026-03-19 18:56:34 +0100
committerAriel Costas Guerrero <ariel@costas.dev>2026-03-19 18:56:34 +0100
commitbee85bf92aab84087798ffa9f3f16336acef2fce (patch)
tree4fc8e2907e6618940cd9bdeb3da1a81172aab459
parentfed5d57b9e5d3df7c34bccb7a120bfa274b2039a (diff)
Basic backoffice for alert management
-rw-r--r--Directory.Packages.props37
-rw-r--r--Taskfile.yml10
-rw-r--r--src/Enmarcha.Backend/Configuration/AppConfiguration.cs1
-rw-r--r--src/Enmarcha.Backend/Content/xunta_fares.csv (renamed from src/Enmarcha.Backend/Data/xunta_fares.csv)0
-rw-r--r--src/Enmarcha.Backend/Controllers/ArrivalsController.cs2
-rw-r--r--src/Enmarcha.Backend/Controllers/Backoffice/AlertsApiController.cs14
-rw-r--r--src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs83
-rw-r--r--src/Enmarcha.Backend/Controllers/Backoffice/BackofficeController.cs18
-rw-r--r--src/Enmarcha.Backend/Controllers/Backoffice/LoginController.cs28
-rw-r--r--src/Enmarcha.Backend/Controllers/TileController.cs2
-rw-r--r--src/Enmarcha.Backend/Data/AppDbContext.cs76
-rw-r--r--src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.Designer.cs392
-rw-r--r--src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.cs251
-rw-r--r--src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs389
-rw-r--r--src/Enmarcha.Backend/Data/Models/AlertSelector.cs19
-rw-r--r--src/Enmarcha.Backend/Data/Models/ServiceAlert.cs127
-rw-r--r--src/Enmarcha.Backend/Data/Models/TranslatedString.cs26
-rw-r--r--src/Enmarcha.Backend/Enmarcha.Backend.csproj12
-rw-r--r--src/Enmarcha.Backend/Helpers/EnumExtensions.cs22
-rw-r--r--src/Enmarcha.Backend/Program.cs66
-rw-r--r--src/Enmarcha.Backend/Services/BackofficeSelectorService.cs117
-rw-r--r--src/Enmarcha.Backend/Services/OtpService.cs2
-rw-r--r--src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs2
-rw-r--r--src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs118
-rw-r--r--src/Enmarcha.Backend/Views/Alerts/Delete.cshtml41
-rw-r--r--src/Enmarcha.Backend/Views/Alerts/Edit.cshtml446
-rw-r--r--src/Enmarcha.Backend/Views/Alerts/Index.cshtml81
-rw-r--r--src/Enmarcha.Backend/Views/Backoffice/Index.cshtml27
-rw-r--r--src/Enmarcha.Backend/Views/Shared/_BackofficeLayout.cshtml50
-rw-r--r--src/Enmarcha.Backend/Views/_ViewImports.cshtml5
-rw-r--r--src/Enmarcha.Backend/Views/_ViewStart.cshtml3
-rw-r--r--src/Enmarcha.Sources.OpenTripPlannerGql/OpenTripPlannerClient.cs2
-rw-r--r--src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs11
-rw-r--r--src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs25
-rw-r--r--src/frontend/public/maps/styles/openfreemap-light.json62
-rw-r--r--src/frontend/vite.config.ts2
36 files changed, 2470 insertions, 99 deletions
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5e43aca..c59ee3c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,27 +4,30 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
- <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
+ <PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.5" />
+ <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.3" />
+ <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
- <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1" />
- <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
- <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0" />
+ <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1" />
+ <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
+ <PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" Version="10.0.0" />
+ <PackageVersion Include="EFCore.NamingConventions" Version="10.0.1" />
- <PackageVersion Include="Costasdev.VigoTransitApi" Version="0.1.0" />
- <PackageVersion Include="Google.Protobuf" Version="3.33.1" />
- <PackageVersion Include="ProjNet" Version="2.1.0" />
+ <PackageVersion Include="Costasdev.VigoTransitApi" Version="0.1.0" />
+ <PackageVersion Include="Google.Protobuf" Version="3.33.1" />
+ <PackageVersion Include="ProjNet" Version="2.1.0" />
- <PackageVersion Include="NetTopologySuite" Version="2.6.0" />
- <PackageVersion Include="NetTopologySuite.IO.GeoJSON" Version="4.0.0" />
- <PackageVersion Include="NetTopologySuite.IO.VectorTiles.Mapbox" Version="1.1.0" />
+ <PackageVersion Include="NetTopologySuite" Version="2.6.0" />
+ <PackageVersion Include="NetTopologySuite.IO.GeoJSON" Version="4.0.0" />
+ <PackageVersion Include="NetTopologySuite.IO.VectorTiles.Mapbox" Version="1.1.0" />
- <PackageVersion Include="NodaTime" Version="3.3.1" />
- <PackageVersion Include="CsvHelper" Version="33.1.0" />
- <PackageVersion Include="FuzzySharp" Version="2.0.2" />
+ <PackageVersion Include="NodaTime" Version="3.3.1" />
+ <PackageVersion Include="CsvHelper" Version="33.1.0" />
+ <PackageVersion Include="FuzzySharp" Version="2.0.2" />
- <PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15" />
- <PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
- <PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
- <PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
+ <PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.15" />
+ <PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
+ <PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.15.0" />
+ <PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.15.0" />
</ItemGroup>
</Project>
diff --git a/Taskfile.yml b/Taskfile.yml
index 9015fd0..6ed06f5 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -33,3 +33,13 @@ tasks:
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/src/Enmarcha.Backend/Configuration/AppConfiguration.cs b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs
index f52c89e..8c6e411 100644
--- a/src/Enmarcha.Backend/Configuration/AppConfiguration.cs
+++ b/src/Enmarcha.Backend/Configuration/AppConfiguration.cs
@@ -5,6 +5,7 @@ public class AppConfiguration
public required string OpenTripPlannerBaseUrl { get; set; }
public required string GeoapifyApiKey { get; set; }
public string NominatimBaseUrl { get; set; } = "https://nominatim.openstreetmap.org";
+ public string[] OtpFeeds { get; set; } = [];
public OpenTelemetryConfiguration? OpenTelemetry { get; set; }
}
diff --git a/src/Enmarcha.Backend/Data/xunta_fares.csv b/src/Enmarcha.Backend/Content/xunta_fares.csv
index 35cf65f..35cf65f 100644
--- a/src/Enmarcha.Backend/Data/xunta_fares.csv
+++ b/src/Enmarcha.Backend/Content/xunta_fares.csv
diff --git a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
index 8ce63f7..f922ca9 100644
--- a/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
+++ b/src/Enmarcha.Backend/Controllers/ArrivalsController.cs
@@ -168,7 +168,7 @@ public partial class ArrivalsController : ControllerBase
List<Arrival> arrivals = [];
foreach (var item in stop.Arrivals)
{
- //if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) continue;
+ if (item.PickupTypeParsed.Equals(ArrivalsAtStopResponse.PickupType.None)) continue;
if (
item.Trip.ArrivalStoptime.Stop.GtfsId == id &&
item.Trip.DepartureStoptime.Stop.GtfsId != id
diff --git a/src/Enmarcha.Backend/Controllers/Backoffice/AlertsApiController.cs b/src/Enmarcha.Backend/Controllers/Backoffice/AlertsApiController.cs
new file mode 100644
index 0000000..fe425d4
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/Backoffice/AlertsApiController.cs
@@ -0,0 +1,14 @@
+using Enmarcha.Backend.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Enmarcha.Backend.Controllers.Backoffice;
+
+[Route("backoffice/api")]
+[Authorize(AuthenticationSchemes = "Backoffice")]
+public class AlertsApiController(BackofficeSelectorService selectors) : ControllerBase
+{
+ [HttpGet("selectors/transit")]
+ public async Task<IActionResult> GetTransit() =>
+ Ok(await selectors.GetTransitDataAsync());
+}
diff --git a/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs b/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs
new file mode 100644
index 0000000..4e83abc
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/Backoffice/AlertsController.cs
@@ -0,0 +1,83 @@
+using Enmarcha.Backend.Data;
+using Enmarcha.Backend.ViewModels;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Enmarcha.Backend.Controllers.Backoffice;
+
+[Route("backoffice/alerts")]
+[Authorize(AuthenticationSchemes = "Backoffice")]
+public class AlertsController(AppDbContext db) : Controller
+{
+ [HttpGet("")]
+ public async Task<IActionResult> Index()
+ {
+ var alerts = await db.ServiceAlerts
+ .OrderByDescending(a => a.InsertedDate)
+ .ToListAsync();
+ return View(alerts);
+ }
+
+ [HttpGet("create")]
+ public IActionResult Create() => View("Edit", new AlertFormViewModel());
+
+ [HttpPost("create")]
+ [ValidateAntiForgeryToken]
+ public async Task<IActionResult> CreatePost(AlertFormViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ db.ServiceAlerts.Add(model.ToServiceAlert());
+ await db.SaveChangesAsync();
+ return RedirectToAction(nameof(Index));
+ }
+
+ [HttpGet("{id}/edit")]
+ public async Task<IActionResult> Edit(string id)
+ {
+ var alert = await db.ServiceAlerts.FindAsync(id);
+ if (alert is null) return NotFound();
+ return View(AlertFormViewModel.FromServiceAlert(alert));
+ }
+
+ [HttpPost("{id}/edit")]
+ [ValidateAntiForgeryToken]
+ public async Task<IActionResult> EditPost(string id, AlertFormViewModel model)
+ {
+ if (!ModelState.IsValid)
+ {
+ return View("Edit", model);
+ }
+
+ var alert = await db.ServiceAlerts.FindAsync(id);
+ if (alert is null) return NotFound();
+
+ model.ApplyTo(alert);
+ await db.SaveChangesAsync();
+ return RedirectToAction(nameof(Index));
+ }
+
+ [HttpGet("{id}/delete")]
+ public async Task<IActionResult> Delete(string id)
+ {
+ var alert = await db.ServiceAlerts.FindAsync(id);
+ if (alert is null) return NotFound();
+ return View(alert);
+ }
+
+ [HttpPost("{id}/delete")]
+ [ValidateAntiForgeryToken]
+ public async Task<IActionResult> DeleteConfirm(string id)
+ {
+ var alert = await db.ServiceAlerts.FindAsync(id);
+ if (alert is null) return NotFound();
+
+ db.ServiceAlerts.Remove(alert);
+ await db.SaveChangesAsync();
+ return RedirectToAction(nameof(Index));
+ }
+}
diff --git a/src/Enmarcha.Backend/Controllers/Backoffice/BackofficeController.cs b/src/Enmarcha.Backend/Controllers/Backoffice/BackofficeController.cs
new file mode 100644
index 0000000..a3c41dc
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/Backoffice/BackofficeController.cs
@@ -0,0 +1,18 @@
+using Enmarcha.Backend.Data;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Enmarcha.Backend.Controllers.Backoffice;
+
+[Route("backoffice")]
+[Authorize(AuthenticationSchemes = "Backoffice")]
+public class BackofficeController(AppDbContext db) : Controller
+{
+ [HttpGet("")]
+ public async Task<IActionResult> Index()
+ {
+ ViewData["AlertCount"] = await db.ServiceAlerts.CountAsync();
+ return View();
+ }
+}
diff --git a/src/Enmarcha.Backend/Controllers/Backoffice/LoginController.cs b/src/Enmarcha.Backend/Controllers/Backoffice/LoginController.cs
new file mode 100644
index 0000000..1e9f12f
--- /dev/null
+++ b/src/Enmarcha.Backend/Controllers/Backoffice/LoginController.cs
@@ -0,0 +1,28 @@
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Enmarcha.Backend.Controllers.Backoffice;
+
+[Route("backoffice/auth")]
+public class LoginController : Controller
+{
+ [HttpGet("login")]
+ [AllowAnonymous]
+ public IActionResult Login(string returnUrl = "/backoffice")
+ {
+ return Challenge(new AuthenticationProperties { RedirectUri = returnUrl }, "Auth0");
+ }
+
+ [HttpPost("logout")]
+ [ValidateAntiForgeryToken]
+ [Authorize(AuthenticationSchemes = "Backoffice")]
+ public IActionResult Logout()
+ {
+ return SignOut(
+ new AuthenticationProperties { RedirectUri = "/backoffice" },
+ "Backoffice",
+ "Auth0");
+ }
+}
+
diff --git a/src/Enmarcha.Backend/Controllers/TileController.cs b/src/Enmarcha.Backend/Controllers/TileController.cs
index bf89a08..63e8a9a 100644
--- a/src/Enmarcha.Backend/Controllers/TileController.cs
+++ b/src/Enmarcha.Backend/Controllers/TileController.cs
@@ -72,7 +72,7 @@ public class TileController : ControllerBase
var latMinRad = Math.Atan(Math.Sinh(Math.PI * (1 - 2 * (y + 1) / n)));
var latMin = latMinRad * 180.0 / Math.PI;
- var requestContent = StopTileRequestContent.Query(new StopTileRequestContent.Bbox(lonMin, latMin, lonMax, latMax));
+ var requestContent = StopTileRequestContent.Query(new StopTileRequestContent.TileRequestParams(lonMin, latMin, lonMax, latMax));
var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1");
request.Content = JsonContent.Create(new GraphClientRequest
{
diff --git a/src/Enmarcha.Backend/Data/AppDbContext.cs b/src/Enmarcha.Backend/Data/AppDbContext.cs
new file mode 100644
index 0000000..d5a29ee
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/AppDbContext.cs
@@ -0,0 +1,76 @@
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.ChangeTracking;
+using Enmarcha.Backend.Data.Models;
+using System.Text.Json;
+
+namespace Enmarcha.Backend.Data;
+
+public class AppDbContext : IdentityDbContext<IdentityUser>
+{
+ public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
+ {
+ }
+
+ public DbSet<ServiceAlert> ServiceAlerts { get; set; }
+
+ protected override void OnModelCreating(ModelBuilder builder)
+ {
+ base.OnModelCreating(builder);
+
+ // Rename Identity tables to snake_case for PostgreSQL
+ builder.Entity<IdentityUser>(b => b.ToTable("users"));
+ builder.Entity<IdentityRole>(b => b.ToTable("roles"));
+ builder.Entity<IdentityUserRole<string>>(b => b.ToTable("user_roles"));
+ builder.Entity<IdentityUserClaim<string>>(b => b.ToTable("user_claims"));
+ builder.Entity<IdentityUserLogin<string>>(b => b.ToTable("user_logins"));
+ builder.Entity<IdentityRoleClaim<string>>(b => b.ToTable("role_claims"));
+ builder.Entity<IdentityUserToken<string>>(b => b.ToTable("user_tokens"));
+
+ // ServiceAlert configuration
+ builder.Entity<ServiceAlert>(b =>
+ {
+ b.HasKey(x => x.Id);
+
+ static ValueComparer<T> JsonComparer<T>() where T : class => new(
+ (x, y) => JsonSerializer.Serialize(x, (JsonSerializerOptions?)null) ==
+ JsonSerializer.Serialize(y, (JsonSerializerOptions?)null),
+ c => JsonSerializer.Serialize(c, (JsonSerializerOptions?)null).GetHashCode(),
+ c => JsonSerializer.Deserialize<T>(
+ JsonSerializer.Serialize(c, (JsonSerializerOptions?)null),
+ (JsonSerializerOptions?)null)!);
+
+ // Store Selectors as JSONB
+ b.Property(x => x.Selectors)
+ .HasColumnType("jsonb")
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
+ v => JsonSerializer.Deserialize<List<AlertSelector>>(v, (JsonSerializerOptions?)null) ?? new List<AlertSelector>(),
+ JsonComparer<List<AlertSelector>>());
+
+ // Store TranslatedStrings as JSONB
+ b.Property(x => x.Header)
+ .HasColumnType("jsonb")
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
+ v => JsonSerializer.Deserialize<TranslatedString>(v, (JsonSerializerOptions?)null) ?? new(),
+ JsonComparer<TranslatedString>());
+
+ b.Property(x => x.Description)
+ .HasColumnType("jsonb")
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
+ v => JsonSerializer.Deserialize<TranslatedString>(v, (JsonSerializerOptions?)null) ?? new(),
+ JsonComparer<TranslatedString>());
+
+ // Store InfoUrls as JSONB array
+ b.Property(x => x.InfoUrls)
+ .HasColumnType("jsonb")
+ .HasConversion(
+ v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
+ v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new List<string>(),
+ JsonComparer<List<string>>());
+ });
+ }
+}
diff --git a/src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.Designer.cs b/src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.Designer.cs
new file mode 100644
index 0000000..4e0684d
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.Designer.cs
@@ -0,0 +1,392 @@
+// <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("20260319113819_Initial")]
+ partial class Initial
+ {
+ /// <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.ServiceAlert", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("text")
+ .HasColumnName("id");
+
+ 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>("PublishDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("publish_date");
+
+ b.Property<string>("Selectors")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("selectors");
+
+ 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/20260319113819_Initial.cs b/src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.cs
new file mode 100644
index 0000000..2548aa9
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/Migrations/20260319113819_Initial.cs
@@ -0,0 +1,251 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Data.Migrations
+{
+ /// <inheritdoc />
+ public partial class Initial : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AlterDatabase()
+ .Annotation("Npgsql:PostgresExtension:postgis", ",,");
+
+ migrationBuilder.CreateTable(
+ name: "roles",
+ columns: table => new
+ {
+ id = table.Column<string>(type: "text", nullable: false),
+ name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
+ normalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
+ concurrencyStamp = table.Column<string>(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pK_roles", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "service_alerts",
+ columns: table => new
+ {
+ id = table.Column<string>(type: "text", nullable: false),
+ selectors = table.Column<string>(type: "jsonb", nullable: false),
+ cause = table.Column<int>(type: "integer", nullable: false),
+ effect = table.Column<int>(type: "integer", nullable: false),
+ header = table.Column<string>(type: "jsonb", nullable: false),
+ description = table.Column<string>(type: "jsonb", nullable: false),
+ info_urls = table.Column<string>(type: "jsonb", nullable: false),
+ inserted_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ publish_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ event_start_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ event_end_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
+ hidden_date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pK_service_alerts", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "users",
+ columns: table => new
+ {
+ id = table.Column<string>(type: "text", nullable: false),
+ userName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
+ normalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
+ email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
+ normalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true),
+ emailConfirmed = table.Column<bool>(type: "boolean", nullable: false),
+ passwordHash = table.Column<string>(type: "text", nullable: true),
+ securityStamp = table.Column<string>(type: "text", nullable: true),
+ concurrencyStamp = table.Column<string>(type: "text", nullable: true),
+ phoneNumber = table.Column<string>(type: "text", nullable: true),
+ phoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false),
+ twoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false),
+ lockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
+ lockoutEnabled = table.Column<bool>(type: "boolean", nullable: false),
+ accessFailedCount = table.Column<int>(type: "integer", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pK_users", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "role_claims",
+ columns: table => new
+ {
+ id = table.Column<int>(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ roleId = table.Column<string>(type: "text", nullable: false),
+ claimType = table.Column<string>(type: "text", nullable: true),
+ claimValue = table.Column<string>(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pK_role_claims", x => x.id);
+ table.ForeignKey(
+ name: "fK_role_claims_roles_roleId",
+ column: x => x.roleId,
+ principalTable: "roles",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "user_claims",
+ columns: table => new
+ {
+ id = table.Column<int>(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ userId = table.Column<string>(type: "text", nullable: false),
+ claimType = table.Column<string>(type: "text", nullable: true),
+ claimValue = table.Column<string>(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pK_user_claims", x => x.id);
+ table.ForeignKey(
+ name: "fK_user_claims_users_userId",
+ column: x => x.userId,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "user_logins",
+ columns: table => new
+ {
+ loginProvider = table.Column<string>(type: "text", nullable: false),
+ providerKey = table.Column<string>(type: "text", nullable: false),
+ providerDisplayName = table.Column<string>(type: "text", nullable: true),
+ userId = table.Column<string>(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pK_user_logins", x => new { x.loginProvider, x.providerKey });
+ table.ForeignKey(
+ name: "fK_user_logins_users_userId",
+ column: x => x.userId,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "user_roles",
+ columns: table => new
+ {
+ userId = table.Column<string>(type: "text", nullable: false),
+ roleId = table.Column<string>(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pK_user_roles", x => new { x.userId, x.roleId });
+ table.ForeignKey(
+ name: "fK_user_roles_roles_roleId",
+ column: x => x.roleId,
+ principalTable: "roles",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fK_user_roles_users_userId",
+ column: x => x.userId,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "user_tokens",
+ columns: table => new
+ {
+ userId = table.Column<string>(type: "text", nullable: false),
+ loginProvider = table.Column<string>(type: "text", nullable: false),
+ name = table.Column<string>(type: "text", nullable: false),
+ value = table.Column<string>(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pK_user_tokens", x => new { x.userId, x.loginProvider, x.name });
+ table.ForeignKey(
+ name: "fK_user_tokens_users_userId",
+ column: x => x.userId,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "iX_role_claims_roleId",
+ table: "role_claims",
+ column: "roleId");
+
+ migrationBuilder.CreateIndex(
+ name: "RoleNameIndex",
+ table: "roles",
+ column: "normalizedName",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "iX_user_claims_userId",
+ table: "user_claims",
+ column: "userId");
+
+ migrationBuilder.CreateIndex(
+ name: "iX_user_logins_userId",
+ table: "user_logins",
+ column: "userId");
+
+ migrationBuilder.CreateIndex(
+ name: "iX_user_roles_roleId",
+ table: "user_roles",
+ column: "roleId");
+
+ migrationBuilder.CreateIndex(
+ name: "EmailIndex",
+ table: "users",
+ column: "normalizedEmail");
+
+ migrationBuilder.CreateIndex(
+ name: "UserNameIndex",
+ table: "users",
+ column: "normalizedUserName",
+ unique: true);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "role_claims");
+
+ migrationBuilder.DropTable(
+ name: "service_alerts");
+
+ migrationBuilder.DropTable(
+ name: "user_claims");
+
+ migrationBuilder.DropTable(
+ name: "user_logins");
+
+ migrationBuilder.DropTable(
+ name: "user_roles");
+
+ migrationBuilder.DropTable(
+ name: "user_tokens");
+
+ migrationBuilder.DropTable(
+ name: "roles");
+
+ migrationBuilder.DropTable(
+ name: "users");
+ }
+ }
+}
diff --git a/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs
new file mode 100644
index 0000000..88488bf
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -0,0 +1,389 @@
+// <auto-generated />
+using System;
+using Enmarcha.Backend.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Data.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ partial class AppDbContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(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.ServiceAlert", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("text")
+ .HasColumnName("id");
+
+ 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>("PublishDate")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("publish_date");
+
+ b.Property<string>("Selectors")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("selectors");
+
+ 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/Models/AlertSelector.cs b/src/Enmarcha.Backend/Data/Models/AlertSelector.cs
new file mode 100644
index 0000000..34b2de3
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/Models/AlertSelector.cs
@@ -0,0 +1,19 @@
+namespace Enmarcha.Backend.Data.Models;
+
+/// <summary>
+/// Defines the scope of an alert (e.g., "stop#vitrasa:1400", "route#xunta:123").
+/// This follows a URI-like pattern for easy parsing and matching.
+/// </summary>
+public class AlertSelector
+{
+ public string Raw { get; set; } = string.Empty;
+
+ public string Type => Raw.Split('#').FirstOrDefault() ?? string.Empty;
+ public string Id => Raw.Split('#').ElementAtOrDefault(1) ?? string.Empty;
+
+ 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}" };
+
+ public override string ToString() => Raw;
+}
diff --git a/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs b/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs
new file mode 100644
index 0000000..5f80e3c
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/Models/ServiceAlert.cs
@@ -0,0 +1,127 @@
+using System.ComponentModel;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Enmarcha.Backend.Data.Models;
+
+[Table("service_alerts")]
+public class ServiceAlert
+{
+ public string Id { get; set; }
+
+ public List<AlertSelector> Selectors { get; set; } = [];
+
+ public AlertCause Cause { get; set; }
+ public AlertEffect Effect { get; set; }
+
+ public TranslatedString Header { get; set; } = [];
+ public TranslatedString Description { get; set; } = [];
+ [Column("info_urls")] public List<string> InfoUrls { get; set; } = [];
+
+ [Column("inserted_date")] public DateTime InsertedDate { get; set; }
+
+ [Column("publish_date")] public DateTime PublishDate { get; set; }
+ [Column("event_start_date")] public DateTime EventStartDate { get; set; }
+ [Column("event_end_date")] public DateTime EventEndDate { get; set; }
+ [Column("hidden_date")] public DateTime HiddenDate { get; set; }
+
+ public AlertPhase GetPhase(DateTime? now = null)
+ {
+ now ??= DateTime.UtcNow;
+
+ if (now < PublishDate)
+ {
+ return AlertPhase.Draft;
+ }
+
+ if (now < EventStartDate)
+ {
+ return AlertPhase.PreNotice;
+ }
+
+ if (now < EventEndDate)
+ {
+ return AlertPhase.Active;
+ }
+
+ if (now < HiddenDate)
+ {
+ return AlertPhase.Finished;
+ }
+
+ return AlertPhase.Done;
+ }
+}
+
+/// <summary>
+/// Phases of an alert lifecycle, not standard GTFS-RT, but useful if we can display a change to the service with a notice
+/// before it actually starts affecting the service. For example, if we know that a strike will start on a certain date, we can show it as "PreNotice"
+/// before it starts, then "Active" while it's happening, and "Finished" after it ends but before we hide it from the system, for example with
+/// a checkmark saying "everything back to normal".
+/// </summary>
+public enum AlertPhase
+{
+ Draft = -1,
+ PreNotice = 0,
+ Active = 1,
+ Finished = 2,
+ Done = 3
+}
+
+public enum AlertCause
+{
+ [Description("Causa desconocida")]
+ UnknownCause = 1,
+ [Description("Otra causa")]
+ OtherCause = 2, // Not machine-representable.
+ [Description("Problema técnico")]
+ TechnicalProblem = 3,
+ [Description("Huelga (personal de la agencia)")]
+ Strike = 4, // Public transit agency employees stopped working.
+ [Description("Manifestación (otros)")]
+ Demonstration = 5, // People are blocking the streets.
+ [Description("Accidente")]
+ Accident = 6,
+ [Description("Festivo")]
+ Holiday = 7,
+ [Description("Condiciones meteorológicas")]
+ Weather = 8,
+ [Description("Obras en carretera (mantenimiento)")]
+ Maintenance = 9,
+ [Description("Obras próximas (construcción)")]
+ Construction = 10,
+ [Description("Intervención policial")]
+ PoliceActivity = 11,
+ [Description("Emergencia médica")]
+ MedicalEmergency = 12
+}
+
+public enum AlertEffect
+{
+ [Description("Sin servicio")]
+ NoService = 1,
+ [Description("Servicio reducido")]
+ ReducedService = 2,
+
+ // We don't care about INsignificant delays: they are hard to detect, have
+ // little impact on the user, and would clutter the results as they are too
+ // frequent.
+ [Description("Retrasos significativos")]
+ SignificantDelays = 3,
+
+ [Description("Desvío")]
+ Detour = 4,
+ [Description("Servicio adicional")]
+ AdditionalService = 5,
+ [Description("Servicio modificado")]
+ ModifiedService = 6,
+ [Description("Otro efecto")]
+ OtherEffect = 7,
+ [Description("Efecto desconocido")]
+ UnknownEffect = 8,
+ [Description("Parada movida")]
+ StopMoved = 9,
+ [Description("Sin efecto")]
+ NoEffect = 10,
+ [Description("Problemas de accesibilidad")]
+ AccessibilityIssue = 11
+}
diff --git a/src/Enmarcha.Backend/Data/Models/TranslatedString.cs b/src/Enmarcha.Backend/Data/Models/TranslatedString.cs
new file mode 100644
index 0000000..7bce8ea
--- /dev/null
+++ b/src/Enmarcha.Backend/Data/Models/TranslatedString.cs
@@ -0,0 +1,26 @@
+namespace Enmarcha.Backend.Data.Models;
+
+/// <summary>
+/// A translatable string that can be stored in the database as a single JSON column.
+/// Keys are ISO language codes (e.g., "es", "gl", "en").
+/// </summary>
+public class TranslatedString : Dictionary<string, string>
+{
+ public TranslatedString() : base() { }
+
+ public TranslatedString(IDictionary<string, string> dictionary) : base(dictionary) { }
+
+ /// <summary>
+ /// Gets the translation for the specified language, or a fallback if not found.
+ /// </summary>
+ public string Get(string lang, string fallback = "es")
+ {
+ if (TryGetValue(lang, out var value))
+ return value;
+
+ if (TryGetValue(fallback, out var fallbackValue))
+ return fallbackValue;
+
+ return Values.FirstOrDefault() ?? string.Empty;
+ }
+}
diff --git a/src/Enmarcha.Backend/Enmarcha.Backend.csproj b/src/Enmarcha.Backend/Enmarcha.Backend.csproj
index d2c5a28..c70624c 100644
--- a/src/Enmarcha.Backend/Enmarcha.Backend.csproj
+++ b/src/Enmarcha.Backend/Enmarcha.Backend.csproj
@@ -12,6 +12,16 @@
<ItemGroup>
<PackageReference Include="Costasdev.VigoTransitApi" />
+ <PackageReference Include="Google.Protobuf" />
+ <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" />
+ <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
+ <PackageReference Include="Microsoft.EntityFrameworkCore.Design">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
+ <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NetTopologySuite" />
+ <PackageReference Include="EFCore.NamingConventions" />
<PackageReference Include="ProjNet" />
<PackageReference Include="NetTopologySuite" />
@@ -36,7 +46,7 @@
</ItemGroup>
<ItemGroup>
- <None Update="Data\*.csv">
+ <None Update="Content\*.csv">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
diff --git a/src/Enmarcha.Backend/Helpers/EnumExtensions.cs b/src/Enmarcha.Backend/Helpers/EnumExtensions.cs
new file mode 100644
index 0000000..4ab3a66
--- /dev/null
+++ b/src/Enmarcha.Backend/Helpers/EnumExtensions.cs
@@ -0,0 +1,22 @@
+using System.ComponentModel;
+using System.Reflection;
+using Microsoft.AspNetCore.Mvc.Rendering;
+
+namespace Enmarcha.Backend.Helpers;
+
+public static class EnumExtensions
+{
+ public static string GetDescription(this Enum value)
+ {
+ var field = value.GetType().GetField(value.ToString());
+ var attr = field?.GetCustomAttribute<DescriptionAttribute>();
+ return attr?.Description ?? value.ToString();
+ }
+
+ public static IEnumerable<SelectListItem> ToSelectList<TEnum>() where TEnum : struct, Enum =>
+ Enum.GetValues<TEnum>().Select(e => new SelectListItem
+ {
+ Value = e.ToString(),
+ Text = e.GetDescription()
+ });
+}
diff --git a/src/Enmarcha.Backend/Program.cs b/src/Enmarcha.Backend/Program.cs
index 587da78..7ca0b34 100644
--- a/src/Enmarcha.Backend/Program.cs
+++ b/src/Enmarcha.Backend/Program.cs
@@ -1,11 +1,15 @@
using System.Text.Json.Serialization;
using Enmarcha.Backend;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
using Enmarcha.Backend.Configuration;
+using Enmarcha.Backend.Data;
using Enmarcha.Backend.Services;
using Enmarcha.Backend.Services.Geocoding;
using Enmarcha.Backend.Services.Processors;
using Enmarcha.Backend.Services.Providers;
+using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.EntityFrameworkCore;
using OpenTelemetry.Logs;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
@@ -130,7 +134,7 @@ builder.Services.AddOpenTelemetry()
});
builder.Services
- .AddControllers()
+ .AddControllersWithViews()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
@@ -139,6 +143,59 @@ builder.Services
builder.Services.AddHttpClient();
builder.Services.AddMemoryCache();
+builder.Services.AddDbContext<AppDbContext>(options =>
+{
+ options.UseNpgsql(
+ builder.Configuration.GetConnectionString("Database"),
+ o => o.UseNetTopologySuite()
+ )
+ .UseCamelCaseNamingConvention();
+});
+
+builder.Services.AddIdentityApiEndpoints<IdentityUser>()
+ .AddEntityFrameworkStores<AppDbContext>();
+
+var auth0Domain = builder.Configuration["Auth0:Domain"] ?? "";
+var auth0ClientId = builder.Configuration["Auth0:ClientId"] ?? "";
+
+builder.Services.AddAuthentication()
+ .AddCookie("Backoffice", options =>
+ {
+ options.LoginPath = "/backoffice/auth/login";
+ })
+ .AddOpenIdConnect("Auth0", options =>
+ {
+ options.Authority = $"https://{auth0Domain}/";
+ options.ClientId = auth0ClientId;
+ options.ClientSecret = builder.Configuration["Auth0:ClientSecret"];
+ options.ResponseType = "code";
+ options.CallbackPath = "/backoffice/auth/callback";
+ options.SignInScheme = "Backoffice";
+ options.Scope.Clear();
+ options.Scope.Add("openid");
+ options.Scope.Add("profile");
+ options.Scope.Add("email");
+ options.SaveTokens = true;
+ options.Events = new OpenIdConnectEvents
+ {
+ OnRedirectToIdentityProviderForSignOut = context =>
+ {
+ var logoutUri = $"https://{auth0Domain}/v2/logout?client_id={Uri.EscapeDataString(auth0ClientId)}";
+ var returnTo = context.Properties.RedirectUri;
+ if (!string.IsNullOrEmpty(returnTo))
+ {
+ var req = context.Request;
+ if (!returnTo.StartsWith("http", StringComparison.OrdinalIgnoreCase))
+ returnTo = $"{req.Scheme}://{req.Host}{req.PathBase}{returnTo}";
+ logoutUri += $"&returnTo={Uri.EscapeDataString(returnTo)}";
+ }
+ context.Response.Redirect(logoutUri);
+ context.HandleResponse();
+ return Task.CompletedTask;
+ }
+ };
+ });
+
builder.Services.AddSingleton<XuntaFareProvider>();
builder.Services.AddSingleton<ShapeTraversalService>();
@@ -161,6 +218,7 @@ builder.Services.AddScoped<ArrivalsPipeline>();
// builder.Services.AddKeyedScoped<IGeocodingService, NominatimGeocodingService>("Nominatim");
builder.Services.AddHttpClient<IGeocodingService, GeoapifyGeocodingService>();
builder.Services.AddHttpClient<OtpService>();
+builder.Services.AddHttpClient<BackofficeSelectorService>();
builder.Services.AddHttpClient<Enmarcha.Sources.TranviasCoruna.CorunaRealtimeEstimatesProvider>();
builder.Services.AddHttpClient<Enmarcha.Sources.Tussa.SantiagoRealtimeEstimatesProvider>();
builder.Services.AddHttpClient<Enmarcha.Sources.CtagShuttle.CtagShuttleRealtimeEstimatesProvider>();
@@ -169,6 +227,12 @@ builder.Services.AddHttpClient<Costasdev.VigoTransitApi.VigoTransitApiClient>();
var app = builder.Build();
+app.UseStaticFiles();
+app.UseAuthentication();
+app.UseAuthorization();
+
+app.MapGroup("/api/identity").MapIdentityApi<IdentityUser>();
+
app.Use(async (context, next) =>
{
if (context.Request.Headers.TryGetValue("X-Session-Id", out var sessionId))
diff --git a/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs b/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs
new file mode 100644
index 0000000..d09e207
--- /dev/null
+++ b/src/Enmarcha.Backend/Services/BackofficeSelectorService.cs
@@ -0,0 +1,117 @@
+using Enmarcha.Backend.Configuration;
+using Enmarcha.Sources.OpenTripPlannerGql;
+using Enmarcha.Sources.OpenTripPlannerGql.Queries;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Enmarcha.Backend.Services;
+
+public class BackofficeSelectorService(
+ HttpClient httpClient,
+ IOptions<AppConfiguration> config,
+ IMemoryCache cache,
+ ILogger<BackofficeSelectorService> logger)
+{
+ public async Task<SelectorTransitData> GetTransitDataAsync()
+ {
+ const string cacheKey = "backoffice_transit";
+ if (cache.TryGetValue(cacheKey, out SelectorTransitData? cached) && cached is not null)
+ return cached;
+
+ var feeds = config.Value.OtpFeeds;
+ var today = DateTime.Today.ToString("yyyy-MM-dd");
+ var query = RoutesListContent.Query(new RoutesListContent.Args(feeds, today));
+
+ List<RoutesListResponse.RouteItem> routes = [];
+ try
+ {
+ var req = new HttpRequestMessage(HttpMethod.Post, $"{config.Value.OpenTripPlannerBaseUrl}/gtfs/v1");
+ req.Content = JsonContent.Create(new GraphClientRequest { Query = query });
+ var resp = await httpClient.SendAsync(req);
+ resp.EnsureSuccessStatusCode();
+ var body = await resp.Content.ReadFromJsonAsync<GraphClientResponse<RoutesListResponse>>();
+ routes = body?.Data?.Routes ?? [];
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to fetch routes from OTP");
+ }
+
+ var routeDtos = routes
+ .Select(r =>
+ {
+ 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);
+ })
+ .OrderBy(r => r.ShortName)
+ .ToList();
+
+ var agencyDtos = routeDtos
+ .Where(r => r.AgencyName is not null)
+ .GroupBy(r => r.FeedId)
+ .Select(g => new SelectorAgencyItem(g.Key, $"agency#{g.Key}", g.First().AgencyName!))
+ .ToList();
+
+ var result = new SelectorTransitData(agencyDtos, routeDtos);
+ cache.Set(cacheKey, result, TimeSpan.FromHours(1));
+ return result;
+ }
+
+ public async Task<List<SelectorStopItem>> GetStopsByBboxAsync(
+ double minLon, double minLat, double maxLon, double maxLat)
+ {
+ // Cache per coarse grid (~0.1° cells, roughly 8 km) to reuse across small pans
+ var cacheKey = $"stops_{minLon:F1}_{minLat:F1}_{maxLon:F1}_{maxLat:F1}";
+ if (cache.TryGetValue(cacheKey, out List<SelectorStopItem>? cached) && cached is not null)
+ return cached;
+
+ var query = StopTileRequestContent.Query(
+ new StopTileRequestContent.TileRequestParams(minLon, minLat, maxLon, maxLat));
+ try
+ {
+ var req = new HttpRequestMessage(HttpMethod.Post, $"{config.Value.OpenTripPlannerBaseUrl}/gtfs/v1");
+ req.Content = JsonContent.Create(new GraphClientRequest { Query = query });
+ var resp = await httpClient.SendAsync(req);
+ resp.EnsureSuccessStatusCode();
+ var body = await resp.Content.ReadFromJsonAsync<GraphClientResponse<StopTileResponse>>();
+ var stops = (body?.Data?.StopsByBbox ?? [])
+ .Select(s =>
+ {
+ var (feedId, stopId) = SplitGtfsId(s.GtfsId);
+ 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));
+ }).ToList();
+ return new SelectorStopItem(s.GtfsId, $"stop#{feedId}:{stopId}", s.Name, s.Code, s.Lat, s.Lon, routeItems);
+ })
+ .ToList();
+ cache.Set(cacheKey, stops, TimeSpan.FromMinutes(30));
+ return stops;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Failed to fetch stops from OTP for bbox {MinLon},{MinLat} to {MaxLon},{MaxLat}",
+ minLon, minLat, maxLon, maxLat);
+ return [];
+ }
+ }
+
+ private static (string FeedId, string EntityId) SplitGtfsId(string gtfsId)
+ {
+ var parts = gtfsId.Split(':', 2);
+ return (parts[0], parts.Length > 1 ? parts[1] : gtfsId);
+ }
+
+ private static string? NormalizeColor(string? color)
+ {
+ if (string.IsNullOrWhiteSpace(color)) return null;
+ return color.StartsWith('#') ? color : '#' + color;
+ }
+}
+
+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);
+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 8724cda..16ba029 100644
--- a/src/Enmarcha.Backend/Services/OtpService.cs
+++ b/src/Enmarcha.Backend/Services/OtpService.cs
@@ -125,7 +125,7 @@ public class OtpService
try
{
- var bbox = new StopTileRequestContent.Bbox(minLon, minLat, maxLon, maxLat);
+ var bbox = new StopTileRequestContent.TileRequestParams(minLon, minLat, maxLon, maxLat);
var query = StopTileRequestContent.Query(bbox);
var request = new HttpRequestMessage(HttpMethod.Post, $"{_config.OpenTripPlannerBaseUrl}/gtfs/v1");
diff --git a/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs
index 3e62264..733be92 100644
--- a/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs
+++ b/src/Enmarcha.Backend/Services/Providers/XuntaFareProvider.cs
@@ -20,7 +20,7 @@ public class XuntaFareProvider
public XuntaFareProvider(IWebHostEnvironment env)
{
- var filePath = Path.Combine(env.ContentRootPath, "Data", "xunta_fares.csv");
+ var filePath = Path.Combine(env.ContentRootPath, "Content", "xunta_fares.csv");
using var reader = new StreamReader(filePath);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
diff --git a/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs b/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs
new file mode 100644
index 0000000..e1e068e
--- /dev/null
+++ b/src/Enmarcha.Backend/ViewModels/AlertFormViewModel.cs
@@ -0,0 +1,118 @@
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Enmarcha.Backend.Data.Models;
+
+namespace Enmarcha.Backend.ViewModels;
+
+public class AlertFormViewModel
+{
+ public string? Id { get; set; }
+
+ [Display(Name = "Título")]
+ public string HeaderEs { get; set; } = "";
+
+ [Display(Name = "Descripción")]
+ public string DescriptionEs { get; set; } = "";
+
+ [Display(Name = "Selectores (uno por línea)")]
+ public string SelectorsRaw { get; set; } = "";
+
+ [Display(Name = "URLs de información (una por línea)")]
+ public string InfoUrlsRaw { get; set; } = "";
+
+ [Display(Name = "Causa")]
+ public AlertCause Cause { get; set; } = AlertCause.OtherCause;
+
+ [Display(Name = "Efecto")]
+ public AlertEffect Effect { get; set; } = AlertEffect.OtherEffect;
+
+ [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd\\THH:mm}", ApplyFormatInEditMode = true)]
+ [Display(Name = "Publicar desde")]
+ public DateTime PublishDate { get; set; } = ToMadrid(DateTime.UtcNow);
+
+ [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd\\THH:mm}", ApplyFormatInEditMode = true)]
+ [Display(Name = "Inicio del evento")]
+ public DateTime EventStartDate { get; set; } = ToMadrid(DateTime.UtcNow);
+
+ [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd\\THH:mm}", ApplyFormatInEditMode = true)]
+ [Display(Name = "Fin del evento")]
+ public DateTime EventEndDate { get; set; } = ToMadrid(DateTime.UtcNow.AddDays(1));
+
+ [DisplayFormat(DataFormatString = "{0:yyyy-MM-dd\\THH:mm}", ApplyFormatInEditMode = true)]
+ [Display(Name = "Ocultar desde")]
+ public DateTime HiddenDate { get; set; } = ToMadrid(DateTime.UtcNow.AddDays(7));
+
+ public ServiceAlert ToServiceAlert() => new()
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ Header = ParseTranslated(HeaderEs),
+ Description = ParseTranslated(DescriptionEs),
+ Selectors = ParseSelectors(),
+ InfoUrls = ParseLines(InfoUrlsRaw),
+ Cause = Cause,
+ Effect = Effect,
+ InsertedDate = DateTime.UtcNow,
+ PublishDate = ToUtc(PublishDate),
+ EventStartDate = ToUtc(EventStartDate),
+ EventEndDate = ToUtc(EventEndDate),
+ HiddenDate = ToUtc(HiddenDate),
+ };
+
+ public void ApplyTo(ServiceAlert alert)
+ {
+ alert.Header = ParseTranslated(HeaderEs);
+ alert.Description = ParseTranslated(DescriptionEs);
+ alert.Selectors = ParseSelectors();
+ alert.InfoUrls = ParseLines(InfoUrlsRaw);
+ alert.Cause = Cause;
+ alert.Effect = Effect;
+ alert.PublishDate = ToUtc(PublishDate);
+ alert.EventStartDate = ToUtc(EventStartDate);
+ alert.EventEndDate = ToUtc(EventEndDate);
+ alert.HiddenDate = ToUtc(HiddenDate);
+ }
+
+ public static AlertFormViewModel FromServiceAlert(ServiceAlert alert) => new()
+ {
+ Id = alert.Id,
+ HeaderEs = alert.Header.GetValueOrDefault("es") ?? "",
+ DescriptionEs = alert.Description.GetValueOrDefault("es") ?? "",
+ SelectorsRaw = string.Join('\n', alert.Selectors.Select(s => s.Raw)),
+ InfoUrlsRaw = string.Join('\n', alert.InfoUrls),
+ Cause = alert.Cause,
+ Effect = alert.Effect,
+ PublishDate = ToMadrid(alert.PublishDate),
+ EventStartDate = ToMadrid(alert.EventStartDate),
+ EventEndDate = ToMadrid(alert.EventEndDate),
+ HiddenDate = ToMadrid(alert.HiddenDate),
+ };
+
+ private static TranslatedString ParseTranslated(string es)
+ {
+ var dict = new TranslatedString();
+ if (!string.IsNullOrWhiteSpace(es)) dict["es"] = es.Trim();
+ return dict;
+ }
+
+ private List<AlertSelector> ParseSelectors() =>
+ ParseLines(SelectorsRaw)
+ .Where(s => s.Contains('#'))
+ .Select(s => new AlertSelector { Raw = s })
+ .ToList();
+
+ private static List<string> ParseLines(string raw) =>
+ raw.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .Where(s => !string.IsNullOrWhiteSpace(s))
+ .ToList();
+
+ private static readonly TimeZoneInfo MadridTz =
+ TimeZoneInfo.FindSystemTimeZoneById("Europe/Madrid");
+
+ // Form input is "Unspecified" (local Madrid time) → convert to UTC for storage
+ private static DateTime ToUtc(DateTime dt) =>
+ TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(dt, DateTimeKind.Unspecified), MadridTz);
+
+ // UTC from DB → Madrid local time for display in datetime-local inputs
+ private static DateTime ToMadrid(DateTime utcDt) =>
+ TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utcDt, DateTimeKind.Utc), MadridTz);
+}
diff --git a/src/Enmarcha.Backend/Views/Alerts/Delete.cshtml b/src/Enmarcha.Backend/Views/Alerts/Delete.cshtml
new file mode 100644
index 0000000..0c24b88
--- /dev/null
+++ b/src/Enmarcha.Backend/Views/Alerts/Delete.cshtml
@@ -0,0 +1,41 @@
+@model Enmarcha.Backend.Data.Models.ServiceAlert
+@{
+ ViewData["Title"] = "Eliminar alerta";
+}
+
+<div class="row justify-content-center">
+ <div class="col-lg-6">
+ <div class="card border-danger shadow-sm">
+ <div class="card-header bg-danger text-white fw-semibold">
+ <i class="bi bi-exclamation-triangle-fill me-2"></i> Confirmar eliminación
+ </div>
+ <div class="card-body">
+ <p class="mb-3">¿Estás seguro de que quieres eliminar la siguiente alerta?</p>
+ <dl class="row mb-3">
+ <dt class="col-sm-4">ID</dt>
+ <dd class="col-sm-8"><code class="text-muted">@Model.Id</code></dd>
+ <dt class="col-sm-4">Título</dt>
+ <dd class="col-sm-8">@Model.Header.Get("es")</dd>
+ <dt class="col-sm-4">Evento</dt>
+ <dd class="col-sm-8">
+ @Model.EventStartDate.ToString("dd/MM/yyyy HH:mm")
+ → @Model.EventEndDate.ToString("dd/MM/yyyy HH:mm")
+ </dd>
+ </dl>
+ <p class="text-danger mb-4">
+ <i class="bi bi-exclamation-circle me-1"></i>
+ <strong>Esta acción no se puede deshacer.</strong>
+ </p>
+ <div class="d-flex gap-2">
+ <form action="/backoffice/alerts/@Model.Id/delete" method="post">
+ @Html.AntiForgeryToken()
+ <button type="submit" class="btn btn-danger">
+ <i class="bi bi-trash me-1"></i> Eliminar
+ </button>
+ </form>
+ <a href="/backoffice/alerts" class="btn btn-outline-secondary">Cancelar</a>
+ </div>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml b/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml
new file mode 100644
index 0000000..57e853d
--- /dev/null
+++ b/src/Enmarcha.Backend/Views/Alerts/Edit.cshtml
@@ -0,0 +1,446 @@
+@model Enmarcha.Backend.ViewModels.AlertFormViewModel
+@using Enmarcha.Backend.Data.Models
+@using Enmarcha.Backend.Helpers
+@{
+ var isCreate = Model.Id is null;
+ ViewData["Title"] = isCreate ? "Nueva alerta" : "Editar alerta";
+ var formAction = isCreate
+ ? "/backoffice/alerts/create"
+ : $"/backoffice/alerts/{Model.Id}/edit";
+}
+
+@section Head {
+ <link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css"/>
+ <script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
+ <style>
+ #stop-map {
+ height: 400px;
+ width: 100%;
+ border-radius: 0 0 0.375rem 0.375rem;
+ }
+
+ .selector-picker-tabs .nav-link {
+ border-radius: 0;
+ border-top: none;
+ }
+
+ .selector-item {
+ cursor: pointer;
+ transition: background .12s;
+ }
+
+ .selector-item:hover {
+ background: var(--bs-secondary-bg);
+ }
+
+ .selector-item.selected {
+ background: var(--bs-primary-bg-subtle);
+ border-color: var(--bs-primary) !important;
+ }
+
+ #route-list, #agency-list {
+ max-height: 360px;
+ overflow-y: auto;
+ }
+
+ .route-badge {
+ min-width: 2.5rem;
+ text-align: center;
+ }
+ </style>
+}
+
+<div class="d-flex justify-content-between align-items-center mb-4">
+ <h1 class="h3 mb-0">@ViewData["Title"]</h1>
+ <a href="/backoffice/alerts" class="btn btn-outline-secondary btn-sm">
+ <i class="bi bi-arrow-left me-1"></i> Volver
+ </a>
+</div>
+
+<form action="@formAction" method="post" novalidate>
+ @Html.AntiForgeryToken()
+ @if (!isCreate)
+ {
+ <input type="hidden" asp-for="Id"/>
+ }
+
+ <div class="row g-4">
+ @* Textos *@
+ <div class="col-12">
+ <div class="card shadow-sm">
+ <div class="card-header fw-semibold">
+ <i class="bi bi-translate me-2"></i>Textos
+ </div>
+ <div class="card-body row g-3">
+ <div class="col-md-6">
+ <label asp-for="HeaderEs" class="form-label"></label>
+ <input asp-for="HeaderEs" class="form-control"/>
+ <span asp-validation-for="HeaderEs" class="text-danger small"></span>
+ </div>
+ <div class="col-md-6">
+ <label asp-for="DescriptionEs" class="form-label"></label>
+ <textarea asp-for="DescriptionEs" class="form-control" rows="3"></textarea>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ @* Causa / Efecto *@
+ <div class="col-md-6">
+ <label asp-for="Cause" class="form-label"></label>
+ <select asp-for="Cause" class="form-select"
+ asp-items="@EnumExtensions.ToSelectList<AlertCause>()"></select>
+ </div>
+ <div class="col-md-6">
+ <label asp-for="Effect" class="form-label"></label>
+ <select asp-for="Effect" class="form-select"
+ asp-items="@EnumExtensions.ToSelectList<AlertEffect>()"></select>
+ </div>
+
+ @* Fechas *@
+ <div class="col-12">
+ <div class="card shadow-sm">
+ <div class="card-header fw-semibold">
+ <i class="bi bi-calendar-range me-2"></i>Fechas
+ </div>
+ <div class="card-body row g-3">
+ <div class="col-sm-6 col-lg-3">
+ <label asp-for="PublishDate" class="form-label"></label>
+ <input asp-for="PublishDate" type="datetime-local" class="form-control"/>
+ </div>
+ <div class="col-sm-6 col-lg-3">
+ <label asp-for="EventStartDate" class="form-label"></label>
+ <input asp-for="EventStartDate" type="datetime-local" class="form-control"/>
+ </div>
+ <div class="col-sm-6 col-lg-3">
+ <label asp-for="EventEndDate" class="form-label"></label>
+ <input asp-for="EventEndDate" type="datetime-local" class="form-control"/>
+ </div>
+ <div class="col-sm-6 col-lg-3">
+ <label asp-for="HiddenDate" class="form-label"></label>
+ <input asp-for="HiddenDate" type="datetime-local" class="form-control"/>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ @* Selectores *@
+ <div class="col-12">
+ <label class="form-label fw-semibold">Selectores</label>
+ <input type="hidden" asp-for="SelectorsRaw" id="selectors-hidden"/>
+
+ <div class="card-body pb-2">
+ <div class="d-flex align-items-center gap-2 mb-2">
+ <span class="small text-muted">Seleccionados:</span>
+ <div id="selector-badges" class="d-flex flex-wrap gap-1 flex-grow-1">
+ <em class="text-muted small">Ninguno</em>
+ </div>
+ </div>
+ </div>
+
+ <div>
+ <ul class="nav nav-tabs" role="tablist">
+ <li class="nav-item">
+ <button class="nav-link active" type="button" data-bs-toggle="tab" data-bs-target="#tab-stops"
+ id="tab-stops-btn">
+ <i class="bi bi-geo-alt me-1"></i> Paradas
+ </button>
+ </li>
+ <li class="nav-item">
+ <button class="nav-link" type="button" data-bs-toggle="tab" data-bs-target="#tab-routes"
+ id="tab-routes-btn">
+ <i class="bi bi-signpost me-1"></i> Líneas
+ </button>
+ </li>
+ <li class="nav-item">
+ <button class="nav-link" type="button" data-bs-toggle="tab" data-bs-target="#tab-agencies"
+ id="tab-agencies-btn">
+ <i class="bi bi-building me-1"></i> Agencias
+ </button>
+ </li>
+ </ul>
+
+ <div class="tab-content">
+ <div id="tab-stops" class="tab-pane fade show active p-0">
+ <div id="stop-map"></div>
+ <div id="map-status" class="px-3 py-1 small text-muted border-top"></div>
+ </div>
+
+ <div id="tab-routes" class="tab-pane fade p-3">
+ <input type="text" id="route-search" class="form-control form-control-sm mb-2"
+ placeholder="Buscar por nombre, línea o agencia…"/>
+ <div id="route-list" class="d-flex flex-column gap-1"></div>
+ </div>
+
+ <div id="tab-agencies" class="tab-pane fade p-3">
+ <div id="agency-list" class="d-flex flex-column gap-1"></div>
+ </div>
+ </div>
+ </div>
+
+ <div class="form-text mt-1">
+ También puedes escribir directamente: <code>stop#feedId:stopId</code> ·
+ <code>route#feedId:routeId</code> · <code>agency#feedId</code>
+ </div>
+ </div>
+
+ @* URLs *@
+ <div class="col-md-6">
+ <label asp-for="InfoUrlsRaw" class="form-label"></label>
+ <textarea asp-for="InfoUrlsRaw" class="form-control" rows="4"
+ placeholder="https://ejemplo.com/aviso"></textarea>
+ <div class="form-text">Una URL por línea</div>
+ </div>
+ </div>
+
+ <div class="mt-4 d-flex gap-2">
+ <button type="submit" class="btn btn-primary">
+ <i class="bi bi-check-lg me-1"></i>
+ @(isCreate ? "Crear alerta" : "Guardar cambios")
+ </button>
+ <a href="/backoffice/alerts" class="btn btn-outline-secondary">Cancelar</a>
+ </div>
+</form>
+
+<script>
+ (function () {
+ 'use strict';
+
+ // ── State ──────────────────────────────────────────────────────────────────
+ const selected = new Set(
+ document.getElementById('selectors-hidden').value
+ .split('\n').map(s => s.trim()).filter(Boolean)
+ );
+
+ function syncHidden() {
+ document.getElementById('selectors-hidden').value = [...selected].join('\n');
+ }
+
+ function toggle(raw) {
+ if (selected.has(raw)) selected.delete(raw);
+ else selected.add(raw);
+ syncHidden();
+ renderBadges();
+ updateMapHighlight();
+ refreshListItem(raw);
+ }
+
+ // ── Badges ─────────────────────────────────────────────────────────────────
+ function renderBadges() {
+ const el = document.getElementById('selector-badges');
+ el.innerHTML = '';
+ if (!selected.size) {
+ el.innerHTML = '<em class="text-muted small">Ninguno</em>';
+ return;
+ }
+ const colors = {stop: 'primary', route: 'success', agency: 'warning'};
+ for (const sel of [...selected].sort()) {
+ const type = sel.split('#')[0];
+ const span = document.createElement('span');
+ span.className = `badge bg-${colors[type] ?? 'secondary'} d-inline-flex align-items-center gap-1`;
+ span.style.cssText = 'cursor:default;font-size:.8em';
+ span.innerHTML =
+ `<span>${escHtml(sel)}</span>` +
+ `<button type="button" class="btn-close btn-close-white" style="font-size:.6em" aria-label="Quitar"></button>`;
+ span.querySelector('button').onclick = () => toggle(sel);
+ el.appendChild(span);
+ }
+ }
+
+ function escHtml(s) {
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+ }
+
+ // ── Map ────────────────────────────────────────────────────────────────────
+ let stopMap = null;
+ const EMPTY_FC = {type: 'FeatureCollection', features: []};
+ let currentStops = EMPTY_FC;
+
+ function initMap() {
+ stopMap = new maplibregl.Map({
+ container: 'stop-map',
+ style: {
+ "version": 8,
+ "sprite": "https://enmarcha.app/ofm/sprites/ofm_f384/ofm",
+ "glyphs": "https://enmarcha.app/ofm/fonts/{fontstack}/{range}.pbf",
+ "sources": {
+ "osm": {
+ "type": "raster",
+ "tiles": [
+ "https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png",
+ "https://b.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png",
+ "https://c.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png"
+ ],
+ "tileSize": 256
+ },
+ "stops": {
+ "type": "vector",
+ "tiles": [
+ window.location.origin + "/api/tiles/stops/{z}/{x}/{y}"
+ ]
+ }
+ },
+ "layers": [
+ {
+ "id": "osm-layer",
+ "type": "raster",
+ "source": "osm"
+ }
+ ]
+
+ },
+ center: [-8.722, 42.232],
+ zoom: 13
+ });
+
+ stopMap.on('load', () => {
+ stopMap.addLayer({
+ id: 'stops-circle',
+ type: 'circle',
+ source: 'stops',
+ "source-layer": 'stops',
+ paint: {
+ 'circle-radius': ['interpolate', ['linear'], ['zoom'], 10, 6, 16, 9],
+ 'circle-color': '#6c757d',
+ 'circle-stroke-width': 1.5,
+ 'circle-stroke-color': '#fff'
+ }
+ });
+
+ stopMap.addLayer({
+ id: 'stops-label',
+ type: 'symbol',
+ source: 'stops',
+ "source-layer": 'stops',
+ minzoom: 15,
+ layout: {
+ 'text-field': ['get', 'name'],
+ 'text-size': 11,
+ 'text-offset': [0, 1.2],
+ 'text-anchor': 'top'
+ },
+ paint: {'text-halo-width': 2, 'text-halo-color': '#fff'}
+ });
+
+ stopMap.on('click', 'stops-circle', e => {
+ if (!e.features.length) return;
+ toggle('stop#' + e.features[0].properties.id);
+ });
+ stopMap.on('mouseenter', 'stops-circle', () => {
+ stopMap.getCanvas().style.cursor = 'pointer';
+ });
+ stopMap.on('mouseleave', 'stops-circle', () => {
+ stopMap.getCanvas().style.cursor = '';
+ });
+ });
+ }
+
+ function updateMapHighlight() {
+ if (!stopMap?.isStyleLoaded()) return;
+ const sels = [...selected].filter(s => s.startsWith('stop#'));
+ stopMap.setPaintProperty('stops-circle', 'circle-color',
+ sels.length
+ ? ['match', ['get', 'selector'], sels, '#0d6efd', '#6c757d']
+ : '#6c757d'
+ );
+ stopMap.setPaintProperty('stops-circle', 'circle-radius', [
+ 'interpolate', ['linear'], ['zoom'],
+ 10, sels.length ? ['match', ['get', 'selector'], sels, 6, 3] : 3,
+ 16, sels.length ? ['match', ['get', 'selector'], sels, 10, 7] : 7
+ ]);
+ }
+
+ // Resize map when its tab is shown (it may have been hidden on init)
+ document.getElementById('tab-stops-btn').addEventListener('shown.bs.tab', () => {
+ stopMap?.resize();
+ });
+
+ // ── Routes & Agencies ──────────────────────────────────────────────────────
+ let allRoutes = [], allAgencies = [];
+
+ async function loadTransitData() {
+ try {
+ const res = await fetch('/backoffice/api/selectors/transit');
+ const data = await res.json();
+ allRoutes = data.routes ?? [];
+ allAgencies = data.agencies ?? [];
+ renderRoutes(allRoutes);
+ renderAgencies(allAgencies);
+ } catch (err) {
+ console.error('Error fetching transit data:', err);
+ document.getElementById('route-list').innerHTML =
+ '<p class="text-danger small">Error cargando líneas</p>';
+ }
+ }
+
+ document.getElementById('route-search').addEventListener('input', function () {
+ const q = this.value.toLowerCase();
+ renderRoutes(allRoutes.filter(r =>
+ (r.shortName ?? '').toLowerCase().includes(q) ||
+ (r.longName ?? '').toLowerCase().includes(q) ||
+ (r.agencyName ?? '').toLowerCase().includes(q)
+ ));
+ });
+
+ function renderRoutes(routes) {
+ const el = document.getElementById('route-list');
+ el.innerHTML = '';
+ if (!routes.length) {
+ el.innerHTML = '<p class="text-muted small text-center py-3">Sin resultados</p>';
+ return;
+ }
+ for (const r of routes) el.appendChild(makeTransitItem(r.selector, () => {
+ const color = r.Color ?? '#808080';
+ const txt = contrastColor(color);
+ return `<span class="badge route-badge me-2" style="background:${color};color:${txt}">${escHtml(r.shortName ?? '?')}</span>` +
+ `<span class="flex-grow-1 small">${escHtml(r.longName ?? '')}</span>` +
+ `<span class="text-muted" style="font-size:.75em">${escHtml(r.agencyName ?? '')}</span>`;
+ }));
+ }
+
+ function renderAgencies(agencies) {
+ const el = document.getElementById('agency-list');
+ el.innerHTML = '';
+ if (!agencies.length) {
+ el.innerHTML = '<p class="text-muted small text-center py-3">Sin agencias</p>';
+ return;
+ }
+ 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>`
+ ));
+ }
+
+ function makeTransitItem(selector, innerHtml) {
+ const div = document.createElement('div');
+ div.id = 'item-' + CSS.escape(selector);
+ div.dataset.selector = selector;
+ div.className = 'selector-item d-flex align-items-center p-2 rounded border ' +
+ (selected.has(selector) ? 'selected' : '');
+ div.innerHTML = innerHtml() +
+ `<i class="bi bi-check-lg ms-2 text-primary ${selected.has(selector) ? '' : 'invisible'}"></i>`;
+ div.onclick = () => toggle(selector);
+ return div;
+ }
+
+ function refreshListItem(selector) {
+ const el = document.getElementById('item-' + CSS.escape(selector));
+ if (!el) return;
+ el.classList.toggle('selected', selected.has(selector));
+ const check = el.querySelector('.bi-check-lg');
+ if (check) check.classList.toggle('invisible', !selected.has(selector));
+ }
+
+ function contrastColor(hex) {
+ const c = hex.replace('#', '');
+ const r = parseInt(c.slice(0, 2), 16), g = parseInt(c.slice(2, 4), 16), b = parseInt(c.slice(4, 6), 16);
+ return (r * 299 + g * 587 + b * 114) / 1000 > 128 ? '#000' : '#fff';
+ }
+
+ // ── Init ───────────────────────────────────────────────────────────────────
+ renderBadges();
+ initMap();
+ loadTransitData();
+ })();
+</script>
+
diff --git a/src/Enmarcha.Backend/Views/Alerts/Index.cshtml b/src/Enmarcha.Backend/Views/Alerts/Index.cshtml
new file mode 100644
index 0000000..d541ccc
--- /dev/null
+++ b/src/Enmarcha.Backend/Views/Alerts/Index.cshtml
@@ -0,0 +1,81 @@
+@model List<Enmarcha.Backend.Data.Models.ServiceAlert>
+@using Enmarcha.Backend.Data.Models
+@using Enmarcha.Backend.Helpers
+@{
+ ViewData["Title"] = "Alertas de servicio";
+}
+
+<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
+ </h1>
+ <a href="/backoffice/alerts/create" class="btn btn-primary">
+ <i class="bi bi-plus-lg me-1"></i> Nueva alerta
+ </a>
+</div>
+
+@if (!Model.Any())
+{
+ <div class="alert alert-secondary d-flex align-items-center gap-2">
+ <i class="bi bi-info-circle"></i>
+ No hay alertas registradas.
+ </div>
+}
+else
+{
+ <div class="card shadow-sm">
+ <div class="table-responsive">
+ <table class="table table-hover align-middle mb-0">
+ <thead class="table-dark">
+ <tr>
+ <th>Título</th>
+ <th>Fase</th>
+ <th>Causa</th>
+ <th>Efecto</th>
+ <th>Evento</th>
+ <th style="width:1%"></th>
+ </tr>
+ </thead>
+ <tbody>
+ @foreach (var alert in Model)
+ {
+ var phase = alert.GetPhase();
+ var (badgeClass, phaseLabel) = phase switch
+ {
+ AlertPhase.Draft => ("bg-secondary", "Borrador"),
+ AlertPhase.PreNotice => ("bg-info text-dark", "Pre-aviso"),
+ AlertPhase.Active => ("bg-success", "Activa"),
+ AlertPhase.Finished => ("bg-warning text-dark", "Finalizada"),
+ _ => ("bg-dark", "Archivada")
+ };
+ <tr>
+ <td>
+ <div class="fw-semibold">@alert.Header.Get("es")</div>
+ <div class="text-muted small font-monospace">@alert.Id[..Math.Min(8, alert.Id.Length)]…</div>
+ </td>
+ <td><span class="badge @badgeClass">@phaseLabel</span></td>
+ <td class="small">@alert.Cause.GetDescription()</td>
+ <td class="small">@alert.Effect.GetDescription()</td>
+ <td class="small text-nowrap">
+ @alert.EventStartDate.ToString("dd/MM/yy HH:mm")<br />
+ <span class="text-muted">→ @alert.EventEndDate.ToString("dd/MM/yy HH:mm")</span>
+ </td>
+ <td class="text-end text-nowrap">
+ <a href="/backoffice/alerts/@alert.Id/edit"
+ class="btn btn-sm btn-outline-secondary"
+ title="Editar">
+ <i class="bi bi-pencil"></i>
+ </a>
+ <a href="/backoffice/alerts/@alert.Id/delete"
+ class="btn btn-sm btn-outline-danger ms-1"
+ title="Eliminar">
+ <i class="bi bi-trash"></i>
+ </a>
+ </td>
+ </tr>
+ }
+ </tbody>
+ </table>
+ </div>
+ </div>
+}
diff --git a/src/Enmarcha.Backend/Views/Backoffice/Index.cshtml b/src/Enmarcha.Backend/Views/Backoffice/Index.cshtml
new file mode 100644
index 0000000..fc31fb4
--- /dev/null
+++ b/src/Enmarcha.Backend/Views/Backoffice/Index.cshtml
@@ -0,0 +1,27 @@
+@{
+ ViewData["Title"] = "Dashboard";
+ var name = User.Identity!.Name ?? User.FindFirst("name")?.Value ?? "Usuario";
+ var alertCount = (int)(ViewData["AlertCount"] ?? 0);
+}
+
+<h1 class="h3 mb-4">Hola, @name 👋</h1>
+
+<div class="row g-3">
+ <div class="col-sm-6 col-lg-3">
+ <div class="card text-bg-warning shadow-sm h-100">
+ <div class="card-body">
+ <div class="d-flex justify-content-between align-items-start">
+ <div>
+ <h6 class="card-subtitle mb-1 text-dark opacity-75">Alertas de servicio</h6>
+ <p class="display-5 fw-bold mb-0">@alertCount</p>
+ </div>
+ <i class="bi bi-exclamation-triangle-fill fs-1 opacity-25"></i>
+ </div>
+ <a href="/backoffice/alerts" class="btn btn-dark btn-sm mt-3">
+ Gestionar <i class="bi bi-arrow-right ms-1"></i>
+ </a>
+ </div>
+ </div>
+ </div>
+</div>
+
diff --git a/src/Enmarcha.Backend/Views/Shared/_BackofficeLayout.cshtml b/src/Enmarcha.Backend/Views/Shared/_BackofficeLayout.cshtml
new file mode 100644
index 0000000..382499e
--- /dev/null
+++ b/src/Enmarcha.Backend/Views/Shared/_BackofficeLayout.cshtml
@@ -0,0 +1,50 @@
+<!DOCTYPE html>
+<html lang="es">
+<head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>@ViewData["Title"] — Backoffice</title>
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" />
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
+ @RenderSection("Head", required: false)
+</head>
+<body class="bg-light">
+ <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
+ <div class="container-fluid">
+ <a class="navbar-brand fw-semibold" href="/backoffice">
+ <i class="bi bi-bus-front me-1"></i> Backoffice
+ </a>
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarMain">
+ <span class="navbar-toggler-icon"></span>
+ </button>
+ <div class="collapse navbar-collapse" id="navbarMain">
+ <ul class="navbar-nav me-auto mb-2 mb-lg-0">
+ <li class="nav-item">
+ <a class="nav-link" href="/backoffice/alerts">
+ <i class="bi bi-exclamation-triangle me-1"></i> Alertas
+ </a>
+ </li>
+ </ul>
+ <div class="d-flex align-items-center gap-3">
+ <span class="text-light small">
+ <i class="bi bi-person-circle me-1"></i>
+ @(User.Identity!.Name ?? User.FindFirst("name")?.Value ?? "Usuario")
+ </span>
+ <form action="/backoffice/auth/logout" method="post" class="m-0">
+ @Html.AntiForgeryToken()
+ <button type="submit" class="btn btn-outline-light btn-sm">
+ <i class="bi bi-box-arrow-right me-1"></i> Salir
+ </button>
+ </form>
+ </div>
+ </div>
+ </div>
+ </nav>
+
+ <main class="container py-4">
+ @RenderBody()
+ </main>
+
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
+</body>
+</html>
diff --git a/src/Enmarcha.Backend/Views/_ViewImports.cshtml b/src/Enmarcha.Backend/Views/_ViewImports.cshtml
new file mode 100644
index 0000000..cea4231
--- /dev/null
+++ b/src/Enmarcha.Backend/Views/_ViewImports.cshtml
@@ -0,0 +1,5 @@
+@using Enmarcha.Backend
+@using Enmarcha.Backend.Data.Models
+@using Enmarcha.Backend.Helpers
+@using Enmarcha.Backend.ViewModels
+@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
diff --git a/src/Enmarcha.Backend/Views/_ViewStart.cshtml b/src/Enmarcha.Backend/Views/_ViewStart.cshtml
new file mode 100644
index 0000000..06a5d00
--- /dev/null
+++ b/src/Enmarcha.Backend/Views/_ViewStart.cshtml
@@ -0,0 +1,3 @@
+@{
+ Layout = "_BackofficeLayout";
+}
diff --git a/src/Enmarcha.Sources.OpenTripPlannerGql/OpenTripPlannerClient.cs b/src/Enmarcha.Sources.OpenTripPlannerGql/OpenTripPlannerClient.cs
index 453a03e..01a1fcd 100644
--- a/src/Enmarcha.Sources.OpenTripPlannerGql/OpenTripPlannerClient.cs
+++ b/src/Enmarcha.Sources.OpenTripPlannerGql/OpenTripPlannerClient.cs
@@ -24,7 +24,7 @@ public class OpenTripPlannerClient
public async Task GetStopsInBbox(double minLat, double minLon, double maxLat, double maxLon)
{
var requestContent =
- StopTileRequestContent.Query(new StopTileRequestContent.Bbox(minLon, minLat, maxLon, maxLat));
+ StopTileRequestContent.Query(new StopTileRequestContent.TileRequestParams(minLon, minLat, maxLon, maxLat));
var request = new HttpRequestMessage(HttpMethod.Post, $"{_baseUrl}/gtfs/v1");
request.Content = JsonContent.Create(new GraphClientRequest
diff --git a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs
index 71360ee..9894f14 100644
--- a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs
+++ b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/RoutesListContent.cs
@@ -1,4 +1,3 @@
-using System.Globalization;
using System.Text.Json.Serialization;
namespace Enmarcha.Sources.OpenTripPlannerGql.Queries;
@@ -9,10 +8,12 @@ public class RoutesListContent : IGraphRequest<RoutesListContent.Args>
public static string Query(Args args)
{
- var feedsStr = string.Join(", ", args.Feeds.Select(f => $"\"{f}\""));
- return string.Create(CultureInfo.InvariantCulture, $$"""
+ var feedsArg = args.Feeds.Length > 0
+ ? $"(feeds: [{string.Join(", ", args.Feeds.Select(f => $"\"{f}\""))}])"
+ : "";
+ return $$"""
query Query {
- routes(feeds: [{{feedsStr}}]) {
+ routes{{feedsArg}} {
gtfsId
shortName
longName
@@ -29,7 +30,7 @@ public class RoutesListContent : IGraphRequest<RoutesListContent.Args>
}
}
}
- """);
+ """;
}
}
diff --git a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs
index fad28eb..6079ea3 100644
--- a/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs
+++ b/src/Enmarcha.Sources.OpenTripPlannerGql/Queries/StopTile.cs
@@ -3,19 +3,30 @@ using System.Text.Json.Serialization;
namespace Enmarcha.Sources.OpenTripPlannerGql.Queries;
-public class StopTileRequestContent : IGraphRequest<StopTileRequestContent.Bbox>
+public class StopTileRequestContent : IGraphRequest<StopTileRequestContent.TileRequestParams>
{
- public record Bbox(double MinLon, double MinLat, double MaxLon, double MaxLat);
+ public record TileRequestParams(
+ double MinLon,
+ double MinLat,
+ double MaxLon,
+ double MaxLat,
+ string[]? Feeds = null
+ );
- public static string Query(Bbox bbox)
+ public static string Query(TileRequestParams req)
{
+ var feedsFilter = req.Feeds != null && req.Feeds.Length > 0
+ ? $"feeds: [{string.Join(", ", req.Feeds.Select(f => $"\"{f}\""))}]"
+ : string.Empty;
+
return string.Create(CultureInfo.InvariantCulture, $@"
query Query {{
stopsByBbox(
- minLat: {bbox.MinLat:F6}
- minLon: {bbox.MinLon:F6}
- maxLon: {bbox.MaxLon:F6}
- maxLat: {bbox.MaxLat:F6}
+ minLat: {req.MinLat:F6}
+ minLon: {req.MinLon:F6}
+ maxLon: {req.MaxLon:F6}
+ maxLat: {req.MaxLat:F6}
+ {feedsFilter}
) {{
gtfsId
code
diff --git a/src/frontend/public/maps/styles/openfreemap-light.json b/src/frontend/public/maps/styles/openfreemap-light.json
index 18053f6..5598237 100644
--- a/src/frontend/public/maps/styles/openfreemap-light.json
+++ b/src/frontend/public/maps/styles/openfreemap-light.json
@@ -4,10 +4,6 @@
"openmaptiles": {
"type": "vector",
"url": "https://enmarcha.app/ofm/planet"
- },
- "vigo_traffic": {
- "type": "geojson",
- "data": "/api/traffic"
}
},
"sprite": "https://enmarcha.app/ofm/sprites/ofm_f384/ofm",
@@ -6943,64 +6939,6 @@
"text-halo-color": "#fff",
"text-halo-width": 1
}
- },
- {
- "id": "vigo_traffic",
- "type": "line",
- "source": "vigo_traffic",
- "layout": {},
- "paint": {
- "line-opacity": [
- "interpolate",
- [
- "linear"
- ],
- [
- "zoom"
- ],
- 0,
- 1,
- 14,
- 1,
- 16,
- 0.8,
- 18,
- 0.6,
- 22,
- 0.6
- ],
- "line-color": [
- "match",
- [
- "get",
- "style"
- ],
- "#CONGESTION",
- "hsl(70.7 100% 38%)",
- "#MUYDENSO",
- "hsl(36.49 100% 50%)",
- "#DENSO",
- "hsl(47.61 100% 49%)",
- "#FLUIDO",
- "hsl(83.9 100% 40%)",
- "#MUYFLUIDO",
- "hsl(161.25 100% 42%)",
- "hsl(0.0 0% 0%)"
- ],
- "line-width": [
- "interpolate",
- [
- "linear"
- ],
- [
- "zoom"
- ],
- 14,
- 2,
- 18,
- 4
- ]
- }
}
]
}
diff --git a/src/frontend/vite.config.ts b/src/frontend/vite.config.ts
index 042177d..1b0a6ad 100644
--- a/src/frontend/vite.config.ts
+++ b/src/frontend/vite.config.ts
@@ -14,7 +14,7 @@ export default defineConfig({
plugins: [reactRouter(), tsconfigPaths(), tailwindcss()],
server: {
proxy: {
- "^/api": {
+ "^/(api|backoffice)": {
target: "https://localhost:7240",
secure: false,
},