diff options
| author | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-19 18:56:34 +0100 |
|---|---|---|
| committer | Ariel Costas Guerrero <ariel@costas.dev> | 2026-03-19 18:56:34 +0100 |
| commit | bee85bf92aab84087798ffa9f3f16336acef2fce (patch) | |
| tree | 4fc8e2907e6618940cd9bdeb3da1a81172aab459 | |
| parent | fed5d57b9e5d3df7c34bccb7a120bfa274b2039a (diff) | |
Basic backoffice for alert management
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, '&').replace(/</g, '<').replace(/>/g, '>'); + } + + // ── 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, }, |
