From 92ee37d719af93844f9d08664e62f507875cee62 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 09:26:50 +0000 Subject: [PATCH 01/10] feat: add admin station merge feature - Add StationMergeIgnore model and EF migration - Add StationMergeController with countries/pairs/merge/skip endpoints - Add DTOs: StationNearbyPairDTO, StationMergeRequestDTO, StationMergeSkipDTO, StationMergeCountryDTO - Add Angular model stationMerge.model.ts - Add API methods in api.service.ts - Add administrator-station-merge component (queue-based UI per country) - Add route /administrator/station-merge - Add 'Merge Stations' nav link in administrator layout Agent-Logs-Url: https://github.com/jjasloot/OVDB/sessions/30de4683-4e00-4644-a15a-81e73ed4e9ff Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- OVDB_database/Database/OVDBDatabaseContext.cs | 14 + ...02092407_AddStationMergeIgnore.Designer.cs | 1340 +++++++++++++++++ .../20260502092407_AddStationMergeIgnore.cs | 60 + .../OVDBDatabaseContextModelSnapshot.cs | 43 + OVDB_database/Models/StationMergeIgnore.cs | 16 + OV_DB/Controllers/StationMergeController.cs | 241 +++ OV_DB/Models/StationMergeCountryDTO.cs | 8 + OV_DB/Models/StationMergeRequestDTO.cs | 8 + OV_DB/Models/StationMergeSkipDTO.cs | 8 + OV_DB/Models/StationNearbyPairDTO.cs | 19 + .../administrator-layout.component.ts | 5 + ...administrator-station-merge.component.html | 119 ++ ...administrator-station-merge.component.scss | 116 ++ .../administrator-station-merge.component.ts | 164 ++ OV_DB/OVDBFrontend/src/app/app.routes.ts | 1 + .../src/app/models/stationMerge.model.ts | 23 + .../src/app/services/api.service.ts | 31 + 17 files changed, 2216 insertions(+) create mode 100644 OVDB_database/Migrations/20260502092407_AddStationMergeIgnore.Designer.cs create mode 100644 OVDB_database/Migrations/20260502092407_AddStationMergeIgnore.cs create mode 100644 OVDB_database/Models/StationMergeIgnore.cs create mode 100644 OV_DB/Controllers/StationMergeController.cs create mode 100644 OV_DB/Models/StationMergeCountryDTO.cs create mode 100644 OV_DB/Models/StationMergeRequestDTO.cs create mode 100644 OV_DB/Models/StationMergeSkipDTO.cs create mode 100644 OV_DB/Models/StationNearbyPairDTO.cs create mode 100644 OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html create mode 100644 OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.scss create mode 100644 OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.ts create mode 100644 OV_DB/OVDBFrontend/src/app/models/stationMerge.model.ts diff --git a/OVDB_database/Database/OVDBDatabaseContext.cs b/OVDB_database/Database/OVDBDatabaseContext.cs index cd374abe..7cf36811 100644 --- a/OVDB_database/Database/OVDBDatabaseContext.cs +++ b/OVDB_database/Database/OVDBDatabaseContext.cs @@ -36,6 +36,7 @@ public OVDBDatabaseContext(DbContextOptions dbOptions) : ba public DbSet TrawellingIgnoredStatuses { get; set; } public DbSet TrawellingStations { get; set; } public DbSet RefreshTokens { get; set; } + public DbSet StationMergeIgnores { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder options) { @@ -126,6 +127,19 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasIndex(e => new { e.RouteInstanceId, e.Key }); }); + // StationMergeIgnore entity configuration + modelBuilder.Entity(entity => + { + entity.HasIndex(e => new { e.Station1Id, e.Station2Id }).IsUnique(); + entity.HasOne(e => e.Station1) + .WithMany() + .HasForeignKey(e => e.Station1Id) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.Station2) + .WithMany() + .HasForeignKey(e => e.Station2Id) + .OnDelete(DeleteBehavior.Cascade); + }); } diff --git a/OVDB_database/Migrations/20260502092407_AddStationMergeIgnore.Designer.cs b/OVDB_database/Migrations/20260502092407_AddStationMergeIgnore.Designer.cs new file mode 100644 index 00000000..d9d81162 --- /dev/null +++ b/OVDB_database/Migrations/20260502092407_AddStationMergeIgnore.Designer.cs @@ -0,0 +1,1340 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using OVDB_database.Database; + +#nullable disable + +namespace OVDB_database.Migrations +{ + [DbContext(typeof(OVDBDatabaseContext))] + [Migration("20260502092407_AddStationMergeIgnore")] + partial class AddStationMergeIgnore + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("OVDB_database.Models.InviteCode", b => + { + b.Property("InviteCodeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("InviteCodeId")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("varchar(50)"); + + b.Property("CreatedByUserId") + .HasColumnType("int"); + + b.Property("DoesNotExpire") + .HasColumnType("tinyint(1)"); + + b.Property("IsUsed") + .HasColumnType("tinyint(1)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("InviteCodeId"); + + b.HasIndex("Code") + .IsUnique(); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("UserId"); + + b.ToTable("InviteCodes"); + }); + + modelBuilder.Entity("OVDB_database.Models.Map", b => + { + b.Property("MapId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("MapId")); + + b.Property("Completed") + .HasColumnType("tinyint(1)"); + + b.Property("Default") + .HasColumnType("tinyint(1)"); + + b.Property("MapGuid") + .HasColumnType("char(36)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("NameNL") + .HasColumnType("longtext"); + + b.Property("OrderNr") + .HasColumnType("int"); + + b.Property("SharingLinkName") + .HasColumnType("longtext"); + + b.Property("ShowRouteInfo") + .HasColumnType("tinyint(1)"); + + b.Property("ShowRouteOutline") + .HasColumnType("tinyint(1)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("MapId"); + + b.HasIndex("MapGuid") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Maps"); + }); + + modelBuilder.Entity("OVDB_database.Models.Operator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("LogoContentType") + .HasColumnType("longtext"); + + b.Property("LogoFilePath") + .HasColumnType("longtext"); + + b.Property("Names") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("Operators"); + }); + + modelBuilder.Entity("OVDB_database.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceInfo") + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRevoked") + .HasColumnType("tinyint(1)"); + + b.Property("LastUsedAt") + .HasColumnType("datetime(6)"); + + b.Property("RevokedAt") + .HasColumnType("datetime(6)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ExpiresAt"); + + b.HasIndex("IsRevoked"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("OVDB_database.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("FlagEmoji") + .HasColumnType("longtext"); + + b.Property("Geometry") + .HasColumnType("multipolygon"); + + b.Property("IsoCode") + .HasColumnType("longtext"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("NameNL") + .HasColumnType("longtext"); + + b.Property("OriginalName") + .HasColumnType("longtext"); + + b.Property("OsmRelationId") + .HasColumnType("bigint"); + + b.Property("ParentRegionId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("ParentRegionId"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("OVDB_database.Models.Request", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("datetime(6)"); + + b.Property("Message") + .HasColumnType("longtext"); + + b.Property("Read") + .HasColumnType("tinyint(1)"); + + b.Property("Responded") + .HasColumnType("datetime(6)"); + + b.Property("Response") + .HasColumnType("longtext"); + + b.Property("ResponseRead") + .HasColumnType("tinyint(1)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Requests"); + }); + + modelBuilder.Entity("OVDB_database.Models.Route", b => + { + b.Property("RouteId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RouteId")); + + b.Property("CalculatedDistance") + .HasColumnType("double"); + + b.Property("Description") + .HasColumnType("longtext"); + + b.Property("DescriptionNL") + .HasColumnType("longtext"); + + b.Property("FirstDateTime") + .HasColumnType("datetime(6)"); + + b.Property("From") + .HasColumnType("longtext"); + + b.Property("LineNumber") + .HasColumnType("longtext"); + + b.Property("LineString") + .HasColumnType("linestring"); + + b.Property("Name") + .IsRequired() + .HasColumnType("varchar(255)"); + + b.Property("NameNL") + .HasColumnType("longtext"); + + b.Property("OperatingCompany") + .HasColumnType("longtext"); + + b.Property("OverrideColour") + .HasColumnType("longtext"); + + b.Property("OverrideDistance") + .HasColumnType("double"); + + b.Property("RouteTypeId") + .HasColumnType("int"); + + b.Property("Share") + .HasColumnType("char(36)"); + + b.Property("To") + .HasColumnType("longtext"); + + b.Property("TrainlogType") + .HasColumnType("longtext"); + + b.HasKey("RouteId"); + + b.HasIndex("Name"); + + b.HasIndex("RouteTypeId"); + + b.HasIndex("Share"); + + b.ToTable("Routes"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteInstance", b => + { + b.Property("RouteInstanceId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RouteInstanceId")); + + b.Property("Date") + .HasColumnType("datetime(6)"); + + b.Property("DurationHours") + .HasColumnType("double"); + + b.Property("EndTime") + .HasColumnType("datetime(6)"); + + b.Property("RouteId") + .HasColumnType("int"); + + b.Property("ScheduledEndTime") + .HasColumnType("datetime(6)"); + + b.Property("ScheduledStartTime") + .HasColumnType("datetime(6)"); + + b.Property("StartTime") + .HasColumnType("datetime(6)"); + + b.Property("TrawellingStatusId") + .HasColumnType("int"); + + b.HasKey("RouteInstanceId"); + + b.HasIndex("Date"); + + b.HasIndex("TrawellingStatusId"); + + b.HasIndex(new[] { "Date", "RouteId" }, "idx_routeinstances_date_routeid"); + + b.HasIndex(new[] { "RouteId", "Date" }, "idx_routeinstances_routeid_date"); + + b.ToTable("RouteInstances"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteInstanceMap", b => + { + b.Property("RouteMapId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RouteMapId")); + + b.Property("MapId") + .HasColumnType("int"); + + b.Property("RouteInstanceId") + .HasColumnType("int"); + + b.HasKey("RouteMapId"); + + b.HasIndex("MapId"); + + b.HasIndex(new[] { "RouteInstanceId", "MapId" }, "idx_routeinstancemap_routeinstanceid_mapid"); + + b.ToTable("RouteInstanceMap"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteInstanceProperty", b => + { + b.Property("RouteInstancePropertyId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RouteInstancePropertyId")); + + b.Property("Bool") + .HasColumnType("tinyint(1)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("RouteInstanceId") + .HasColumnType("int"); + + b.Property("Value") + .HasColumnType("longtext"); + + b.HasKey("RouteInstancePropertyId"); + + b.HasIndex("RouteInstanceId", "Key"); + + b.ToTable("RouteInstanceProperties"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteMap", b => + { + b.Property("RouteMapId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("RouteMapId")); + + b.Property("MapId") + .HasColumnType("int"); + + b.Property("RouteId") + .HasColumnType("int"); + + b.HasKey("RouteMapId"); + + b.HasIndex("MapId"); + + b.HasIndex("RouteId"); + + b.ToTable("RoutesMaps"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteType", b => + { + b.Property("TypeId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("TypeId")); + + b.Property("Colour") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsTrain") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("NameNL") + .HasColumnType("longtext"); + + b.Property("OrderNr") + .HasColumnType("int"); + + b.Property("TrainlogType") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("TypeId"); + + b.HasIndex("UserId"); + + b.ToTable("RouteTypes"); + }); + + modelBuilder.Entity("OVDB_database.Models.Station", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Elevation") + .HasColumnType("double"); + + b.Property("Hidden") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("Lattitude") + .HasColumnType("double"); + + b.Property("Longitude") + .HasColumnType("double"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("Network") + .HasColumnType("longtext"); + + b.Property("Operator") + .HasColumnType("longtext"); + + b.Property("OsmId") + .HasColumnType("bigint"); + + b.Property("Special") + .ValueGeneratedOnAdd() + .HasColumnType("tinyint(1)") + .HasDefaultValue(false); + + b.Property("StationCountryId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("OsmId"); + + b.HasIndex("StationCountryId"); + + b.ToTable("Stations"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationCountry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("NameNL") + .HasColumnType("longtext"); + + b.Property("OsmId") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("StationCountries"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationGrouping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("MapGuid") + .HasColumnType("char(36)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("NameNL") + .HasColumnType("longtext"); + + b.Property("OrderNr") + .HasColumnType("int"); + + b.Property("SharingLinkName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("MapGuid") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("StationGroupings"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationMap", b => + { + b.Property("StationMapId") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("StationMapId")); + + b.Property("MapGuid") + .HasColumnType("char(36)"); + + b.Property("Name") + .HasColumnType("longtext"); + + b.Property("NameNL") + .HasColumnType("longtext"); + + b.Property("OrderNr") + .HasColumnType("int"); + + b.Property("SharingLinkName") + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("StationMapId"); + + b.HasIndex("MapGuid") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("StationMaps"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationMapCountry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IncludeSpecials") + .HasColumnType("tinyint(1)"); + + b.Property("StationCountryId") + .HasColumnType("int"); + + b.Property("StationMapId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("StationCountryId"); + + b.HasIndex("StationMapId"); + + b.ToTable("StationMapCountries"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationMergeIgnore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Station1Id") + .HasColumnType("int"); + + b.Property("Station2Id") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Station2Id"); + + b.HasIndex("Station1Id", "Station2Id") + .IsUnique(); + + b.ToTable("StationMergeIgnores"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationVisit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("StationId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("StationId", "UserId") + .IsUnique(); + + b.ToTable("StationVisits"); + }); + + modelBuilder.Entity("OVDB_database.Models.TrawellingIgnoredStatus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("IgnoredAt") + .HasColumnType("datetime(6)"); + + b.Property("TrawellingStatusId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "TrawellingStatusId") + .IsUnique(); + + b.ToTable("TrawellingIgnoredStatuses"); + }); + + modelBuilder.Entity("OVDB_database.Models.TrawellingStation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasColumnName("id"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("created_at"); + + b.Property("Ibnr") + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("ibnr"); + + b.Property("Latitude") + .HasColumnType("double") + .HasColumnName("latitude"); + + b.Property("Longitude") + .HasColumnType("double") + .HasColumnName("longitude"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("varchar(255)") + .HasColumnName("name"); + + b.Property("RilIdentifier") + .HasMaxLength(50) + .HasColumnType("varchar(50)") + .HasColumnName("ril_identifier"); + + b.Property("TrawellingId") + .HasColumnType("int") + .HasColumnName("traewelling_id"); + + b.Property("UpdatedAt") + .HasColumnType("datetime(6)") + .HasColumnName("updated_at"); + + b.HasKey("Id"); + + b.HasIndex("TrawellingId") + .IsUnique(); + + b.ToTable("trawelling_stations"); + }); + + modelBuilder.Entity("OVDB_database.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("varchar(256)"); + + b.Property("EnableTrainlogExport") + .HasColumnType("tinyint(1)"); + + b.Property("Guid") + .HasColumnType("char(36)"); + + b.Property("IsAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("LastLogin") + .HasColumnType("datetime(6)"); + + b.Property("Password") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("varchar(512)"); + + b.Property("PreferredLanguage") + .HasColumnType("int"); + + b.Property("RefreshToken") + .HasColumnType("longtext"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TraewellingTagMappings") + .HasColumnType("longtext"); + + b.Property("TrainlogMaterialKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("TrainlogOperatorMappings") + .HasColumnType("longtext"); + + b.Property("TrainlogRegistrationKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("TrainlogSeatKey") + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.Property("TrawellingAccessToken") + .HasColumnType("longtext"); + + b.Property("TrawellingRefreshToken") + .HasColumnType("longtext"); + + b.Property("TrawellingTokenExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("TrawellingUsername") + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Guid") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique() + .HasFilter("[TelegramUserId] IS NOT NULL"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("OperatorRegion", b => + { + b.Property("OperatorsRunningTrainsId") + .HasColumnType("int"); + + b.Property("RunsTrainsInRegionsId") + .HasColumnType("int"); + + b.HasKey("OperatorsRunningTrainsId", "RunsTrainsInRegionsId"); + + b.HasIndex("RunsTrainsInRegionsId"); + + b.ToTable("OperatorRegion"); + }); + + modelBuilder.Entity("OperatorRegion1", b => + { + b.Property("OperatorsRestrictedToRegionId") + .HasColumnType("int"); + + b.Property("RestrictToRegionsId") + .HasColumnType("int"); + + b.HasKey("OperatorsRestrictedToRegionId", "RestrictToRegionsId"); + + b.HasIndex("RestrictToRegionsId"); + + b.ToTable("OperatorRegion1"); + }); + + modelBuilder.Entity("OperatorRoute", b => + { + b.Property("OperatorsId") + .HasColumnType("int"); + + b.Property("RoutesRouteId") + .HasColumnType("int"); + + b.HasKey("OperatorsId", "RoutesRouteId"); + + b.HasIndex("RoutesRouteId"); + + b.ToTable("OperatorRoute"); + }); + + modelBuilder.Entity("RegionRoute", b => + { + b.Property("RegionsId") + .HasColumnType("int"); + + b.Property("RoutesRouteId") + .HasColumnType("int"); + + b.HasKey("RegionsId", "RoutesRouteId"); + + b.HasIndex("RoutesRouteId"); + + b.ToTable("RegionRoute"); + }); + + modelBuilder.Entity("RegionStation", b => + { + b.Property("RegionsId") + .HasColumnType("int"); + + b.Property("StationsId") + .HasColumnType("int"); + + b.HasKey("RegionsId", "StationsId"); + + b.HasIndex("StationsId"); + + b.ToTable("RegionStation"); + }); + + modelBuilder.Entity("RegionStationGrouping", b => + { + b.Property("RegionsId") + .HasColumnType("int"); + + b.Property("StationGroupingsId") + .HasColumnType("int"); + + b.HasKey("RegionsId", "StationGroupingsId"); + + b.HasIndex("StationGroupingsId"); + + b.ToTable("RegionStationGrouping"); + }); + + modelBuilder.Entity("OVDB_database.Models.InviteCode", b => + { + b.HasOne("OVDB_database.Models.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("OVDB_database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatedByUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OVDB_database.Models.Map", b => + { + b.HasOne("OVDB_database.Models.User", "User") + .WithMany("Maps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OVDB_database.Models.RefreshToken", b => + { + b.HasOne("OVDB_database.Models.User", "User") + .WithMany("RefreshTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OVDB_database.Models.Region", b => + { + b.HasOne("OVDB_database.Models.Region", "ParentRegion") + .WithMany("SubRegions") + .HasForeignKey("ParentRegionId"); + + b.Navigation("ParentRegion"); + }); + + modelBuilder.Entity("OVDB_database.Models.Request", b => + { + b.HasOne("OVDB_database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OVDB_database.Models.Route", b => + { + b.HasOne("OVDB_database.Models.RouteType", "RouteType") + .WithMany("Routes") + .HasForeignKey("RouteTypeId"); + + b.Navigation("RouteType"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteInstance", b => + { + b.HasOne("OVDB_database.Models.Route", "Route") + .WithMany("RouteInstances") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteInstanceMap", b => + { + b.HasOne("OVDB_database.Models.Map", "Map") + .WithMany() + .HasForeignKey("MapId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.RouteInstance", "RouteInstance") + .WithMany("RouteInstanceMaps") + .HasForeignKey("RouteInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Map"); + + b.Navigation("RouteInstance"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteInstanceProperty", b => + { + b.HasOne("OVDB_database.Models.RouteInstance", "RouteInstance") + .WithMany("RouteInstanceProperties") + .HasForeignKey("RouteInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RouteInstance"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteMap", b => + { + b.HasOne("OVDB_database.Models.Map", "Map") + .WithMany("RouteMaps") + .HasForeignKey("MapId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.Route", "Route") + .WithMany("RouteMaps") + .HasForeignKey("RouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Map"); + + b.Navigation("Route"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteType", b => + { + b.HasOne("OVDB_database.Models.User", null) + .WithMany("RouteTypes") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OVDB_database.Models.Station", b => + { + b.HasOne("OVDB_database.Models.StationCountry", "StationCountry") + .WithMany("Stations") + .HasForeignKey("StationCountryId"); + + b.Navigation("StationCountry"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationGrouping", b => + { + b.HasOne("OVDB_database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationMap", b => + { + b.HasOne("OVDB_database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationMapCountry", b => + { + b.HasOne("OVDB_database.Models.StationCountry", "StationCountry") + .WithMany("StationMapCountries") + .HasForeignKey("StationCountryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.StationMap", "StationMap") + .WithMany("StationMapCountries") + .HasForeignKey("StationMapId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("StationCountry"); + + b.Navigation("StationMap"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationMergeIgnore", b => + { + b.HasOne("OVDB_database.Models.Station", "Station1") + .WithMany() + .HasForeignKey("Station1Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.Station", "Station2") + .WithMany() + .HasForeignKey("Station2Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Station1"); + + b.Navigation("Station2"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationVisit", b => + { + b.HasOne("OVDB_database.Models.Station", "Station") + .WithMany("StationVisits") + .HasForeignKey("StationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Station"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OVDB_database.Models.TrawellingIgnoredStatus", b => + { + b.HasOne("OVDB_database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OperatorRegion", b => + { + b.HasOne("OVDB_database.Models.Operator", null) + .WithMany() + .HasForeignKey("OperatorsRunningTrainsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.Region", null) + .WithMany() + .HasForeignKey("RunsTrainsInRegionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OperatorRegion1", b => + { + b.HasOne("OVDB_database.Models.Operator", null) + .WithMany() + .HasForeignKey("OperatorsRestrictedToRegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.Region", null) + .WithMany() + .HasForeignKey("RestrictToRegionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OperatorRoute", b => + { + b.HasOne("OVDB_database.Models.Operator", null) + .WithMany() + .HasForeignKey("OperatorsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.Route", null) + .WithMany() + .HasForeignKey("RoutesRouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RegionRoute", b => + { + b.HasOne("OVDB_database.Models.Region", null) + .WithMany() + .HasForeignKey("RegionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.Route", null) + .WithMany() + .HasForeignKey("RoutesRouteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RegionStation", b => + { + b.HasOne("OVDB_database.Models.Region", null) + .WithMany() + .HasForeignKey("RegionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.Station", null) + .WithMany() + .HasForeignKey("StationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("RegionStationGrouping", b => + { + b.HasOne("OVDB_database.Models.Region", null) + .WithMany() + .HasForeignKey("RegionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.StationGrouping", null) + .WithMany() + .HasForeignKey("StationGroupingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OVDB_database.Models.Map", b => + { + b.Navigation("RouteMaps"); + }); + + modelBuilder.Entity("OVDB_database.Models.Region", b => + { + b.Navigation("SubRegions"); + }); + + modelBuilder.Entity("OVDB_database.Models.Route", b => + { + b.Navigation("RouteInstances"); + + b.Navigation("RouteMaps"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteInstance", b => + { + b.Navigation("RouteInstanceMaps"); + + b.Navigation("RouteInstanceProperties"); + }); + + modelBuilder.Entity("OVDB_database.Models.RouteType", b => + { + b.Navigation("Routes"); + }); + + modelBuilder.Entity("OVDB_database.Models.Station", b => + { + b.Navigation("StationVisits"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationCountry", b => + { + b.Navigation("StationMapCountries"); + + b.Navigation("Stations"); + }); + + modelBuilder.Entity("OVDB_database.Models.StationMap", b => + { + b.Navigation("StationMapCountries"); + }); + + modelBuilder.Entity("OVDB_database.Models.User", b => + { + b.Navigation("Maps"); + + b.Navigation("RefreshTokens"); + + b.Navigation("RouteTypes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OVDB_database/Migrations/20260502092407_AddStationMergeIgnore.cs b/OVDB_database/Migrations/20260502092407_AddStationMergeIgnore.cs new file mode 100644 index 00000000..8fb6f4cc --- /dev/null +++ b/OVDB_database/Migrations/20260502092407_AddStationMergeIgnore.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OVDB_database.Migrations +{ + /// + public partial class AddStationMergeIgnore : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StationMergeIgnores", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn), + Station1Id = table.Column(type: "int", nullable: false), + Station2Id = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StationMergeIgnores", x => x.Id); + table.ForeignKey( + name: "FK_StationMergeIgnores_Stations_Station1Id", + column: x => x.Station1Id, + principalTable: "Stations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_StationMergeIgnores_Stations_Station2Id", + column: x => x.Station2Id, + principalTable: "Stations", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_StationMergeIgnores_Station1Id_Station2Id", + table: "StationMergeIgnores", + columns: new[] { "Station1Id", "Station2Id" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StationMergeIgnores_Station2Id", + table: "StationMergeIgnores", + column: "Station2Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StationMergeIgnores"); + } + } +} diff --git a/OVDB_database/Migrations/OVDBDatabaseContextModelSnapshot.cs b/OVDB_database/Migrations/OVDBDatabaseContextModelSnapshot.cs index 961f6841..dc788632 100644 --- a/OVDB_database/Migrations/OVDBDatabaseContextModelSnapshot.cs +++ b/OVDB_database/Migrations/OVDBDatabaseContextModelSnapshot.cs @@ -651,6 +651,30 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("StationMapCountries"); }); + modelBuilder.Entity("OVDB_database.Models.StationMergeIgnore", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + MySqlPropertyBuilderExtensions.UseMySqlIdentityColumn(b.Property("Id")); + + b.Property("Station1Id") + .HasColumnType("int"); + + b.Property("Station2Id") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Station2Id"); + + b.HasIndex("Station1Id", "Station2Id") + .IsUnique(); + + b.ToTable("StationMergeIgnores"); + }); + modelBuilder.Entity("OVDB_database.Models.StationVisit", b => { b.Property("Id") @@ -1114,6 +1138,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("StationMap"); }); + modelBuilder.Entity("OVDB_database.Models.StationMergeIgnore", b => + { + b.HasOne("OVDB_database.Models.Station", "Station1") + .WithMany() + .HasForeignKey("Station1Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OVDB_database.Models.Station", "Station2") + .WithMany() + .HasForeignKey("Station2Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Station1"); + + b.Navigation("Station2"); + }); + modelBuilder.Entity("OVDB_database.Models.StationVisit", b => { b.HasOne("OVDB_database.Models.Station", "Station") diff --git a/OVDB_database/Models/StationMergeIgnore.cs b/OVDB_database/Models/StationMergeIgnore.cs new file mode 100644 index 00000000..ccf66517 --- /dev/null +++ b/OVDB_database/Models/StationMergeIgnore.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; + +namespace OVDB_database.Models +{ + [Index(nameof(Station1Id), nameof(Station2Id), IsUnique = true)] + public class StationMergeIgnore + { + [Key] + public long Id { get; set; } + public int Station1Id { get; set; } + public Station Station1 { get; set; } + public int Station2Id { get; set; } + public Station Station2 { get; set; } + } +} diff --git a/OV_DB/Controllers/StationMergeController.cs b/OV_DB/Controllers/StationMergeController.cs new file mode 100644 index 00000000..babc9f77 --- /dev/null +++ b/OV_DB/Controllers/StationMergeController.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using OV_DB.Models; +using OVDB_database.Database; +using OVDB_database.Models; + +namespace OV_DB.Controllers +{ + [Route("api/[controller]")] + [ApiController] + [Authorize] + public class StationMergeController : ControllerBase + { + private OVDBDatabaseContext DbContext { get; } + + public StationMergeController(OVDBDatabaseContext dbContext) + { + DbContext = dbContext; + } + + private bool IsAdmin() => + string.Equals( + User.Claims.SingleOrDefault(c => c.Type == "admin")?.Value ?? "false", + "true", + StringComparison.OrdinalIgnoreCase); + + private static double HaversineDistance(double lat1, double lon1, double lat2, double lon2) + { + const double R = 6371000.0; + var dLat = (lat2 - lat1) * Math.PI / 180.0; + var dLon = (lon2 - lon1) * Math.PI / 180.0; + var a = Math.Sin(dLat / 2) * Math.Sin(dLat / 2) + + Math.Cos(lat1 * Math.PI / 180.0) * Math.Cos(lat2 * Math.PI / 180.0) + * Math.Sin(dLon / 2) * Math.Sin(dLon / 2); + var c = 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + return R * c; + } + + /// + /// Returns all station countries that have at least two non-hidden stations, to drive the country selector. + /// + [HttpGet("countries")] + public async Task GetCountries() + { + if (!IsAdmin()) return Forbid(); + + var countries = await DbContext.StationCountries + .Where(c => c.Stations.Any(s => !s.Hidden)) + .OrderBy(c => c.Name) + .Select(c => new StationMergeCountryDTO + { + CountryId = c.Id, + CountryName = c.Name + }) + .ToListAsync(); + + return Ok(countries); + } + + /// + /// Returns paginated pairs of nearby stations (within 200 m) for a given country + /// that have not yet been reviewed (not in the ignore list). + /// + [HttpGet("pairs/{countryId:int}")] + public async Task GetPairs(int countryId, [FromQuery] int page = 0, [FromQuery] int pageSize = 10) + { + if (!IsAdmin()) return Forbid(); + + const double MaxDistanceMeters = 200.0; + // 200 m ≈ 0.0018° latitude; use a slightly larger bounding box as pre-filter + const double BBoxDegrees = 0.003; + + var stations = await DbContext.Stations + .AsNoTracking() + .Where(s => s.StationCountryId == countryId && !s.Hidden) + .Select(s => new + { + s.Id, + s.Name, + s.Lattitude, + s.Longitude, + Visits = s.StationVisits.Count() + }) + .ToListAsync(); + + var ignoredPairs = await DbContext.StationMergeIgnores + .AsNoTracking() + .Where(i => DbContext.Stations + .Where(s => s.StationCountryId == countryId) + .Select(s => s.Id) + .Contains(i.Station1Id)) + .Select(i => new { i.Station1Id, i.Station2Id }) + .ToListAsync(); + + var ignoredSet = new HashSet<(int, int)>( + ignoredPairs.Select(i => (Math.Min(i.Station1Id, i.Station2Id), Math.Max(i.Station1Id, i.Station2Id)))); + + // Sort by latitude to allow early exit in inner loop + stations.Sort((a, b) => a.Lattitude.CompareTo(b.Lattitude)); + + var pairs = new List(); + for (int i = 0; i < stations.Count; i++) + { + for (int j = i + 1; j < stations.Count; j++) + { + // Early exit: if latitude difference exceeds bounding box, no more candidates + if (stations[j].Lattitude - stations[i].Lattitude > BBoxDegrees) + break; + + if (Math.Abs(stations[i].Longitude - stations[j].Longitude) > BBoxDegrees) + continue; + + var dist = HaversineDistance( + stations[i].Lattitude, stations[i].Longitude, + stations[j].Lattitude, stations[j].Longitude); + + if (dist >= MaxDistanceMeters) + continue; + + var id1 = Math.Min(stations[i].Id, stations[j].Id); + var id2 = Math.Max(stations[i].Id, stations[j].Id); + + if (ignoredSet.Contains((id1, id2))) + continue; + + pairs.Add(new StationNearbyPairDTO + { + Station1Id = stations[i].Id, + Station1Name = stations[i].Name, + Station1Lattitude = stations[i].Lattitude, + Station1Longitude = stations[i].Longitude, + Station1Visits = stations[i].Visits, + Station2Id = stations[j].Id, + Station2Name = stations[j].Name, + Station2Lattitude = stations[j].Lattitude, + Station2Longitude = stations[j].Longitude, + Station2Visits = stations[j].Visits, + DistanceMeters = Math.Round(dist, 1) + }); + } + } + + var total = pairs.Count; + var paged = pairs.Skip(page * pageSize).Take(pageSize).ToList(); + + return Ok(new { total, pairs = paged }); + } + + /// + /// Merges two stations: reassigns all StationVisits from the deleted station to the kept station + /// (skipping duplicates), then hides the deleted station. + /// + [HttpPost("merge")] + public async Task MergeStations([FromBody] StationMergeRequestDTO request) + { + if (!IsAdmin()) return Forbid(); + + if (request.KeepStationId == request.DeleteStationId) + return BadRequest("Keep and delete station IDs must be different."); + + var keepStation = await DbContext.Stations.SingleOrDefaultAsync(s => s.Id == request.KeepStationId); + var deleteStation = await DbContext.Stations + .Include(s => s.StationVisits) + .SingleOrDefaultAsync(s => s.Id == request.DeleteStationId); + + if (keepStation == null || deleteStation == null) + return NotFound(); + + // Reassign StationVisits that don't already exist on the keep station + var existingVisitorIds = await DbContext.StationVisits + .Where(sv => sv.StationId == request.KeepStationId) + .Select(sv => sv.UserId) + .ToHashSetAsync(); + + foreach (var visit in deleteStation.StationVisits) + { + if (!existingVisitorIds.Contains(visit.UserId)) + { + visit.StationId = request.KeepStationId; + } + else + { + DbContext.StationVisits.Remove(visit); + } + } + + // Hide the deleted station + deleteStation.Hidden = true; + + // Record the pair as reviewed so it won't show up again + var id1 = Math.Min(request.KeepStationId, request.DeleteStationId); + var id2 = Math.Max(request.KeepStationId, request.DeleteStationId); + var alreadyIgnored = await DbContext.StationMergeIgnores + .AnyAsync(i => i.Station1Id == id1 && i.Station2Id == id2); + if (!alreadyIgnored) + { + DbContext.StationMergeIgnores.Add(new StationMergeIgnore + { + Station1Id = id1, + Station2Id = id2 + }); + } + + await DbContext.SaveChangesAsync(); + + return Ok(new { message = "Stations merged successfully." }); + } + + /// + /// Marks a pair as "keep both" – the pair will no longer appear in the merge queue. + /// + [HttpPost("skip")] + public async Task SkipPair([FromBody] StationMergeSkipDTO request) + { + if (!IsAdmin()) return Forbid(); + + var id1 = Math.Min(request.Station1Id, request.Station2Id); + var id2 = Math.Max(request.Station1Id, request.Station2Id); + + var alreadyIgnored = await DbContext.StationMergeIgnores + .AnyAsync(i => i.Station1Id == id1 && i.Station2Id == id2); + + if (!alreadyIgnored) + { + DbContext.StationMergeIgnores.Add(new StationMergeIgnore + { + Station1Id = id1, + Station2Id = id2 + }); + await DbContext.SaveChangesAsync(); + } + + return Ok(new { message = "Pair skipped." }); + } + } +} diff --git a/OV_DB/Models/StationMergeCountryDTO.cs b/OV_DB/Models/StationMergeCountryDTO.cs new file mode 100644 index 00000000..c1e1c721 --- /dev/null +++ b/OV_DB/Models/StationMergeCountryDTO.cs @@ -0,0 +1,8 @@ +namespace OV_DB.Models +{ + public class StationMergeCountryDTO + { + public int CountryId { get; set; } + public string CountryName { get; set; } + } +} diff --git a/OV_DB/Models/StationMergeRequestDTO.cs b/OV_DB/Models/StationMergeRequestDTO.cs new file mode 100644 index 00000000..8b4b3f98 --- /dev/null +++ b/OV_DB/Models/StationMergeRequestDTO.cs @@ -0,0 +1,8 @@ +namespace OV_DB.Models +{ + public class StationMergeRequestDTO + { + public int KeepStationId { get; set; } + public int DeleteStationId { get; set; } + } +} diff --git a/OV_DB/Models/StationMergeSkipDTO.cs b/OV_DB/Models/StationMergeSkipDTO.cs new file mode 100644 index 00000000..322f05f6 --- /dev/null +++ b/OV_DB/Models/StationMergeSkipDTO.cs @@ -0,0 +1,8 @@ +namespace OV_DB.Models +{ + public class StationMergeSkipDTO + { + public int Station1Id { get; set; } + public int Station2Id { get; set; } + } +} diff --git a/OV_DB/Models/StationNearbyPairDTO.cs b/OV_DB/Models/StationNearbyPairDTO.cs new file mode 100644 index 00000000..86f3c793 --- /dev/null +++ b/OV_DB/Models/StationNearbyPairDTO.cs @@ -0,0 +1,19 @@ +namespace OV_DB.Models +{ + public class StationNearbyPairDTO + { + public int Station1Id { get; set; } + public string Station1Name { get; set; } + public double Station1Lattitude { get; set; } + public double Station1Longitude { get; set; } + public int Station1Visits { get; set; } + + public int Station2Id { get; set; } + public string Station2Name { get; set; } + public double Station2Lattitude { get; set; } + public double Station2Longitude { get; set; } + public int Station2Visits { get; set; } + + public double DistanceMeters { get; set; } + } +} diff --git a/OV_DB/OVDBFrontend/src/app/administrator/administrator-layout/administrator-layout.component.ts b/OV_DB/OVDBFrontend/src/app/administrator/administrator-layout/administrator-layout.component.ts index 699d5710..5cb38f4a 100644 --- a/OV_DB/OVDBFrontend/src/app/administrator/administrator-layout/administrator-layout.component.ts +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-layout/administrator-layout.component.ts @@ -56,6 +56,11 @@ export class AdministratorLayoutComponent implements OnInit { link: "/administrator/operators", index: 5, }, + { + label: "Merge Stations", + link: "/administrator/station-merge", + index: 6, + }, ]; } ngOnInit(): void { diff --git a/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html new file mode 100644 index 00000000..5efdff0b --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html @@ -0,0 +1,119 @@ + + + Merge Duplicate Stations + + +

+ This tool shows pairs of stations that are within 200 m of each other. + For each pair you can keep the left station, + keep the right station, or keep both + (the pair will no longer appear in this queue). +

+ + + @for (country of countries(); track country.countryId) { + {{ country.countryName }} + } + + + @if (loading()) { +
+ +
+ } + + @if (!loading() && selectedCountryId()) { +

+ {{ totalPairs() }} unreviewed pair(s) found. +

+ } + + @if (!loading() && pairs().length === 0 && selectedCountryId()) { +

No nearby duplicate stations found for this country. 🎉

+ } + + @for (pair of pairs(); track pair.station1Id + '-' + pair.station2Id) { +
+
+
{{ pair.station1Name || '(unnamed)' }}
+
+ Lat: {{ pair.station1Lattitude | number:'1.5-5' }}, + Lon: {{ pair.station1Longitude | number:'1.5-5' }} +
+
+ Visits: {{ pair.station1Visits }} +
+ + map View on OSM + +
+ +
+
+ +
+ merge +
{{ pair.distanceMeters | number:'1.0-0' }} m
+ +
+ +
+
{{ pair.station2Name || '(unnamed)' }}
+
+ Lat: {{ pair.station2Lattitude | number:'1.5-5' }}, + Lon: {{ pair.station2Longitude | number:'1.5-5' }} +
+
+ Visits: {{ pair.station2Visits }} +
+ + map View on OSM + +
+ +
+
+
+ } + + @if (!loading() && totalPairs() > pageSize) { + + } +
+
diff --git a/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.scss b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.scss new file mode 100644 index 00000000..c2d66eeb --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.scss @@ -0,0 +1,116 @@ +.description { + margin-bottom: 1rem; + color: rgba(0, 0, 0, 0.6); +} + +.country-select { + min-width: 240px; + margin-bottom: 1rem; +} + +.spinner-container { + display: flex; + justify-content: center; + padding: 2rem 0; +} + +.pair-count { + color: rgba(0, 0, 0, 0.6); + margin-bottom: 0.5rem; +} + +.no-pairs { + margin-top: 1rem; + font-style: italic; + color: rgba(0, 0, 0, 0.5); +} + +.pair-card { + display: flex; + align-items: flex-start; + gap: 1rem; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + background: #fafafa; +} + +.station-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; + + &.right { + align-items: flex-end; + text-align: right; + } +} + +.station-name { + font-weight: 500; + font-size: 1rem; +} + +.station-detail { + font-size: 0.85rem; + color: rgba(0, 0, 0, 0.6); +} + +.label { + font-weight: 500; +} + +.osm-link { + font-size: 0.8rem; + color: #1565c0; + text-decoration: none; + display: flex; + align-items: center; + gap: 0.2rem; + + .right & { + justify-content: flex-end; + } + + &:hover { + text-decoration: underline; + } +} + +.station-actions { + margin-top: 0.5rem; + + &.right-actions { + align-self: flex-end; + } +} + +.distance-col { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-width: 110px; +} + +.merge-icon { + font-size: 2rem; + color: rgba(0, 0, 0, 0.3); +} + +.distance { + font-size: 0.85rem; + font-weight: 500; + color: rgba(0, 0, 0, 0.6); +} + +.pagination { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + margin-top: 1rem; +} diff --git a/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.ts b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.ts new file mode 100644 index 00000000..f6df9a98 --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.ts @@ -0,0 +1,164 @@ +import { DecimalPipe } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + OnInit, + signal, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormsModule } from "@angular/forms"; +import { MatButton, MatIconButton } from "@angular/material/button"; +import { MatCard, MatCardContent, MatCardHeader, MatCardTitle } from "@angular/material/card"; +import { MatIcon } from "@angular/material/icon"; +import { MatOption } from "@angular/material/core"; +import { MatProgressSpinner } from "@angular/material/progress-spinner"; +import { MatSelect } from "@angular/material/select"; +import { MatTooltip } from "@angular/material/tooltip"; +import { StationMergeCountry, StationNearbyPair } from "src/app/models/stationMerge.model"; +import { ApiService } from "src/app/services/api.service"; + +@Component({ + selector: "app-administrator-station-merge", + templateUrl: "./administrator-station-merge.component.html", + styleUrls: ["./administrator-station-merge.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + MatButton, + MatIconButton, + MatCard, + MatCardContent, + MatCardHeader, + MatCardTitle, + MatIcon, + MatOption, + MatProgressSpinner, + MatSelect, + MatTooltip, + DecimalPipe, + ], +}) +export class AdministratorStationMergeComponent implements OnInit { + protected readonly Math = Math; + private apiService = inject(ApiService); + private destroyRef = inject(DestroyRef); + + countries = signal([]); + selectedCountryId = signal(null); + pairs = signal([]); + totalPairs = signal(0); + currentPage = signal(0); + pageSize = 10; + loading = signal(false); + actionInProgress = signal(false); + + ngOnInit(): void { + this.apiService + .getStationMergeCountries() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((data) => { + this.countries.set(data); + }); + } + + onCountryChange(countryId: number): void { + this.selectedCountryId.set(countryId); + this.currentPage.set(0); + this.loadPairs(); + } + + loadPairs(): void { + const countryId = this.selectedCountryId(); + if (!countryId) return; + this.loading.set(true); + this.apiService + .getStationMergePairs(countryId, this.currentPage(), this.pageSize) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (data) => { + this.pairs.set(data.pairs); + this.totalPairs.set(data.total); + this.loading.set(false); + }, + error: () => this.loading.set(false), + }); + } + + merge(keepId: number, deleteId: number): void { + this.actionInProgress.set(true); + this.apiService + .mergeStations(keepId, deleteId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.actionInProgress.set(false); + this.refreshAfterAction(); + }, + error: () => this.actionInProgress.set(false), + }); + } + + skip(pair: StationNearbyPair): void { + this.actionInProgress.set(true); + this.apiService + .skipStationPair(pair.station1Id, pair.station2Id) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + this.actionInProgress.set(false); + this.refreshAfterAction(); + }, + error: () => this.actionInProgress.set(false), + }); + } + + private refreshAfterAction(): void { + // After an action the total changes; stay on current page (or go back if empty) + const countryId = this.selectedCountryId(); + if (!countryId) return; + this.loading.set(true); + this.apiService + .getStationMergePairs(countryId, this.currentPage(), this.pageSize) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (data) => { + // If page is now empty and not the first page, go back one page + if (data.pairs.length === 0 && this.currentPage() > 0) { + this.currentPage.set(this.currentPage() - 1); + this.loadPairs(); + } else { + this.pairs.set(data.pairs); + this.totalPairs.set(data.total); + this.loading.set(false); + } + }, + error: () => this.loading.set(false), + }); + } + + nextPage(): void { + this.currentPage.set(this.currentPage() + 1); + this.loadPairs(); + } + + prevPage(): void { + if (this.currentPage() > 0) { + this.currentPage.set(this.currentPage() - 1); + this.loadPairs(); + } + } + + get hasPrevPage(): boolean { + return this.currentPage() > 0; + } + + get hasNextPage(): boolean { + return (this.currentPage() + 1) * this.pageSize < this.totalPairs(); + } + + openInOsm(lat: number, lon: number): string { + return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=17`; + } +} diff --git a/OV_DB/OVDBFrontend/src/app/app.routes.ts b/OV_DB/OVDBFrontend/src/app/app.routes.ts index 2eb367af..a72a6c1d 100644 --- a/OV_DB/OVDBFrontend/src/app/app.routes.ts +++ b/OV_DB/OVDBFrontend/src/app/app.routes.ts @@ -61,6 +61,7 @@ export const routes: Routes = [ { path: "regions", loadComponent: () => import("./administrator/administrator-regions/administrator-regions.component").then(m => m.AdministratorRegionsComponent) }, { path: "requests", loadComponent: () => import("./administrator/administrator-requests/administrator-requests.component").then(m => m.AdministratorRequestsComponent) }, { path: "operators", loadComponent: () => import("./administrator/administrator-operators/administrator-operators.component").then(m => m.AdministratorOperatorsComponent) }, + { path: "station-merge", loadComponent: () => import("./administrator/administrator-station-merge/administrator-station-merge.component").then(m => m.AdministratorStationMergeComponent) }, ], }, { path: "requests", loadComponent: () => import("./requests/requests-list/requests-list.component").then(m => m.RequestsListComponent), canActivate: [LoginGuard] }, diff --git a/OV_DB/OVDBFrontend/src/app/models/stationMerge.model.ts b/OV_DB/OVDBFrontend/src/app/models/stationMerge.model.ts new file mode 100644 index 00000000..d248d2de --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/models/stationMerge.model.ts @@ -0,0 +1,23 @@ +export interface StationNearbyPair { + station1Id: number; + station1Name: string; + station1Lattitude: number; + station1Longitude: number; + station1Visits: number; + station2Id: number; + station2Name: string; + station2Lattitude: number; + station2Longitude: number; + station2Visits: number; + distanceMeters: number; +} + +export interface StationMergeCountry { + countryId: number; + countryName: string; +} + +export interface StationMergePairsResponse { + total: number; + pairs: StationNearbyPair[]; +} diff --git a/OV_DB/OVDBFrontend/src/app/services/api.service.ts b/OV_DB/OVDBFrontend/src/app/services/api.service.ts index b4663a60..3f5b35a8 100644 --- a/OV_DB/OVDBFrontend/src/app/services/api.service.ts +++ b/OV_DB/OVDBFrontend/src/app/services/api.service.ts @@ -33,6 +33,7 @@ import { LinkToRouteInstanceResponse, TrawellingIgnoreResponse } from "../models/traewelling.model"; +import { StationMergeCountry, StationMergePairsResponse } from "../models/stationMerge.model"; @Injectable({ providedIn: "root", @@ -669,4 +670,34 @@ export class ApiService { {} ); } + + getStationMergeCountries(): Observable { + return this.httpClient.get( + environment.backend + "api/stationMerge/countries" + ); + } + + getStationMergePairs(countryId: number, page = 0, pageSize = 10): Observable { + const params = new HttpParams() + .set("page", page.toString()) + .set("pageSize", pageSize.toString()); + return this.httpClient.get( + environment.backend + "api/stationMerge/pairs/" + countryId, + { params } + ); + } + + mergeStations(keepStationId: number, deleteStationId: number): Observable { + return this.httpClient.post( + environment.backend + "api/stationMerge/merge", + { keepStationId, deleteStationId } + ); + } + + skipStationPair(station1Id: number, station2Id: number): Observable { + return this.httpClient.post( + environment.backend + "api/stationMerge/skip", + { station1Id, station2Id } + ); + } } From d633e5d4334cdaf7160f5be8738f9c8f569469ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 09:45:20 +0000 Subject: [PATCH 02/10] feat: redesign station merge UI with map, per-country counts, one-pair-at-a-time queue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add PairCount to StationMergeCountryDTO and compute it in GetCountries - Refactor GetCountries to load all stations once and compute pair counts efficiently - Extract CountNearbyPairs helper method to eliminate duplication - Redesign Angular component to show one pair at a time (queue approach) - Add Leaflet map with L (blue) and R (red) labeled markers per pair, auto-zoomed - Show per-country pair counts as red badges in country selector (✓ when done) - Refresh country counts after each merge/skip action - Add global styles for Leaflet custom marker icons Agent-Logs-Url: https://github.com/jjasloot/OVDB/sessions/5871d0a5-1781-42dc-9e62-dfe262617ce1 Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- OV_DB/Controllers/StationMergeController.cs | 124 ++++++++---- OV_DB/Models/StationMergeCountryDTO.cs | 1 + ...administrator-station-merge.component.html | 186 +++++++++++------- ...administrator-station-merge.component.scss | 159 ++++++++++----- .../administrator-station-merge.component.ts | 132 +++++++------ .../src/app/models/stationMerge.model.ts | 1 + OV_DB/OVDBFrontend/src/styles.scss | 29 +++ 7 files changed, 411 insertions(+), 221 deletions(-) diff --git a/OV_DB/Controllers/StationMergeController.cs b/OV_DB/Controllers/StationMergeController.cs index babc9f77..c65d3d8c 100644 --- a/OV_DB/Controllers/StationMergeController.cs +++ b/OV_DB/Controllers/StationMergeController.cs @@ -42,24 +42,96 @@ private static double HaversineDistance(double lat1, double lon1, double lat2, d } /// - /// Returns all station countries that have at least two non-hidden stations, to drive the country selector. + /// Counts nearby pairs (within maxDistanceMeters) that are not in the ignore set. + /// The stations list must be pre-sorted by latitude. + /// + private static int CountNearbyPairs( + IList<(int Id, double Lat, double Lon)> sortedStations, + HashSet<(int, int)> ignoredSet, + double maxDistanceMeters) + { + const double BBoxDegrees = 0.003; + int count = 0; + for (int i = 0; i < sortedStations.Count; i++) + { + for (int j = i + 1; j < sortedStations.Count; j++) + { + if (sortedStations[j].Lat - sortedStations[i].Lat > BBoxDegrees) break; + if (Math.Abs(sortedStations[i].Lon - sortedStations[j].Lon) > BBoxDegrees) continue; + + var dist = HaversineDistance( + sortedStations[i].Lat, sortedStations[i].Lon, + sortedStations[j].Lat, sortedStations[j].Lon); + + if (dist < maxDistanceMeters) + { + var id1 = Math.Min(sortedStations[i].Id, sortedStations[j].Id); + var id2 = Math.Max(sortedStations[i].Id, sortedStations[j].Id); + if (!ignoredSet.Contains((id1, id2))) + count++; + } + } + } + return count; + } + + /// + /// Returns all station countries with the number of unreviewed nearby pairs. /// [HttpGet("countries")] public async Task GetCountries() { if (!IsAdmin()) return Forbid(); + var allStations = await DbContext.Stations + .AsNoTracking() + .Where(s => !s.Hidden && s.StationCountryId.HasValue) + .Select(s => new { s.Id, s.Lattitude, s.Longitude, CountryId = s.StationCountryId.Value }) + .ToListAsync(); + + if (!allStations.Any()) + return Ok(new List()); + + var stationsByCountry = allStations + .GroupBy(s => s.CountryId) + .ToDictionary( + g => g.Key, + g => g.Select(s => (s.Id, s.Lattitude, s.Longitude)) + .OrderBy(s => s.Lattitude) + .ToList()); + + var allStationIds = allStations.Select(s => s.Id).ToHashSet(); + var allIgnoredPairs = await DbContext.StationMergeIgnores + .AsNoTracking() + .Where(i => allStationIds.Contains(i.Station1Id)) + .Select(i => new { i.Station1Id, i.Station2Id }) + .ToListAsync(); + + var ignoredSet = new HashSet<(int, int)>( + allIgnoredPairs.Select(i => (i.Station1Id, i.Station2Id))); + + var countryIds = stationsByCountry.Keys.ToHashSet(); var countries = await DbContext.StationCountries - .Where(c => c.Stations.Any(s => !s.Hidden)) + .AsNoTracking() + .Where(c => countryIds.Contains(c.Id)) .OrderBy(c => c.Name) - .Select(c => new StationMergeCountryDTO + .Select(c => new { c.Id, c.Name }) + .ToListAsync(); + + var result = countries.Select(c => + { + var stations = stationsByCountry.TryGetValue(c.Id, out var s) + ? s + : new List<(int, double, double)>(); + return new StationMergeCountryDTO { CountryId = c.Id, - CountryName = c.Name - }) - .ToListAsync(); + CountryName = c.Name, + PairCount = CountNearbyPairs(stations, ignoredSet, 200.0) + }; + }).ToList(); - return Ok(countries); + return Ok(result); } /// @@ -72,7 +144,6 @@ public async Task GetPairs(int countryId, [FromQuery] int page = if (!IsAdmin()) return Forbid(); const double MaxDistanceMeters = 200.0; - // 200 m ≈ 0.0018° latitude; use a slightly larger bounding box as pre-filter const double BBoxDegrees = 0.003; var stations = await DbContext.Stations @@ -98,9 +169,8 @@ public async Task GetPairs(int countryId, [FromQuery] int page = .ToListAsync(); var ignoredSet = new HashSet<(int, int)>( - ignoredPairs.Select(i => (Math.Min(i.Station1Id, i.Station2Id), Math.Max(i.Station1Id, i.Station2Id)))); + ignoredPairs.Select(i => (i.Station1Id, i.Station2Id))); - // Sort by latitude to allow early exit in inner loop stations.Sort((a, b) => a.Lattitude.CompareTo(b.Lattitude)); var pairs = new List(); @@ -108,25 +178,19 @@ public async Task GetPairs(int countryId, [FromQuery] int page = { for (int j = i + 1; j < stations.Count; j++) { - // Early exit: if latitude difference exceeds bounding box, no more candidates - if (stations[j].Lattitude - stations[i].Lattitude > BBoxDegrees) - break; - - if (Math.Abs(stations[i].Longitude - stations[j].Longitude) > BBoxDegrees) - continue; + if (stations[j].Lattitude - stations[i].Lattitude > BBoxDegrees) break; + if (Math.Abs(stations[i].Longitude - stations[j].Longitude) > BBoxDegrees) continue; var dist = HaversineDistance( stations[i].Lattitude, stations[i].Longitude, stations[j].Lattitude, stations[j].Longitude); - if (dist >= MaxDistanceMeters) - continue; + if (dist >= MaxDistanceMeters) continue; var id1 = Math.Min(stations[i].Id, stations[j].Id); var id2 = Math.Max(stations[i].Id, stations[j].Id); - if (ignoredSet.Contains((id1, id2))) - continue; + if (ignoredSet.Contains((id1, id2))) continue; pairs.Add(new StationNearbyPairDTO { @@ -171,7 +235,6 @@ public async Task MergeStations([FromBody] StationMergeRequestDTO if (keepStation == null || deleteStation == null) return NotFound(); - // Reassign StationVisits that don't already exist on the keep station var existingVisitorIds = await DbContext.StationVisits .Where(sv => sv.StationId == request.KeepStationId) .Select(sv => sv.UserId) @@ -180,34 +243,23 @@ public async Task MergeStations([FromBody] StationMergeRequestDTO foreach (var visit in deleteStation.StationVisits) { if (!existingVisitorIds.Contains(visit.UserId)) - { visit.StationId = request.KeepStationId; - } else - { DbContext.StationVisits.Remove(visit); - } } - // Hide the deleted station deleteStation.Hidden = true; - // Record the pair as reviewed so it won't show up again var id1 = Math.Min(request.KeepStationId, request.DeleteStationId); var id2 = Math.Max(request.KeepStationId, request.DeleteStationId); var alreadyIgnored = await DbContext.StationMergeIgnores .AnyAsync(i => i.Station1Id == id1 && i.Station2Id == id2); if (!alreadyIgnored) { - DbContext.StationMergeIgnores.Add(new StationMergeIgnore - { - Station1Id = id1, - Station2Id = id2 - }); + DbContext.StationMergeIgnores.Add(new StationMergeIgnore { Station1Id = id1, Station2Id = id2 }); } await DbContext.SaveChangesAsync(); - return Ok(new { message = "Stations merged successfully." }); } @@ -227,11 +279,7 @@ public async Task SkipPair([FromBody] StationMergeSkipDTO request if (!alreadyIgnored) { - DbContext.StationMergeIgnores.Add(new StationMergeIgnore - { - Station1Id = id1, - Station2Id = id2 - }); + DbContext.StationMergeIgnores.Add(new StationMergeIgnore { Station1Id = id1, Station2Id = id2 }); await DbContext.SaveChangesAsync(); } diff --git a/OV_DB/Models/StationMergeCountryDTO.cs b/OV_DB/Models/StationMergeCountryDTO.cs index c1e1c721..9a1cc493 100644 --- a/OV_DB/Models/StationMergeCountryDTO.cs +++ b/OV_DB/Models/StationMergeCountryDTO.cs @@ -4,5 +4,6 @@ public class StationMergeCountryDTO { public int CountryId { get; set; } public string CountryName { get; set; } + public int PairCount { get; set; } } } diff --git a/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html index 5efdff0b..d9bcf7e7 100644 --- a/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html @@ -4,116 +4,150 @@

- This tool shows pairs of stations that are within 200 m of each other. - For each pair you can keep the left station, - keep the right station, or keep both - (the pair will no longer appear in this queue). + Stations within 200 m of each other may be duplicates. Work through the queue + per country: Keep L or Keep R to merge them + (visits transfer to the kept station, the other is hidden), or + Keep Both if they are legitimately different stops.

- - @for (country of countries(); track country.countryId) { - {{ country.countryName }} + +
+ + @for (country of countries(); track country.countryId) { + + {{ country.countryName }} + @if (country.pairCount > 0) { + {{ country.pairCount }} + } @else { + + } + + } + + + @if (selectedCountryId()) { + + @if (loading()) { + + } @else if (totalPairs() > 0) { + {{ totalPairs() }} pair(s) remaining + } @else { + All done for this country 🎉 + } + } - +
@if (loading()) {
- +
} - @if (!loading() && selectedCountryId()) { -

- {{ totalPairs() }} unreviewed pair(s) found. -

- } - - @if (!loading() && pairs().length === 0 && selectedCountryId()) { -

No nearby duplicate stations found for this country. 🎉

- } + @if (!loading() && currentPair()) { + +
- @for (pair of pairs(); track pair.station1Id + '-' + pair.station2Id) { -
+ +
+
-
{{ pair.station1Name || '(unnamed)' }}
+
L
+
{{ currentPair().station1Name || '(unnamed)' }}
- Lat: {{ pair.station1Lattitude | number:'1.5-5' }}, - Lon: {{ pair.station1Longitude | number:'1.5-5' }} + {{ currentPair().station1Lattitude | number:'1.5-5' }}, + {{ currentPair().station1Longitude | number:'1.5-5' }}
-
- Visits: {{ pair.station1Visits }} +
+ people {{ currentPair().station1Visits }} visit(s)
- - map View on OSM + + map OSM -
- -
+
-
- merge -
{{ pair.distanceMeters | number:'1.0-0' }} m
+ +
+
+ swap_horiz + {{ currentPair().distanceMeters | number:'1.0-0' }} m +
+
-
{{ pair.station2Name || '(unnamed)' }}
+
R
+
{{ currentPair().station2Name || '(unnamed)' }}
- Lat: {{ pair.station2Lattitude | number:'1.5-5' }}, - Lon: {{ pair.station2Longitude | number:'1.5-5' }} + {{ currentPair().station2Lattitude | number:'1.5-5' }}, + {{ currentPair().station2Longitude | number:'1.5-5' }}
-
- Visits: {{ pair.station2Visits }} +
+ people {{ currentPair().station2Visits }} visit(s)
- - map View on OSM + + map OSM -
- -
+
} - @if (!loading() && totalPairs() > pageSize) { - - map OSM - + >OSM map
- {{ currentPair().station2Visits }} visit(s) people + people {{ currentPair().station2Visits }} visit(s)
@if (currentPair().station2Special) { @@ -143,7 +143,7 @@ (click)="toggleSpecial(currentPair().station2Id, currentPair().station2Special, 2)" matTooltip="Unmark as special/museum" >train - Museum / special museum + museum Museum / special } @else { - Regular train + train Regular }
OSM map + >map OSM
+ /// Station latitude in degrees. + /// + /// Longitude delta in degrees that corresponds to at + /// . Returns 180° near the poles to avoid division-by-zero. + /// private static double LonBBoxDegrees(double latDeg) { var cosLat = Math.Cos(latDeg * Math.PI / 180.0); @@ -189,6 +194,14 @@ private sealed record StationData(int Id, string Name, double Lat, double Lon, b /// Uses a per-station latitude-based longitude bounding box to prevent false negatives /// at high latitudes. /// + /// + /// Station list sorted ascending by . Sorting is required + /// for the early-exit latitude bounding-box optimisation. + /// + /// + /// Set of already-reviewed pairs as normalised (min-id, max-id) tuples that should be + /// excluded from the results. + /// private static IEnumerable EnumeratePairs( IList stations, HashSet<(int, int)> ignoredSet) From 53e98b41ba3849cc0e8573093f48ec0d5c646325 Mon Sep 17 00:00:00 2001 From: Jaap Date: Sat, 9 May 2026 23:50:25 +0200 Subject: [PATCH 07/10] Hide subregions if parent has no dupes --- OV_DB/Controllers/StationMergeController.cs | 22 ++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/OV_DB/Controllers/StationMergeController.cs b/OV_DB/Controllers/StationMergeController.cs index f06763fb..cf690b4f 100644 --- a/OV_DB/Controllers/StationMergeController.cs +++ b/OV_DB/Controllers/StationMergeController.cs @@ -168,21 +168,33 @@ public async Task GetRegions() r => r.Id, r => (r.Name, ParentId: r.ParentRegionId)); - var result = stationsByRegion.Keys + var result = stationsByRegion + .Keys .Select(rId => { var stations = stationsByRegion[rId]; - return new StationMergeCountryDTO + return new { RegionId = rId, RegionName = BuildRegionName(rId, regionDict), - PairCount = CountNearbyPairs(stations, ignoredSet) + PairCount = CountNearbyPairs(stations, ignoredSet), + ParentRegionId = regionDict[rId].ParentId }; }) - .OrderBy(r => r.RegionName) .ToList(); - return Ok(result); + + result = result.Where(r => !r.ParentRegionId.HasValue || result.Find(res => res.RegionId == r.ParentRegionId.Value)!.PairCount > 0).ToList(); + + + return Ok(result + .Select(r => new StationMergeCountryDTO + { + RegionId = r.RegionId, + RegionName = r.RegionName, + PairCount = r.PairCount + }).OrderBy(r => r.RegionName).ToList() + ); } // Typed station data used for in-memory pair generation From a8d5b69309541e4cf9e0b8dd89792ea2879dacc7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 21:59:47 +0000 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20rename=20StationMergeCountryDTO?= =?UTF-8?q?=E2=86=92RegionDTO=20and=20fix=20race=20conditions=20in=20merge?= =?UTF-8?q?/skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename StationMergeCountryDTO → StationMergeRegionDTO (C# model, controller references) - Rename StationMergeCountry → StationMergeRegion in TypeScript model, api.service.ts, and administrator-station-merge component - Fix race condition in MergeStations: remove AnyAsync pre-check; always add the ignore entry and catch DbUpdateException on duplicate key - Fix race condition in SkipPair: same pattern – attempt insert and catch DbUpdateException for idempotent behaviour under concurrent requests Agent-Logs-Url: https://github.com/jjasloot/OVDB/sessions/4812843b-fb7f-4aa0-af3e-ae22ac5a0e90 Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- OV_DB/Controllers/StationMergeController.cs | 33 ++++++++++++------- ...CountryDTO.cs => StationMergeRegionDTO.cs} | 2 +- .../administrator-station-merge.component.ts | 4 +-- .../src/app/models/stationMerge.model.ts | 2 +- .../src/app/services/api.service.ts | 6 ++-- 5 files changed, 29 insertions(+), 18 deletions(-) rename OV_DB/Models/{StationMergeCountryDTO.cs => StationMergeRegionDTO.cs} (81%) diff --git a/OV_DB/Controllers/StationMergeController.cs b/OV_DB/Controllers/StationMergeController.cs index cf690b4f..e7a79199 100644 --- a/OV_DB/Controllers/StationMergeController.cs +++ b/OV_DB/Controllers/StationMergeController.cs @@ -136,7 +136,7 @@ public async Task GetRegions() .ToListAsync(); if (!stationsWithRegions.Any()) - return Ok(new List()); + return Ok(new List()); var stationsByRegion = stationsWithRegions .GroupBy(x => x.RegionId) @@ -188,7 +188,7 @@ public async Task GetRegions() return Ok(result - .Select(r => new StationMergeCountryDTO + .Select(r => new StationMergeRegionDTO { RegionId = r.RegionId, RegionName = r.RegionName, @@ -352,14 +352,21 @@ public async Task MergeStations([FromBody] StationMergeRequestDTO var id1 = Math.Min(request.KeepStationId, request.DeleteStationId); var id2 = Math.Max(request.KeepStationId, request.DeleteStationId); - var alreadyIgnored = await DbContext.StationMergeIgnores - .AnyAsync(i => i.Station1Id == id1 && i.Station2Id == id2); - if (!alreadyIgnored) + + // Always add the ignore entry; if a concurrent request already inserted the same pair, + // catch the resulting duplicate-key DbUpdateException and treat it as success. + DbContext.StationMergeIgnores.Add(new StationMergeIgnore { Station1Id = id1, Station2Id = id2 }); + + try { - DbContext.StationMergeIgnores.Add(new StationMergeIgnore { Station1Id = id1, Station2Id = id2 }); + await DbContext.SaveChangesAsync(); + } + catch (DbUpdateException) + { + // Duplicate ignore entry created by a concurrent request – that's fine; the merge + // itself (Hidden flag + visit reassignments) was still applied correctly. } - await DbContext.SaveChangesAsync(); return Ok(new { message = "Stations merged successfully." }); } @@ -375,14 +382,18 @@ public async Task SkipPair([FromBody] StationMergeSkipDTO request var id1 = Math.Min(request.Station1Id, request.Station2Id); var id2 = Math.Max(request.Station1Id, request.Station2Id); - var alreadyIgnored = await DbContext.StationMergeIgnores - .AnyAsync(i => i.Station1Id == id1 && i.Station2Id == id2); + // Always attempt the insert; if a concurrent request beat us to it, treat the + // resulting duplicate-key DbUpdateException as success (idempotent). + DbContext.StationMergeIgnores.Add(new StationMergeIgnore { Station1Id = id1, Station2Id = id2 }); - if (!alreadyIgnored) + try { - DbContext.StationMergeIgnores.Add(new StationMergeIgnore { Station1Id = id1, Station2Id = id2 }); await DbContext.SaveChangesAsync(); } + catch (DbUpdateException) + { + // Pair was already skipped/merged by a concurrent request – that's fine. + } return Ok(new { message = "Pair skipped." }); } diff --git a/OV_DB/Models/StationMergeCountryDTO.cs b/OV_DB/Models/StationMergeRegionDTO.cs similarity index 81% rename from OV_DB/Models/StationMergeCountryDTO.cs rename to OV_DB/Models/StationMergeRegionDTO.cs index 66ea5abe..0033077a 100644 --- a/OV_DB/Models/StationMergeCountryDTO.cs +++ b/OV_DB/Models/StationMergeRegionDTO.cs @@ -1,6 +1,6 @@ namespace OV_DB.Models { - public class StationMergeCountryDTO + public class StationMergeRegionDTO { public int RegionId { get; set; } public string RegionName { get; set; } diff --git a/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.ts b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.ts index 5c4d839c..1fe8c614 100644 --- a/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.ts +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.ts @@ -19,7 +19,7 @@ import { MatSelect } from "@angular/material/select"; import { MatTooltip } from "@angular/material/tooltip"; import { LeafletModule } from "@bluehalo/ngx-leaflet"; import { divIcon, LatLng, LatLngBounds, Layer, marker, tileLayer } from "leaflet"; -import { StationMergeCountry, StationNearbyPair } from "src/app/models/stationMerge.model"; +import { StationMergeRegion, StationNearbyPair } from "src/app/models/stationMerge.model"; import { ApiService } from "src/app/services/api.service"; /** Returns an HTMLElement whose textContent is set, so Leaflet treats it as safe plain text. */ @@ -54,7 +54,7 @@ export class AdministratorStationMergeComponent implements OnInit { private apiService = inject(ApiService); private destroyRef = inject(DestroyRef); - regions = signal([]); + regions = signal([]); selectedRegionId = signal(null); currentPair = signal(null); totalPairs = signal(0); diff --git a/OV_DB/OVDBFrontend/src/app/models/stationMerge.model.ts b/OV_DB/OVDBFrontend/src/app/models/stationMerge.model.ts index 4ba3590b..a99a7032 100644 --- a/OV_DB/OVDBFrontend/src/app/models/stationMerge.model.ts +++ b/OV_DB/OVDBFrontend/src/app/models/stationMerge.model.ts @@ -14,7 +14,7 @@ export interface StationNearbyPair { distanceMeters: number; } -export interface StationMergeCountry { +export interface StationMergeRegion { regionId: number; regionName: string; pairCount: number; diff --git a/OV_DB/OVDBFrontend/src/app/services/api.service.ts b/OV_DB/OVDBFrontend/src/app/services/api.service.ts index ec3b9241..dbb721dc 100644 --- a/OV_DB/OVDBFrontend/src/app/services/api.service.ts +++ b/OV_DB/OVDBFrontend/src/app/services/api.service.ts @@ -33,7 +33,7 @@ import { LinkToRouteInstanceResponse, TrawellingIgnoreResponse } from "../models/traewelling.model"; -import { StationMergeCountry, StationMergePairsResponse } from "../models/stationMerge.model"; +import { StationMergeRegion, StationMergePairsResponse } from "../models/stationMerge.model"; @Injectable({ providedIn: "root", @@ -671,8 +671,8 @@ export class ApiService { ); } - getStationMergeCountries(): Observable { - return this.httpClient.get( + getStationMergeCountries(): Observable { + return this.httpClient.get( environment.backend + "api/stationMerge/regions" ); } From 6d51f76cd03b8950e3acf07d315449e0096401a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 9 May 2026 22:04:07 +0000 Subject: [PATCH 09/10] fix: narrow DbUpdateException catch to unique constraint violations only Add IsUniqueConstraintViolation helper that checks the inner exception message for "duplicate" (MySQL error 1062) and "UNIQUE constraint failed" (SQLite). Use exception filter `when (IsUniqueConstraintViolation(ex))` in both MergeStations and SkipPair so that connection failures, timeouts and other database errors still propagate rather than being silently swallowed. Agent-Logs-Url: https://github.com/jjasloot/OVDB/sessions/4812843b-fb7f-4aa0-af3e-ae22ac5a0e90 Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- OV_DB/Controllers/StationMergeController.cs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/OV_DB/Controllers/StationMergeController.cs b/OV_DB/Controllers/StationMergeController.cs index e7a79199..11922660 100644 --- a/OV_DB/Controllers/StationMergeController.cs +++ b/OV_DB/Controllers/StationMergeController.cs @@ -29,6 +29,19 @@ private bool IsAdmin() => "true", StringComparison.OrdinalIgnoreCase); + /// + /// Returns true when a was caused by a unique-constraint + /// violation (e.g. MySQL error 1062 "Duplicate entry", SQLite "UNIQUE constraint failed"). + /// Other database errors (connection loss, timeout, etc.) are NOT matched and will + /// continue to propagate. + /// + private static bool IsUniqueConstraintViolation(DbUpdateException ex) + { + var msg = ex.InnerException?.Message ?? ex.Message; + return msg.Contains("duplicate", StringComparison.OrdinalIgnoreCase) + || msg.Contains("UNIQUE constraint failed", StringComparison.OrdinalIgnoreCase); + } + private const double MaxDistanceMeters = 500.0; /// Conservative latitude bounding-box pre-filter (~500 m). private const double LatBBoxDegrees = 0.005; @@ -361,7 +374,7 @@ public async Task MergeStations([FromBody] StationMergeRequestDTO { await DbContext.SaveChangesAsync(); } - catch (DbUpdateException) + catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) { // Duplicate ignore entry created by a concurrent request – that's fine; the merge // itself (Hidden flag + visit reassignments) was still applied correctly. @@ -390,7 +403,7 @@ public async Task SkipPair([FromBody] StationMergeSkipDTO request { await DbContext.SaveChangesAsync(); } - catch (DbUpdateException) + catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) { // Pair was already skipped/merged by a concurrent request – that's fine. } From b0dfde56b26b3a5ed82377d266cb6b3924dfad0b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 10 May 2026 16:32:20 +0000 Subject: [PATCH 10/10] feat: add "Stations map" link in merge UI, centered on pair midpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stationsMapQueryParams computed to AdministratorStationMergeComponent that calculates the midpoint lat/lon of the current pair - Add a "Stations map" link in the center column of the pair UI (opens in a new tab) using routerLink + queryParams: /administrator/stations?lat=…&lon=… - Update AdminStationsMapComponent to read lat/lon query params on init: when present, set initial bounds to a ~1 km box centred on that location instead of fitting all markers; inject ActivatedRoute for this purpose Agent-Logs-Url: https://github.com/jjasloot/OVDB/sessions/a9775185-f6b8-4093-8122-61b9ac4d7233 Co-authored-by: jjasloot <5612709+jjasloot@users.noreply.github.com> --- .../administrator-station-merge.component.html | 13 +++++++++++++ .../administrator-station-merge.component.ts | 12 ++++++++++++ .../admin-stations-map.component.ts | 17 +++++++++++++++-- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html index c7d71a13..d57ba4a0 100644 --- a/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html @@ -115,6 +115,19 @@ swap_horiz {{ currentPair().distanceMeters | number:'1.0-0' }} m + @if (stationsMapQueryParams()) { + + open_in_new Stations map + + }