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..8f726af5 --- /dev/null +++ b/OVDB_database/Models/StationMergeIgnore.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using System.ComponentModel.DataAnnotations; + +namespace OVDB_database.Models +{ + /// + /// Records a station pair that should not resurface in the merge queue (either merged or + /// deliberately kept separate).
+ /// Invariant: Station1Id < Station2Id is always enforced by the + /// application layer so that the unique index on (Station1Id, Station2Id) prevents duplicates + /// regardless of argument order. + ///
+ [Index(nameof(Station1Id), nameof(Station2Id), IsUnique = true)] + public class StationMergeIgnore + { + [Key] + public long Id { get; set; } + /// Always the smaller of the two station IDs. + public int Station1Id { get; set; } + public Station Station1 { get; set; } + /// Always the larger of the two station IDs. + 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..11922660 --- /dev/null +++ b/OV_DB/Controllers/StationMergeController.cs @@ -0,0 +1,414 @@ +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); + + /// + /// 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; + private const double EarthRadius = 6371000.0; + /// Maximum pair count returned or counted; prevents runaway iteration on dense regions. + private const int MaxPairCount = 9999; + + /// + /// Computes a conservative longitude bounding-box for the given latitude. + /// At higher latitudes, 1° of longitude covers fewer metres, so the pre-filter + /// degree threshold must be widened to avoid false negatives. + /// + /// 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); + if (cosLat < 0.001) return 180.0; // near pole – no lon restriction + return MaxDistanceMeters / (EarthRadius * cosLat * Math.PI / 180.0); + } + + private static double HaversineDistance(double lat1, double lon1, double lat2, double lon2) + { + 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); + return EarthRadius * 2 * Math.Atan2(Math.Sqrt(a), Math.Sqrt(1 - a)); + } + + /// + /// Counts nearby pairs (within MaxDistanceMeters, inclusive) that are not in the ignore set. + /// The stations list must be pre-sorted by latitude. + /// Uses a per-station longitude bounding box to avoid false negatives at high latitudes. + /// + private static int CountNearbyPairs( + IList<(int Id, double Lat, double Lon)> sortedStations, + HashSet<(int, int)> ignoredSet) + { + int count = 0; + for (int i = 0; i < sortedStations.Count; i++) + { + if (count >= MaxPairCount) break; + var latI = sortedStations[i].Lat; + var lonBBox = LonBBoxDegrees(latI); + for (int j = i + 1; j < sortedStations.Count; j++) + { + if (sortedStations[j].Lat - latI > LatBBoxDegrees) break; + if (Math.Abs(sortedStations[i].Lon - sortedStations[j].Lon) > lonBBox) continue; + + var dist = HaversineDistance( + latI, 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++; + if (count >= MaxPairCount) break; + } + } + } + } + return count; + } + + /// + /// Builds a hierarchical region name, e.g. "United Kingdom - England - Cornwall". + /// + private static string BuildRegionName( + int regionId, + Dictionary regionDict) + { + var parts = new List(); + int? current = regionId; + while (current.HasValue && regionDict.TryGetValue(current.Value, out var r)) + { + parts.Add(r.Name); + current = r.ParentId; + } + parts.Reverse(); + return string.Join(" - ", parts); + } + + /// + /// Returns all regions (at any level) that have non-hidden stations, with + /// the number of unreviewed nearby pairs and a hierarchical name. + /// + [HttpGet("regions")] + public async Task GetRegions() + { + if (!IsAdmin()) return Forbid(); + + var stationsWithRegions = await DbContext.Stations + .AsNoTracking() + .Where(s => !s.Hidden) + .SelectMany(s => s.Regions, (s, r) => new { s.Id, s.Lattitude, s.Longitude, RegionId = r.Id }) + .ToListAsync(); + + if (!stationsWithRegions.Any()) + return Ok(new List()); + + var stationsByRegion = stationsWithRegions + .GroupBy(x => x.RegionId) + .ToDictionary( + g => g.Key, + g => g.Select(x => (x.Id, x.Lattitude, x.Longitude)) + .DistinctBy(x => x.Id) + .OrderBy(x => x.Lattitude) + .ToList()); + + var allStationIds = stationsWithRegions.Select(x => x.Id).ToHashSet(); + + // Query ignore pairs where either station is in the region, and normalize ordering + var allIgnoredPairs = await DbContext.StationMergeIgnores + .AsNoTracking() + .Where(i => allStationIds.Contains(i.Station1Id) || allStationIds.Contains(i.Station2Id)) + .Select(i => new { i.Station1Id, i.Station2Id }) + .ToListAsync(); + + var ignoredSet = new HashSet<(int, int)>( + allIgnoredPairs.Select(i => (Math.Min(i.Station1Id, i.Station2Id), Math.Max(i.Station1Id, i.Station2Id)))); + + var allRegions = await DbContext.Regions + .AsNoTracking() + .Select(r => new { r.Id, r.Name, r.ParentRegionId }) + .ToListAsync(); + + var regionDict = allRegions.ToDictionary( + r => r.Id, + r => (r.Name, ParentId: r.ParentRegionId)); + + var result = stationsByRegion + .Keys + .Select(rId => + { + var stations = stationsByRegion[rId]; + return new + { + RegionId = rId, + RegionName = BuildRegionName(rId, regionDict), + PairCount = CountNearbyPairs(stations, ignoredSet), + ParentRegionId = regionDict[rId].ParentId + }; + }) + .ToList(); + + + result = result.Where(r => !r.ParentRegionId.HasValue || result.Find(res => res.RegionId == r.ParentRegionId.Value)!.PairCount > 0).ToList(); + + + return Ok(result + .Select(r => new StationMergeRegionDTO + { + RegionId = r.RegionId, + RegionName = r.RegionName, + PairCount = r.PairCount + }).OrderBy(r => r.RegionName).ToList() + ); + } + + // Typed station data used for in-memory pair generation + private sealed record StationData(int Id, string Name, double Lat, double Lon, bool Special, int Visits); + + /// + /// Lazily enumerates pairs of nearby stations (within MaxDistanceMeters, inclusive) + /// that are not in the ignore set. Stations must be pre-sorted by latitude. + /// 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) + { + for (int i = 0; i < stations.Count; i++) + { + var latI = stations[i].Lat; + var lonBBox = LonBBoxDegrees(latI); + for (int j = i + 1; j < stations.Count; j++) + { + if (stations[j].Lat - latI > LatBBoxDegrees) break; + if (Math.Abs(stations[i].Lon - stations[j].Lon) > lonBBox) continue; + + var dist = HaversineDistance(latI, stations[i].Lon, stations[j].Lat, stations[j].Lon); + 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; + + yield return new StationNearbyPairDTO + { + Station1Id = stations[i].Id, + Station1Name = stations[i].Name, + Station1Lattitude = stations[i].Lat, + Station1Longitude = stations[i].Lon, + Station1Visits = stations[i].Visits, + Station1Special = stations[i].Special, + Station2Id = stations[j].Id, + Station2Name = stations[j].Name, + Station2Lattitude = stations[j].Lat, + Station2Longitude = stations[j].Lon, + Station2Visits = stations[j].Visits, + Station2Special = stations[j].Special, + DistanceMeters = Math.Round(dist, 1) + }; + } + } + } + + /// + /// Returns paginated pairs of nearby stations (within 500 m) for a given region + /// that have not yet been reviewed (not in the ignore list). + /// Total is capped at MaxPairCount to prevent runaway counting on dense regions. + /// + [HttpGet("pairs/{regionId:int}")] + public async Task GetPairs(int regionId, [FromQuery] int page = 0, [FromQuery] int pageSize = 10) + { + if (!IsAdmin()) return Forbid(); + + if (page < 0 || pageSize < 1 || pageSize > 100) + return BadRequest("page must be >= 0 and pageSize must be between 1 and 100."); + + var stationsRaw = await DbContext.Stations + .AsNoTracking() + .Where(s => !s.Hidden && s.Regions.Any(r => r.Id == regionId)) + .Select(s => new + { + s.Id, + s.Name, + s.Lattitude, + s.Longitude, + s.Special, + Visits = s.StationVisits.Count() + }) + .OrderBy(s => s.Lattitude) + .ToListAsync(); + + var stationIds = stationsRaw.Select(s => s.Id).ToHashSet(); + + // Query ignore pairs where either station belongs to this region, normalize ordering + var ignoredPairs = await DbContext.StationMergeIgnores + .AsNoTracking() + .Where(i => stationIds.Contains(i.Station1Id) || stationIds.Contains(i.Station2Id)) + .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)))); + + var stationList = stationsRaw + .Select(s => new StationData(s.Id, s.Name, s.Lattitude, s.Longitude, s.Special, s.Visits)) + .ToList(); + + // Enumerate pairs lazily: collect the requested page; count up to MaxPairCount without + // building a full DTO list for non-paged items. + int skip = page * pageSize; + int total = 0; + var paged = new List(pageSize); + + foreach (var pair in EnumeratePairs(stationList, ignoredSet)) + { + if (total >= skip && paged.Count < pageSize) + paged.Add(pair); + total++; + if (total >= MaxPairCount) break; + } + + 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(); + + 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); + } + + deleteStation.Hidden = true; + + var id1 = Math.Min(request.KeepStationId, request.DeleteStationId); + var id2 = Math.Max(request.KeepStationId, request.DeleteStationId); + + // 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 + { + await DbContext.SaveChangesAsync(); + } + 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. + } + + return Ok(new { message = "Stations merged successfully." }); + } + + /// + /// Marks a pair as "keep both" – the pair will no longer appear in the merge queue. + /// Station1Id is always stored as min(id1, id2) to enforce canonical ordering. + /// + [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); + + // 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 }); + + try + { + await DbContext.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) + { + // Pair was already skipped/merged by a concurrent request – that's fine. + } + + return Ok(new { message = "Pair skipped." }); + } + } +} diff --git a/OV_DB/Models/StationMergeRegionDTO.cs b/OV_DB/Models/StationMergeRegionDTO.cs new file mode 100644 index 00000000..0033077a --- /dev/null +++ b/OV_DB/Models/StationMergeRegionDTO.cs @@ -0,0 +1,9 @@ +namespace OV_DB.Models +{ + public class StationMergeRegionDTO + { + public int RegionId { get; set; } + public string RegionName { get; set; } + public int PairCount { 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..aae818f5 --- /dev/null +++ b/OV_DB/Models/StationNearbyPairDTO.cs @@ -0,0 +1,21 @@ +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 bool Station1Special { 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 bool Station2Special { get; set; } + + public double DistanceMeters { get; set; } + } +} diff --git a/OV_DB/OVDBFrontend/package-lock.json b/OV_DB/OVDBFrontend/package-lock.json index 59852ffa..1f029b64 100644 --- a/OV_DB/OVDBFrontend/package-lock.json +++ b/OV_DB/OVDBFrontend/package-lock.json @@ -699,18 +699,6 @@ } } }, - "node_modules/@angular-devkit/build-angular/node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.18.0" - } - }, "node_modules/@angular-devkit/build-angular/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -1403,18 +1391,6 @@ "listr2": "9.0.5" } }, - "node_modules/@angular/cli/node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "undici-types": "~7.18.0" - } - }, "node_modules/@angular/cli/node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -14880,15 +14856,6 @@ "node": ">=20.18.1" } }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/unicode-canonical-property-names-ecmascript": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", 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..d57ba4a0 --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.html @@ -0,0 +1,202 @@ + + + Merge Duplicate Stations + + +

+ Stations within 500 m of each other may be duplicates. Work through the queue + per region: 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 (region of regions(); track region.regionId) { + + {{ region.regionName }} + @if (region.pairCount > 0) { + {{ region.pairCount }} + } @else { + + } + + } + + + @if (selectedRegionId()) { + + @if (loading()) { + + } @else if (totalPairs() > 0) { + {{ totalPairs() }} pair(s) remaining + } @else { + All done for this region 🎉 + } + + } +
+ + @if (loading()) { +
+ +
+ } + + @if (!loading() && currentPair()) { + +
+ + +
+ +
+
L
+
{{ currentPair().station1Name || '(unnamed)' }}
+
+ {{ currentPair().station1Lattitude | number:'1.5-5' }}, + {{ currentPair().station1Longitude | number:'1.5-5' }} +
+
+ people {{ currentPair().station1Visits }} visit(s) +
+
+ @if (currentPair().station1Special) { + museum Museum / special + + } @else { + train Regular + + } +
+ map OSM + +
+ + +
+
+ swap_horiz + {{ currentPair().distanceMeters | number:'1.0-0' }} m +
+ @if (stationsMapQueryParams()) { + + open_in_new Stations map + + } + +
+ + +
+
R
+
{{ currentPair().station2Name || '(unnamed)' }}
+
+ {{ currentPair().station2Lattitude | number:'1.5-5' }}, + {{ currentPair().station2Longitude | number:'1.5-5' }} +
+
+ people {{ currentPair().station2Visits }} visit(s) +
+
+ @if (currentPair().station2Special) { + + museum Museum / special + } @else { + + train Regular + } +
+ map OSM + +
+
+ } + + @if (!loading() && totalPairs() === 0 && selectedRegionId()) { +
+ check_circle + No nearby duplicate stations to review for this region. +
+ } + + @if (!selectedRegionId()) { +
Select a region above to start reviewing duplicates.
+ } +
+
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..4daddd7b --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.scss @@ -0,0 +1,203 @@ +.description { + opacity: 0.7; + margin-bottom: 1.25rem; +} + +.country-row { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; +} + +.country-select { + min-width: 240px; +} + +.pair-badge { + display: inline-block; + background: #e53935; + color: white; + border-radius: 10px; + padding: 1px 7px; + font-size: 0.75rem; + font-weight: 600; + margin-left: 6px; +} + +.pair-done { + color: #43a047; + margin-left: 6px; +} + +.queue-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9rem; +} + +.done-label { + color: #43a047; + font-weight: 500; +} + +.spinner-container { + display: flex; + justify-content: center; + padding: 3rem 0; +} + +// Leaflet map +.pair-map { + height: 340px; + width: 100%; + border-radius: 8px; + overflow: hidden; + margin-bottom: 1rem; + border: 1px solid rgba(128, 128, 128, 0.3); +} + +.pair-info { + display: flex; + gap: 0.75rem; + align-items: flex-start; + margin-bottom: 1rem; +} + +.station-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.3rem; + + &.right { + align-items: flex-end; + text-align: right; + } +} + +.station-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + color: white; + font-weight: 700; + font-size: 0.85rem; + + &.left-badge { + background: #1565c0; + border: 2px solid #0d47a1; + } + + &.right-badge { + background: #c62828; + border: 2px solid #b71c1c; + align-self: flex-end; + } +} + +.station-name { + font-weight: 600; + font-size: 1rem; +} + +.station-detail { + font-size: 0.82rem; + opacity: 0.75; + + &.visits { + display: flex; + align-items: center; + } + + &.special-row { + display: flex; + align-items: center; + gap: 2px; + opacity: 1; + } +} + +.right .station-detail.visits { + justify-content: flex-end; +} + +.special-icon { + color: #fb8c00; +} + +.normal-icon { + opacity: 0.5; +} + +.toggle-special-btn { + width: 24px; + height: 24px; + font-size: 16px; + line-height: 24px; + padding: 0; +} + +.osm-link { + font-size: 0.8rem; + color: #1976d2; + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 2px; + + &:hover { text-decoration: underline; } +} + +:host-context(.dark-theme) .osm-link { + color: #90caf9; +} + +.keep-btn { + margin-top: 0.4rem; + align-self: flex-start; +} + +.right .keep-btn { + align-self: flex-end; +} + +.center-col { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.6rem; + min-width: 100px; + padding-top: 30px; +} + +.distance-display { + display: flex; + flex-direction: column; + align-items: center; + font-size: 0.85rem; + font-weight: 500; + opacity: 0.65; + gap: 2px; +} + +.no-pairs { + display: flex; + align-items: center; + gap: 0.5rem; + color: #43a047; + font-size: 1rem; + margin-top: 1rem; +} + +.prompt { + opacity: 0.55; + font-style: italic; + margin-top: 0.5rem; +} 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..b036e50d --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/administrator/administrator-station-merge/administrator-station-merge.component.ts @@ -0,0 +1,221 @@ +import { RouterLink } from "@angular/router"; +import { DecimalPipe } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + OnInit, + signal, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormsModule } from "@angular/forms"; +import { MatButton } 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 { LeafletModule } from "@bluehalo/ngx-leaflet"; +import { divIcon, LatLng, LatLngBounds, Layer, marker, tileLayer } from "leaflet"; +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. */ +function safeTooltipContent(text: string): HTMLElement { + const el = document.createElement("span"); + el.textContent = text; + return el; +} + +@Component({ + selector: "app-administrator-station-merge", + templateUrl: "./administrator-station-merge.component.html", + styleUrls: ["./administrator-station-merge.component.scss"], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + MatButton, + MatCard, + MatCardContent, + MatCardHeader, + MatCardTitle, + MatIcon, + MatOption, + MatProgressSpinner, + MatSelect, + MatTooltip, + DecimalPipe, + LeafletModule, + RouterLink, + ], +}) +export class AdministratorStationMergeComponent implements OnInit { + private apiService = inject(ApiService); + private destroyRef = inject(DestroyRef); + + regions = signal([]); + selectedRegionId = signal(null); + currentPair = signal(null); + totalPairs = signal(0); + loading = signal(false); + actionInProgress = signal(false); + + readonly mapOptions = { + layers: [ + tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + opacity: 0.85, + attribution: '© OpenStreetMap contributors', + }), + ], + zoom: 17, + }; + + readonly mapLayers = computed(() => { + const pair = this.currentPair(); + if (!pair) return []; + + const markerL = marker(new LatLng(pair.station1Lattitude, pair.station1Longitude), { + icon: divIcon({ + html: '
L
', + className: "smerge-icon", + iconSize: [28, 28], + iconAnchor: [14, 14], + }), + }).bindTooltip(safeTooltipContent(pair.station1Name || "(unnamed)"), { + permanent: true, + direction: "top", + offset: [0, -14], + }); + + const markerR = marker(new LatLng(pair.station2Lattitude, pair.station2Longitude), { + icon: divIcon({ + html: '
R
', + className: "smerge-icon", + iconSize: [28, 28], + iconAnchor: [14, 14], + }), + }).bindTooltip(safeTooltipContent(pair.station2Name || "(unnamed)"), { + permanent: true, + direction: "top", + offset: [0, -14], + }); + + return [markerL, markerR]; + }); + + readonly mapBounds = computed(() => { + const pair = this.currentPair(); + if (!pair) { + return new LatLngBounds(new LatLng(50.656245, 2.92136), new LatLng(53.604563, 7.428211)); + } + const bounds = new LatLngBounds( + new LatLng(pair.station1Lattitude, pair.station1Longitude), + new LatLng(pair.station2Lattitude, pair.station2Longitude) + ); + return bounds.isValid() ? bounds.pad(1.5) : bounds; + }); + + ngOnInit(): void { + this.loadRegions(); + } + + private loadRegions(): void { + this.apiService + .getStationMergeCountries() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((data) => { + this.regions.set(data); + }); + } + + onRegionChange(regionId: number): void { + this.selectedRegionId.set(regionId); + this.loadCurrentPair(); + } + + private loadCurrentPair(): void { + const regionId = this.selectedRegionId(); + if (!regionId) return; + this.loading.set(true); + this.apiService + .getStationMergePairs(regionId, 0, 1) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (data) => { + this.totalPairs.set(data.total); + this.currentPair.set(data.pairs.length > 0 ? data.pairs[0] : null); + 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.loadCurrentPair(); + this.loadRegions(); + }, + 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.loadCurrentPair(); + this.loadRegions(); + }, + error: () => this.actionInProgress.set(false), + }); + } + + toggleSpecial(stationId: number, currentSpecial: boolean, side: 1 | 2): void { + const newSpecial = !currentSpecial; + this.apiService + .updateStationAdmin(stationId, newSpecial, false) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: () => { + const pair = this.currentPair(); + if (!pair) return; + if (side === 1) { + this.currentPair.set({ ...pair, station1Special: newSpecial }); + } else { + this.currentPair.set({ ...pair, station2Special: newSpecial }); + } + }, + error: () => { + // Reload pair to reset any optimistic state on failure + this.loadCurrentPair(); + }, + }); + } + + openInOsm(lat: number, lon: number): string { + return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=18`; + } + + /** Query params for the admin stations map link, centred on the midpoint of the current pair. */ + readonly stationsMapQueryParams = computed(() => { + const pair = this.currentPair(); + if (!pair) return null; + return { + lat: ((pair.station1Lattitude + pair.station2Lattitude) / 2).toFixed(6), + lon: ((pair.station1Longitude + pair.station2Longitude) / 2).toFixed(6), + }; + }); +} 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..a99a7032 --- /dev/null +++ b/OV_DB/OVDBFrontend/src/app/models/stationMerge.model.ts @@ -0,0 +1,26 @@ +export interface StationNearbyPair { + station1Id: number; + station1Name: string; + station1Lattitude: number; + station1Longitude: number; + station1Visits: number; + station1Special: boolean; + station2Id: number; + station2Name: string; + station2Lattitude: number; + station2Longitude: number; + station2Visits: number; + station2Special: boolean; + distanceMeters: number; +} + +export interface StationMergeRegion { + regionId: number; + regionName: string; + pairCount: number; +} + +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..dbb721dc 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 { StationMergeRegion, 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/regions" + ); + } + + getStationMergePairs(regionId: 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/" + regionId, + { 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 } + ); + } } diff --git a/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.ts b/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.ts index c368af7a..08478ff4 100644 --- a/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.ts +++ b/OV_DB/OVDBFrontend/src/app/stations/admin-stations-map/admin-stations-map.component.ts @@ -6,6 +6,7 @@ import { input, signal } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; import { MatCheckboxChange, MatCheckbox } from "@angular/material/checkbox"; import { LatLngBounds, LatLng, divIcon, circleMarker } from "leaflet"; import { tileLayer } from "leaflet"; @@ -59,6 +60,7 @@ import { createMarkerClusterGroup } from "src/app/leaflet-markercluster-loader"; export class AdminStationsMapComponent implements OnInit { private apiService = inject(ApiService); private cd = inject(ChangeDetectorRef); + private route = inject(ActivatedRoute); regionsService = inject(RegionsService); translationService = inject(TranslationService); @@ -142,9 +144,20 @@ export class AdminStationsMapComponent implements OnInit { return Math.round((this.visited / this.total) * 1000) / 10; } ngOnInit(): void { - this.getData(true); + const lat = parseFloat(this.route.snapshot.queryParamMap.get('lat')); + const lon = parseFloat(this.route.snapshot.queryParamMap.get('lon')); + if (isFinite(lat) && isFinite(lon)) { + // Set initial bounds to a ~1 km box around the requested location so the + // map opens centred there rather than fitting all markers. + this.bounds = new LatLngBounds( + new LatLng(lat - 0.005, lon - 0.010), + new LatLng(lat + 0.005, lon + 0.010) + ); + this.getData(false); + } else { + this.getData(true); + } this.getRegions(); - } getRegions() { diff --git a/OV_DB/OVDBFrontend/src/styles.scss b/OV_DB/OVDBFrontend/src/styles.scss index cdede8ad..37154779 100644 --- a/OV_DB/OVDBFrontend/src/styles.scss +++ b/OV_DB/OVDBFrontend/src/styles.scss @@ -238,3 +238,32 @@ body { -ms-user-select: none; user-select: none; } + +// Station merge map markers (rendered by Leaflet outside Angular's scope) +.smerge-icon { + background: transparent !important; + border: none !important; +} + +.smerge-marker-l, +.smerge-marker-r { + width: 28px; + height: 28px; + border-radius: 50%; + color: white; + font-weight: 700; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; +} + +.smerge-marker-l { + background: #1565c0; + border: 2px solid #0d47a1; +} + +.smerge-marker-r { + background: #c62828; + border: 2px solid #b71c1c; +}