From 3c575f254ff147f80dc3ec7f2aa447357202c3cb Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:38:01 +0300 Subject: [PATCH 01/29] WIP: cover Geoapify verification and credits (checkpoint; tests failing) --- .../Services/GeoapifyStageOneContractTests.cs | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 tests/Wayfarer.Tests/Services/GeoapifyStageOneContractTests.cs diff --git a/tests/Wayfarer.Tests/Services/GeoapifyStageOneContractTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyStageOneContractTests.cs new file mode 100644 index 00000000..5d5112f9 --- /dev/null +++ b/tests/Wayfarer.Tests/Services/GeoapifyStageOneContractTests.cs @@ -0,0 +1,201 @@ +using System.Net; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Wayfarer.Models; +using Wayfarer.Models.LocationProviders; +using Wayfarer.Services.ExternalRouting; +using Wayfarer.Services.LocationProviders; +using Xunit; + +namespace Wayfarer.Tests.Services; + +/// Locks Geoapify capability verification, shared credit, and provider-scoped mapping authority. +public sealed class GeoapifyStageOneContractTests +{ + [Fact] + public async Task GeocodingAndRoutingVerification_AreSeparateAndShareOneCredentialAndPool() + { + await using var db = CreateDb(nameof(GeocodingAndRoutingVerification_AreSeparateAndShareOneCredentialAndPool)); + var credentials = new PersonalProviderCredentialService(new EphemeralDataProtectionProvider()); + var profile = PersonalLocationProviderProfile.Create("user", PersonalLocationProvider.Geoapify); + credentials.Replace(profile, "secret-key"); + profile.SetAuthorization(PersonalProviderCapability.Geocoding, true); + profile.SetAuthorization(PersonalProviderCapability.Routing, true); + db.Add(profile); + await db.SaveChangesAsync(); + var handler = new RecordingHandler( + "{\"type\":\"FeatureCollection\",\"features\":[]}", + ValidRouteJson); + var service = CreateVerificationService(db, credentials, handler); + + var geocoding = await service.VerifyGeocodingAsync(profile.UserId); + + Assert.Equal(PersonalProviderVerification.Verified, geocoding); + Assert.Equal(PersonalProviderVerification.Unverified, profile.RoutingVerification); + Assert.Null((await db.Set().SingleOrDefaultAsync())?.GeocodingProviderKey); + + var routing = await service.VerifyRoutingAsync(profile.UserId); + + Assert.Equal(PersonalProviderVerification.Verified, routing); + Assert.Equal(PersonalProviderVerification.Verified, profile.GeocodingVerification); + Assert.Equal(2, handler.Requests.Count); + Assert.Equal(2, await db.GeoapifyUsageAdmissions.SumAsync(item => item.Credits)); + Assert.All(handler.Requests, request => Assert.DoesNotContain("secret-key", request.SafeDiagnostic, StringComparison.Ordinal)); + Assert.DoesNotContain("apiKey", service.ToString(), StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task VerificationFailure_IsCountedAndBoundToContactedCapabilityGeneration() + { + await using var db = CreateDb(nameof(VerificationFailure_IsCountedAndBoundToContactedCapabilityGeneration)); + var credentials = new PersonalProviderCredentialService(new EphemeralDataProtectionProvider()); + var profile = PersonalLocationProviderProfile.Create("user", PersonalLocationProvider.Geoapify); + credentials.Replace(profile, "first-key"); + profile.SetAuthorization(PersonalProviderCapability.Geocoding, true); + db.Add(profile); + await db.SaveChangesAsync(); + var handler = new RecordingHandler("{\"type\":\"FeatureCollection\",\"features\":[]}") + { + BeforeResponse = () => credentials.Replace(profile, "replacement-key") + }; + + var result = await CreateVerificationService(db, credentials, handler).VerifyGeocodingAsync(profile.UserId); + + Assert.Equal(PersonalProviderVerification.Unavailable, result); + Assert.Equal(PersonalProviderVerification.Unverified, profile.GeocodingVerification); + Assert.Equal(1, await db.GeoapifyUsageAdmissions.SumAsync(item => item.Credits)); + } + + [Theory] + [InlineData(GeoapifyRoutingMode.Walk, 2, 1)] + [InlineData(GeoapifyRoutingMode.Bicycle, 3, 2)] + [InlineData(GeoapifyRoutingMode.Motorcycle, 3, 42)] + [InlineData(GeoapifyRoutingMode.Drive, 25, 504)] + [InlineData(GeoapifyRoutingMode.Bus, 2, 21)] + public void RouteCost_IsConservativeCheckedAndPairBased(GeoapifyRoutingMode mode, int waypointCount, int expected) + { + Assert.Equal(expected, GeoapifyRouteCost.Calculate(mode, waypointCount)); + } + + [Fact] + public void RouteCost_RejectsUnsupportedBoundsAndOverflow() + { + Assert.Throws(() => GeoapifyRouteCost.Calculate((GeoapifyRoutingMode)999, 2)); + Assert.Throws(() => GeoapifyRouteCost.Calculate(GeoapifyRoutingMode.Walk, 1)); + Assert.Throws(() => GeoapifyRouteCost.Calculate(GeoapifyRoutingMode.Walk, 26)); + Assert.Throws(() => GeoapifyRouteCost.CalculatePairs(GeoapifyRoutingMode.Drive, int.MaxValue)); + } + + [Theory] + [InlineData("WALK")] + [InlineData("walk")] + [InlineData("Walking")] + [InlineData("Περπάτημα")] + public void DisplayNameNeverCreatesAProviderMapping(string displayName) + { + var profile = new TransportProfile { Id = Guid.NewGuid(), Name = displayName }; + var configuration = new RoutingProviderConfiguration { Id = Guid.NewGuid(), AdapterType = RoutingAdapterType.Geoapify }; + + var resolution = ProviderTransportProfileResolver.Resolve(configuration, profile); + + Assert.Equal(ProviderTransportProfileCategory.Unmapped, resolution.Category); + } + + [Fact] + public void ExplicitMappingSurvivesRenameAndIsIndependentPerProvider() + { + var profile = new TransportProfile { Id = Guid.NewGuid(), Name = "Family car" }; + var geoapify = Configuration(RoutingAdapterType.Geoapify, profile.Id, "drive"); + var mapbox = Configuration(RoutingAdapterType.MapboxDirections, profile.Id, "mapbox/driving-traffic"); + + Assert.Equal("drive", ProviderTransportProfileResolver.Resolve(geoapify, profile).NativeMode); + Assert.Equal("mapbox/driving-traffic", ProviderTransportProfileResolver.Resolve(mapbox, profile).NativeMode); + + profile.Name = "Voiture familiale"; + + Assert.Equal("drive", ProviderTransportProfileResolver.Resolve(geoapify, profile).NativeMode); + Assert.Equal("mapbox/driving-traffic", ProviderTransportProfileResolver.Resolve(mapbox, profile).NativeMode); + } + + [Fact] + public void MissingAndUnsupportedMappingsAreRejectedBeforeCreditOrHttp() + { + var profile = new TransportProfile { Id = Guid.NewGuid(), Name = "Custom" }; + var configuration = Configuration(RoutingAdapterType.Geoapify, profile.Id, "hovercraft"); + var ledger = new PersonalProviderUsageLedger(); + var handler = new RecordingHandler(ValidRouteJson); + + var unsupported = ProviderTransportProfileResolver.Resolve(configuration, profile); + configuration.ProfileMappings.Clear(); + var unmapped = ProviderTransportProfileResolver.Resolve(configuration, profile); + + Assert.Equal(ProviderTransportProfileCategory.Unsupported, unsupported.Category); + Assert.Equal(ProviderTransportProfileCategory.Unmapped, unmapped.Category); + Assert.Equal(0, handler.Requests.Count); + Assert.True(ledger.TryAdmitGeoapify(DateTimeOffset.UtcNow, 1, 1, PersonalProviderProduct.Routing)); + } + + [Fact] + public void MappingVersionParticipatesInStableAuthority() + { + var profileId = Guid.NewGuid(); + var configuration = Configuration(RoutingAdapterType.Geoapify, profileId, "walk"); + var first = ProviderTransportProfileResolver.Resolve(configuration, new TransportProfile { Id = profileId, Name = "A" }); + + configuration.ProfileMappings.Single().SetNativeMode("bicycle"); + configuration.MarkConfigurationChanged(); + var second = ProviderTransportProfileResolver.Resolve(configuration, new TransportProfile { Id = profileId, Name = "A" }); + + Assert.NotEqual(first.Authority, second.Authority); + Assert.Equal("bicycle", second.NativeMode); + } + + private static GeoapifyVerificationService CreateVerificationService(ApplicationDbContext db, + PersonalProviderCredentialService credentials, HttpMessageHandler handler) + { + var gate = new PersonalProviderContactGate(db, credentials, + new LegacyMapboxMigrationService(db, credentials), new ConfigurationBuilder().Build()); + return new GeoapifyVerificationService(new HttpClient(handler), gate, db); + } + + private static RoutingProviderConfiguration Configuration(RoutingAdapterType adapter, Guid profileId, string nativeMode) + { + var configuration = new RoutingProviderConfiguration { Id = Guid.NewGuid(), AdapterType = adapter }; + configuration.ProfileMappings.Add(new RoutingProviderProfileMapping + { + RoutingProviderConfigurationId = configuration.Id, + TransportProfileId = profileId, + ProviderNativeMode = nativeMode + }); + return configuration; + } + + private static ApplicationDbContext CreateDb(string name) + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(name).Options; + return new ApplicationDbContext(options, new ServiceCollection().BuildServiceProvider()); + } + + private const string ValidRouteJson = """ + {"results":[{"distance":1111,"time":900,"geometry":{"type":"LineString","coordinates":[[0,0],[0.01,0]]}, + "legs":[{"distance":1111,"time":900,"steps":[{"instruction":{"text":"Walk east","type":"Straight"},"from_index":0,"to_index":1,"distance":1111,"time":900}]}]}]} + """; + + private sealed class RecordingHandler(params string[] responses) : HttpMessageHandler + { + private int index; + public Action? BeforeResponse { get; init; } + public List<(string Method, string Host, string Path, string SafeDiagnostic)> Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add((request.Method.Method, request.RequestUri!.Host, request.RequestUri.AbsolutePath, + $"{request.Method.Method} {request.RequestUri.Host}{request.RequestUri.AbsolutePath}")); + BeforeResponse?.Invoke(); + var response = responses[Math.Min(index++, responses.Length - 1)]; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response) }); + } + } +} From 0555eeeef62b2d311e23d4db28d3da97e9003db5 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:42:50 +0300 Subject: [PATCH 02/29] feat(providers): verify Geoapify capabilities and costs --- ...utingProviderConfigurationConfiguration.cs | 1 + Models/RoutingProviderConfiguration.cs | 6 +- Models/RoutingProviderProfileMapping.cs | 14 +- .../ExternalRouting/GeoapifyRoutingPolicy.cs | 78 ++++++++++ .../GeoapifyVerificationService.cs | 137 ++++++++++++++++++ .../PersonalProviderContactGate.cs | 61 +++++++- .../Services/GeoapifyStageOneContractTests.cs | 12 +- 7 files changed, 298 insertions(+), 11 deletions(-) create mode 100644 Services/ExternalRouting/GeoapifyRoutingPolicy.cs create mode 100644 Services/LocationProviders/GeoapifyVerificationService.cs diff --git a/Models/Configuration/RoutingProviderConfigurationConfiguration.cs b/Models/Configuration/RoutingProviderConfigurationConfiguration.cs index e4e0961e..a5f04b14 100644 --- a/Models/Configuration/RoutingProviderConfigurationConfiguration.cs +++ b/Models/Configuration/RoutingProviderConfigurationConfiguration.cs @@ -31,6 +31,7 @@ public void Configure(EntityTypeBuilder builder) { builder.ToTable("RoutingProviderProfileMappings"); builder.HasKey(item => new { item.RoutingProviderConfigurationId, item.TransportProfileId }); + builder.Ignore(item => item.ProviderNativeMode); builder.HasOne(item => item.TransportProfile).WithMany().HasForeignKey(item => item.TransportProfileId) .OnDelete(DeleteBehavior.Restrict); } diff --git a/Models/RoutingProviderConfiguration.cs b/Models/RoutingProviderConfiguration.cs index 4a77d401..fe13bbaf 100644 --- a/Models/RoutingProviderConfiguration.cs +++ b/Models/RoutingProviderConfiguration.cs @@ -109,7 +109,11 @@ public void MarkConfigurationChanged() public enum RoutingAdapterType { /// The explicit OSRM route API contract. - OsrmCompatible = 1 + OsrmCompatible = 1, + /// The fixed Geoapify Routing API contract. + Geoapify = 2, + /// The distinct Mapbox Directions contract reserved for issue #500. + MapboxDirections = 3 } /// Controls whether users may select an administrator-owned provider template. diff --git a/Models/RoutingProviderProfileMapping.cs b/Models/RoutingProviderProfileMapping.cs index 8ceea603..ffe01583 100644 --- a/Models/RoutingProviderProfileMapping.cs +++ b/Models/RoutingProviderProfileMapping.cs @@ -2,7 +2,7 @@ namespace Wayfarer.Models; -/// Maps one Wayfarer transport profile to an exact OSRM profile. +/// Maps one stable Wayfarer transport profile to one provider-native routing mode. public sealed class RoutingProviderProfileMapping { /// Gets or sets the owning provider configuration. @@ -11,10 +11,20 @@ public sealed class RoutingProviderProfileMapping /// Gets or sets the mapped Wayfarer transport profile. public Guid TransportProfileId { get; set; } - /// Gets or sets the exact OSRM route profile path value. + /// Gets or sets the exact provider-native mode validated for the owning adapter. [Required, StringLength(80)] public string OsrmProfile { get; set; } = string.Empty; + /// Gets or sets the provider-neutral name for the existing provider-scoped storage column. + public string ProviderNativeMode + { + get => OsrmProfile; + set => OsrmProfile = value; + } + + /// Changes the provider-native mode without consulting the display profile name. + public void SetNativeMode(string nativeMode) => OsrmProfile = nativeMode; + /// Gets or sets the provider configuration navigation. public RoutingProviderConfiguration RoutingProviderConfiguration { get; set; } = null!; diff --git a/Services/ExternalRouting/GeoapifyRoutingPolicy.cs b/Services/ExternalRouting/GeoapifyRoutingPolicy.cs new file mode 100644 index 00000000..9f7febba --- /dev/null +++ b/Services/ExternalRouting/GeoapifyRoutingPolicy.cs @@ -0,0 +1,78 @@ +using Wayfarer.Models; + +namespace Wayfarer.Services.ExternalRouting; + +/// Identifies the closed initial Geoapify routing-mode catalog. +public enum GeoapifyRoutingMode { Walk, Bicycle, Motorcycle, Drive, Bus } + +/// Calculates conservative Geoapify credits before provider contact. +public static class GeoapifyRouteCost +{ + /// Calculates checked cost for the consecutive pairs in 2–25 waypoints. + public static int Calculate(GeoapifyRoutingMode mode, int waypointCount) + { + if (waypointCount is < 2 or > 25) throw new ArgumentOutOfRangeException(nameof(waypointCount)); + return CalculatePairs(mode, waypointCount - 1); + } + + /// Calculates a checked cost for an already validated positive pair count. + public static int CalculatePairs(GeoapifyRoutingMode mode, int pairCount) + { + if (pairCount <= 0) throw new ArgumentOutOfRangeException(nameof(pairCount)); + var perPair = mode switch + { + GeoapifyRoutingMode.Walk or GeoapifyRoutingMode.Bicycle => 1, + GeoapifyRoutingMode.Motorcycle or GeoapifyRoutingMode.Drive or GeoapifyRoutingMode.Bus => 21, + _ => throw new ArgumentOutOfRangeException(nameof(mode)) + }; + return checked(pairCount * perPair); + } + + /// Parses only an exact supported persisted value. + public static bool TryParse(string? value, out GeoapifyRoutingMode mode) => Enum.TryParse(value, true, out mode) + && value == NativeMode(mode); + + /// Returns the exact provider-native value. + public static string NativeMode(GeoapifyRoutingMode mode) => mode switch + { + GeoapifyRoutingMode.Walk => "walk", + GeoapifyRoutingMode.Bicycle => "bicycle", + GeoapifyRoutingMode.Motorcycle => "motorcycle", + GeoapifyRoutingMode.Drive => "drive", + GeoapifyRoutingMode.Bus => "bus", + _ => throw new ArgumentOutOfRangeException(nameof(mode)) + }; +} + +/// Identifies bounded mapping resolution without inferring from display text. +public enum ProviderTransportProfileCategory { Supported, Unmapped, Unsupported } + +/// Contains safe stable mapping authority for provider work. +public sealed record ProviderTransportProfileResolution( + ProviderTransportProfileCategory Category, string? NativeMode, string? Authority); + +/// Resolves only explicit provider-configuration plus stable-profile mappings. +public static class ProviderTransportProfileResolver +{ + /// Resolves one exact mapping and validates it against the selected adapter catalog. + public static ProviderTransportProfileResolution Resolve( + RoutingProviderConfiguration configuration, TransportProfile profile) + { + var mapping = configuration.ProfileMappings.SingleOrDefault(item => item.TransportProfileId == profile.Id); + if (mapping == null) + return new(ProviderTransportProfileCategory.Unmapped, null, null); + var nativeMode = mapping.ProviderNativeMode; + var supported = configuration.AdapterType switch + { + RoutingAdapterType.Geoapify => GeoapifyRouteCost.TryParse(nativeMode, out _), + RoutingAdapterType.MapboxDirections => nativeMode is "mapbox/driving" or "mapbox/driving-traffic" + or "mapbox/walking" or "mapbox/cycling", + RoutingAdapterType.OsrmCompatible => !string.IsNullOrWhiteSpace(nativeMode), + _ => false + }; + if (!supported) + return new(ProviderTransportProfileCategory.Unsupported, null, null); + var authority = $"{configuration.Id:N}:{configuration.ConfigurationVersion}:{profile.Id:N}:{nativeMode}"; + return new(ProviderTransportProfileCategory.Supported, nativeMode, authority); + } +} diff --git a/Services/LocationProviders/GeoapifyVerificationService.cs b/Services/LocationProviders/GeoapifyVerificationService.cs new file mode 100644 index 00000000..5145e3e3 --- /dev/null +++ b/Services/LocationProviders/GeoapifyVerificationService.cs @@ -0,0 +1,137 @@ +using System.Globalization; +using System.Net; +using System.Text.Json; +using Wayfarer.Models; +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Services.LocationProviders; + +/// Performs explicit bounded Geoapify capability verification with no selection side effect. +public sealed class GeoapifyVerificationService( + HttpClient httpClient, PersonalProviderContactGate contactGate, ApplicationDbContext dbContext) +{ + private readonly ApplicationDbContext authorityContext = dbContext; + private const int ResponseLimit = 262_144; + + /// Verifies the fixed non-personal reverse-geocoding request. + public Task VerifyGeocodingAsync( + string userId, CancellationToken cancellationToken = default) => VerifyAsync( + userId, PersonalProviderCapability.Geocoding, BuildGeocodingUri, ValidateGeocoding, cancellationToken); + + /// Verifies the fixed non-personal one-pair walk routing request. + public Task VerifyRoutingAsync( + string userId, CancellationToken cancellationToken = default) => VerifyAsync( + userId, PersonalProviderCapability.Routing, BuildRoutingUri, ValidateRouting, cancellationToken); + + private async Task VerifyAsync(string userId, PersonalProviderCapability capability, + Func uriFactory, Func validator, CancellationToken cancellationToken) + { + var admission = await contactGate.AdmitGeoapifyVerificationAsync(userId, capability, cancellationToken); + if (!admission.Succeeded || admission.Authority == null) + return PersonalProviderVerification.Unavailable; + var authority = admission.Authority; + if (!IsTrackedAuthorityCurrent(authority) + || !await contactGate.IsGeoapifyVerificationCurrentAsync(authority, cancellationToken)) + return PersonalProviderVerification.Unavailable; + + var result = PersonalProviderVerification.Unavailable; + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, uriFactory(authority.Credential)); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + result = PersonalProviderVerification.Failed; + else if (response.IsSuccessStatusCode && response.Content.Headers.ContentLength <= ResponseLimit) + { + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (bytes.Length <= ResponseLimit) + { + using var document = JsonDocument.Parse(bytes); + result = validator(document.RootElement) + ? PersonalProviderVerification.Verified : PersonalProviderVerification.Unavailable; + } + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (HttpRequestException) { } + catch (TaskCanceledException) { } + catch (JsonException) { } + + if (!IsTrackedAuthorityCurrent(authority) + || !await contactGate.IsGeoapifyVerificationCurrentAsync(authority, cancellationToken)) + return PersonalProviderVerification.Unavailable; + return await contactGate.TryRecordGeoapifyVerificationAsync(authority, result, cancellationToken) + ? result : PersonalProviderVerification.Unavailable; + } + + private bool IsTrackedAuthorityCurrent(PersonalProviderAuthoritySnapshot authority) + { + var tracked = authorityContext.ChangeTracker.Entries() + .Select(entry => entry.Entity).SingleOrDefault(profile => + profile.UserId == authority.UserId && profile.ProviderKey == authority.ProviderKey); + return tracked == null || tracked.CredentialGeneration == authority.CredentialGeneration + && (authority.Capability == PersonalProviderCapability.Geocoding + ? tracked.GeocodingGeneration : tracked.RoutingGeneration) == authority.CapabilityGeneration; + } + + private static Uri BuildGeocodingUri(string credential) => new( + "https://api.geoapify.com/v1/geocode/reverse?lat=0&lon=0&format=geojson&lang=en&limit=1&apiKey=" + + Uri.EscapeDataString(credential)); + + private static Uri BuildRoutingUri(string credential) => new( + "https://api.geoapify.com/v1/routing?waypoints=0,0%7C0,0.01&mode=walk&format=json&lang=en" + + "&details=instruction_details&type=balanced&traffic=free_flow&apiKey=" + Uri.EscapeDataString(credential)); + + private static bool ValidateGeocoding(JsonElement root) => root.ValueKind == JsonValueKind.Object + && root.TryGetProperty("type", out var type) && type.GetString() == "FeatureCollection" + && root.TryGetProperty("features", out var features) && features.ValueKind == JsonValueKind.Array; + + private static bool ValidateRouting(JsonElement root) + { + if (!root.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array + || results.GetArrayLength() != 1) return false; + var route = results[0]; + if (!NonNegativeFinite(route, "distance") || !NonNegativeFinite(route, "time") + || !route.TryGetProperty("legs", out var legs) || legs.ValueKind != JsonValueKind.Array + || legs.GetArrayLength() != 1 || !ValidateGeometry(route)) return false; + var leg = legs[0]; + return leg.TryGetProperty("steps", out var steps) && steps.ValueKind == JsonValueKind.Array + && steps.GetArrayLength() > 0 && steps.EnumerateArray().All(ValidateStep); + } + + private static bool ValidateGeometry(JsonElement route) + { + if (!route.TryGetProperty("geometry", out var geometry) + || !geometry.TryGetProperty("type", out var type) || type.GetString() != "LineString" + || !geometry.TryGetProperty("coordinates", out var points) || points.ValueKind != JsonValueKind.Array + || points.GetArrayLength() < 2) return false; + var parsed = points.EnumerateArray().Select(ParsePoint).ToArray(); + return parsed.All(point => point.HasValue) + && Close(parsed[0]!.Value, (0d, 0d)) && Close(parsed[^1]!.Value, (0.01d, 0d)); + } + + private static (double Longitude, double Latitude)? ParsePoint(JsonElement point) + { + if (point.ValueKind != JsonValueKind.Array || point.GetArrayLength() < 2 + || !point[0].TryGetDouble(out var longitude) || !point[1].TryGetDouble(out var latitude) + || !double.IsFinite(longitude) || !double.IsFinite(latitude) + || longitude is < -180 or > 180 || latitude is < -90 or > 90) return null; + return (longitude, latitude); + } + + private static bool ValidateStep(JsonElement step) => NonNegativeFinite(step, "distance") + && NonNegativeFinite(step, "time") + && step.TryGetProperty("from_index", out var from) && from.TryGetInt32(out var fromIndex) && fromIndex >= 0 + && step.TryGetProperty("to_index", out var to) && to.TryGetInt32(out var toIndex) && toIndex > fromIndex + && step.TryGetProperty("instruction", out var instruction) && instruction.ValueKind == JsonValueKind.Object + && instruction.TryGetProperty("text", out var text) && !string.IsNullOrWhiteSpace(text.GetString()) + && instruction.TryGetProperty("type", out var type) && !string.IsNullOrWhiteSpace(type.GetString()); + + private static bool NonNegativeFinite(JsonElement value, string property) => + value.TryGetProperty(property, out var number) && number.TryGetDouble(out var parsed) + && double.IsFinite(parsed) && parsed >= 0; + + private static bool Close((double Longitude, double Latitude) actual, (double Longitude, double Latitude) expected) => + Math.Abs(actual.Longitude - expected.Longitude) <= 0.00001 + && Math.Abs(actual.Latitude - expected.Latitude) <= 0.00001; +} diff --git a/Services/LocationProviders/PersonalProviderContactGate.cs b/Services/LocationProviders/PersonalProviderContactGate.cs index d9897a5e..bf45a6df 100644 --- a/Services/LocationProviders/PersonalProviderContactGate.cs +++ b/Services/LocationProviders/PersonalProviderContactGate.cs @@ -105,6 +105,59 @@ public async Task TryRecordMapboxPermanentVerificationAsync( return true; } + /// Admits one explicit Geoapify capability verification without requiring selection or prior verification. + public async Task AdmitGeoapifyVerificationAsync( + string userId, PersonalProviderCapability capability, CancellationToken cancellationToken = default) + { + var profile = await dbContext.Set().AsNoTracking() + .SingleOrDefaultAsync(item => item.UserId == userId && item.ProviderKey == "geoapify", cancellationToken); + if (profile == null || profile.RevokedAt != null || !profile.IsAuthorized(capability)) + return PersonalProviderAdmission.Rejected(PersonalProviderAdmissionCategory.Unauthorized); + var read = credentials.Read(profile); + if (!read.Succeeded) + return PersonalProviderAdmission.Rejected(PersonalProviderAdmissionCategory.CredentialUnavailable); + var product = capability == PersonalProviderCapability.Geocoding + ? PersonalProviderProduct.Geocoding : PersonalProviderProduct.Routing; + var admitted = await AdmitGeoapifyAsync(userId, product, 1, cancellationToken); + if (!admitted.Succeeded) return admitted; + var generation = capability == PersonalProviderCapability.Geocoding + ? profile.GeocodingGeneration : profile.RoutingGeneration; + return new(PersonalProviderAdmissionCategory.Admitted, + new(userId, "geoapify", capability, read.Credential!, profile.CredentialGeneration, generation, 0), + admitted.Usage); + } + + /// Revalidates Geoapify verification authority without requiring selection or verified state. + public async Task IsGeoapifyVerificationCurrentAsync( + PersonalProviderAuthoritySnapshot snapshot, CancellationToken cancellationToken = default) + { + var profile = await dbContext.Set().AsNoTracking() + .SingleOrDefaultAsync(item => item.UserId == snapshot.UserId && item.ProviderKey == "geoapify", cancellationToken); + return profile != null && profile.RevokedAt == null && profile.IsAuthorized(snapshot.Capability) + && profile.CredentialGeneration == snapshot.CredentialGeneration + && (snapshot.Capability == PersonalProviderCapability.Geocoding + ? profile.GeocodingGeneration : profile.RoutingGeneration) == snapshot.CapabilityGeneration; + } + + /// Atomically records one Geoapify capability result only for the authority that contacted the provider. + public async Task TryRecordGeoapifyVerificationAsync( + PersonalProviderAuthoritySnapshot snapshot, PersonalProviderVerification verification, + CancellationToken cancellationToken = default) + { + if (snapshot.ProviderKey != "geoapify") return false; + var query = dbContext.Set().Where(profile => + profile.UserId == snapshot.UserId && profile.ProviderKey == "geoapify" && profile.RevokedAt == null + && profile.CredentialGeneration == snapshot.CredentialGeneration + && (snapshot.Capability == PersonalProviderCapability.Geocoding + ? profile.GeocodingAuthorized && profile.GeocodingGeneration == snapshot.CapabilityGeneration + : profile.RoutingAuthorized && profile.RoutingGeneration == snapshot.CapabilityGeneration)); + var profile = await query.SingleOrDefaultAsync(cancellationToken); + if (profile == null) return false; + credentials.RecordVerification(profile, snapshot.Capability, verification); + await dbContext.SaveChangesAsync(cancellationToken); + return true; + } + /// Revalidates bounded authority immediately before contact and result persistence. public async Task IsCurrentAsync( PersonalProviderAuthoritySnapshot snapshot, CancellationToken cancellationToken = default) @@ -174,8 +227,12 @@ private async Task AdmitGeoapifyAsync( dbContext.Set().Add(new() { UserId = userId, Credits = credits, Product = product, AdmittedAt = now }); - await dbContext.Set() - .Where(item => item.UserId == userId && item.AdmittedAt <= cutoff).ExecuteDeleteAsync(cancellationToken); + var expired = dbContext.Set() + .Where(item => item.UserId == userId && item.AdmittedAt <= cutoff); + if (dbContext.Database.IsRelational()) + await expired.ExecuteDeleteAsync(cancellationToken); + else + dbContext.RemoveRange(await expired.ToListAsync(cancellationToken)); await dbContext.SaveChangesAsync(cancellationToken); return await CompleteAsync(new(PersonalProviderAdmissionCategory.Admitted, null, new(used + credits, guard.CreditLimit, "credits", cutoff, null)), transaction, true, cancellationToken); diff --git a/tests/Wayfarer.Tests/Services/GeoapifyStageOneContractTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyStageOneContractTests.cs index 5d5112f9..d1454de3 100644 --- a/tests/Wayfarer.Tests/Services/GeoapifyStageOneContractTests.cs +++ b/tests/Wayfarer.Tests/Services/GeoapifyStageOneContractTests.cs @@ -95,7 +95,7 @@ public void RouteCost_RejectsUnsupportedBoundsAndOverflow() [InlineData("Περπάτημα")] public void DisplayNameNeverCreatesAProviderMapping(string displayName) { - var profile = new TransportProfile { Id = Guid.NewGuid(), Name = displayName }; + var profile = new TransportProfile { Id = Guid.NewGuid(), Label = displayName }; var configuration = new RoutingProviderConfiguration { Id = Guid.NewGuid(), AdapterType = RoutingAdapterType.Geoapify }; var resolution = ProviderTransportProfileResolver.Resolve(configuration, profile); @@ -106,14 +106,14 @@ public void DisplayNameNeverCreatesAProviderMapping(string displayName) [Fact] public void ExplicitMappingSurvivesRenameAndIsIndependentPerProvider() { - var profile = new TransportProfile { Id = Guid.NewGuid(), Name = "Family car" }; + var profile = new TransportProfile { Id = Guid.NewGuid(), Label = "Family car" }; var geoapify = Configuration(RoutingAdapterType.Geoapify, profile.Id, "drive"); var mapbox = Configuration(RoutingAdapterType.MapboxDirections, profile.Id, "mapbox/driving-traffic"); Assert.Equal("drive", ProviderTransportProfileResolver.Resolve(geoapify, profile).NativeMode); Assert.Equal("mapbox/driving-traffic", ProviderTransportProfileResolver.Resolve(mapbox, profile).NativeMode); - profile.Name = "Voiture familiale"; + profile.Label = "Voiture familiale"; Assert.Equal("drive", ProviderTransportProfileResolver.Resolve(geoapify, profile).NativeMode); Assert.Equal("mapbox/driving-traffic", ProviderTransportProfileResolver.Resolve(mapbox, profile).NativeMode); @@ -122,7 +122,7 @@ public void ExplicitMappingSurvivesRenameAndIsIndependentPerProvider() [Fact] public void MissingAndUnsupportedMappingsAreRejectedBeforeCreditOrHttp() { - var profile = new TransportProfile { Id = Guid.NewGuid(), Name = "Custom" }; + var profile = new TransportProfile { Id = Guid.NewGuid(), Label = "Custom" }; var configuration = Configuration(RoutingAdapterType.Geoapify, profile.Id, "hovercraft"); var ledger = new PersonalProviderUsageLedger(); var handler = new RecordingHandler(ValidRouteJson); @@ -142,11 +142,11 @@ public void MappingVersionParticipatesInStableAuthority() { var profileId = Guid.NewGuid(); var configuration = Configuration(RoutingAdapterType.Geoapify, profileId, "walk"); - var first = ProviderTransportProfileResolver.Resolve(configuration, new TransportProfile { Id = profileId, Name = "A" }); + var first = ProviderTransportProfileResolver.Resolve(configuration, new TransportProfile { Id = profileId, Label = "A" }); configuration.ProfileMappings.Single().SetNativeMode("bicycle"); configuration.MarkConfigurationChanged(); - var second = ProviderTransportProfileResolver.Resolve(configuration, new TransportProfile { Id = profileId, Name = "A" }); + var second = ProviderTransportProfileResolver.Resolve(configuration, new TransportProfile { Id = profileId, Label = "A" }); Assert.NotEqual(first.Authority, second.Authority); Assert.Equal("bicycle", second.NativeMode); From 404db51b7561498e2679216da6c4c3901b7ac743 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:43:40 +0300 Subject: [PATCH 03/29] WIP: cover persistent Geoapify enrichment (checkpoint; tests failing) --- .../GeoapifyReverseGeocodingAdapterTests.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/Wayfarer.Tests/Services/GeoapifyReverseGeocodingAdapterTests.cs diff --git a/tests/Wayfarer.Tests/Services/GeoapifyReverseGeocodingAdapterTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyReverseGeocodingAdapterTests.cs new file mode 100644 index 00000000..52e571ef --- /dev/null +++ b/tests/Wayfarer.Tests/Services/GeoapifyReverseGeocodingAdapterTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Wayfarer.Areas.Api.Controllers; +using Wayfarer.Parsers; +using Wayfarer.Services.LocationProviders; +using Xunit; + +namespace Wayfarer.Tests.Services; + +/// Proves Geoapify reverse parsing, request shape, and failure containment with fake HTTP. +public sealed class GeoapifyReverseGeocodingAdapterTests +{ + [Fact] + public async Task ValidFeatureMapsExactPersistentFields() + { + const string json = """ + {"type":"FeatureCollection","features":[{"type":"Feature","properties":{ + "formatted":"12 Main Street, Town","address_line1":"12 Main Street","housenumber":"12", + "street":"Main Street","postcode":"12345","city":"Town","state":"Region","country":"Country"}}]} + """; + var handler = new FakeHandler(json); + var adapter = new GeoapifyReverseGeocodingAdapter(new HttpClient(handler)); + + var result = await adapter.ReverseAsync(10.5, 20.25, "secret"); + + Assert.True(result.Succeeded); + Assert.Equal("12 Main Street, Town", result.Value!.FullAddress); + Assert.Equal("12 Main Street", result.Value.Address); + Assert.Equal("12", result.Value.AddressNumber); + Assert.Equal("Main Street", result.Value.StreetName); + Assert.Equal("12345", result.Value.PostCode); + Assert.Equal("Town", result.Value.Place); + Assert.Equal("Region", result.Value.Region); + Assert.Equal("Country", result.Value.Country); + Assert.Equal("api.geoapify.com", handler.Uri!.Host); + Assert.Equal("/v1/geocode/reverse", handler.Uri.AbsolutePath); + Assert.Contains("format=geojson&lang=en&limit=1", handler.Uri.Query, StringComparison.Ordinal); + } + + [Theory] + [InlineData("{\"type\":\"FeatureCollection\",\"features\":[]}")] + [InlineData("{\"type\":\"FeatureCollection\"}")] + [InlineData("{\"type\":\"FeatureCollection\",\"features\":[{\"properties\":{}}]}")] + public async Task EmptyOrMalformedResponseNeverProducesPersistenceAuthority(string json) + { + var result = await new GeoapifyReverseGeocodingAdapter(new HttpClient(new FakeHandler(json))) + .ReverseAsync(10, 20, "secret"); + + Assert.False(result.Succeeded); + Assert.Null(result.Authority); + } + + private sealed class FakeHandler(string json) : HttpMessageHandler + { + public Uri? Uri { get; private set; } + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Uri = request.RequestUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(json) }); + } + } +} From ee65b8f08439b82f5a3251150f16e4b72bdd2cd4 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:45:49 +0300 Subject: [PATCH 04/29] feat(geocoding): add persistent Geoapify enrichment --- .../GeoapifyReverseGeocodingAdapter.cs | 91 +++++++++++++++++++ .../PersonalProviderContactGate.cs | 17 ++++ Services/ReverseGeocodingService.cs | 17 ++-- Services/TripEditorPlaceMutationService.cs | 21 +++-- 4 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 Services/LocationProviders/GeoapifyReverseGeocodingAdapter.cs diff --git a/Services/LocationProviders/GeoapifyReverseGeocodingAdapter.cs b/Services/LocationProviders/GeoapifyReverseGeocodingAdapter.cs new file mode 100644 index 00000000..697be936 --- /dev/null +++ b/Services/LocationProviders/GeoapifyReverseGeocodingAdapter.cs @@ -0,0 +1,91 @@ +using System.Globalization; +using System.Net; +using System.Text.Json; +using Wayfarer.Parsers; + +namespace Wayfarer.Services.LocationProviders; + +/// Contacts and parses the fixed Geoapify persistent reverse-geocoding contract. +public sealed class GeoapifyReverseGeocodingAdapter(HttpClient httpClient) +{ + private const int ResponseLimit = 262_144; + private const int ValueLimit = 500; + + /// Returns one complete normalized result or a bounded failure without leaking request data. + public async Task ReverseAsync(double latitude, double longitude, string credential, + CancellationToken cancellationToken = default) + { + if (!double.IsFinite(latitude) || !double.IsFinite(longitude) + || latitude is < -90 or > 90 || longitude is < -180 or > 180) + return ReverseGeocodingResult.Failure(ReverseGeocodingCategory.InvalidRequest); + try + { + using var request = new HttpRequestMessage(HttpMethod.Get, BuildUri(latitude, longitude, credential)); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + if (response.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) + return ReverseGeocodingResult.Failure(ReverseGeocodingCategory.Authorization); + if ((int)response.StatusCode == 429) + return ReverseGeocodingResult.Unavailable(ReverseGeocodingCategory.RateLimited); + if (!response.IsSuccessStatusCode) + return ReverseGeocodingResult.Unavailable(ReverseGeocodingCategory.ProviderUnavailable); + if (response.Content.Headers.ContentLength > ResponseLimit) + return ReverseGeocodingResult.Failure(ReverseGeocodingCategory.InvalidResponse); + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + if (bytes.Length > ResponseLimit) + return ReverseGeocodingResult.Failure(ReverseGeocodingCategory.InvalidResponse); + using var document = JsonDocument.Parse(bytes); + return Parse(document.RootElement); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } + catch (TaskCanceledException) { return ReverseGeocodingResult.Unavailable(ReverseGeocodingCategory.ProviderUnavailable); } + catch (HttpRequestException) { return ReverseGeocodingResult.Unavailable(ReverseGeocodingCategory.ProviderUnavailable); } + catch (JsonException) { return ReverseGeocodingResult.Failure(ReverseGeocodingCategory.InvalidResponse); } + } + + private static Uri BuildUri(double latitude, double longitude, string credential) => new( + "https://api.geoapify.com/v1/geocode/reverse?lat=" + latitude.ToString("R", CultureInfo.InvariantCulture) + + "&lon=" + longitude.ToString("R", CultureInfo.InvariantCulture) + + "&format=geojson&lang=en&limit=1&apiKey=" + Uri.EscapeDataString(credential)); + + private static ReverseGeocodingResult Parse(JsonElement root) + { + if (root.ValueKind != JsonValueKind.Object + || !root.TryGetProperty("type", out var type) || type.GetString() != "FeatureCollection" + || !root.TryGetProperty("features", out var features) || features.ValueKind != JsonValueKind.Array + || features.GetArrayLength() == 0 || features[0].ValueKind != JsonValueKind.Object + || !features[0].TryGetProperty("properties", out var properties) + || properties.ValueKind != JsonValueKind.Object) + return ReverseGeocodingResult.Failure(ReverseGeocodingCategory.InvalidResponse); + + var formatted = Read(properties, "formatted"); + var line = Read(properties, "address_line1"); + var number = Read(properties, "housenumber"); + var street = Read(properties, "street"); + if (string.IsNullOrEmpty(formatted) && string.IsNullOrEmpty(line)) + return ReverseGeocodingResult.Failure(ReverseGeocodingCategory.InvalidResponse); + var address = line ?? Join(number, street) ?? formatted!; + return ReverseGeocodingResult.Success(new ReverseLocationResults + { + FullAddress = formatted ?? line!, Address = address, AddressNumber = number ?? string.Empty, + StreetName = street ?? string.Empty, PostCode = Read(properties, "postcode") ?? string.Empty, + Place = First(properties, "city", "town", "village", "municipality", "county"), + Region = First(properties, "state", "state_district", "county"), + Country = Read(properties, "country") ?? string.Empty + }); + } + + private static string? Read(JsonElement properties, string name) + { + if (!properties.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String) + return null; + var trimmed = value.GetString()?.Trim(); + if (string.IsNullOrEmpty(trimmed)) return null; + return trimmed.Length <= ValueLimit ? trimmed : trimmed[..ValueLimit]; + } + + private static string? First(JsonElement properties, params string[] names) => + names.Select(name => Read(properties, name)).FirstOrDefault(value => value != null); + + private static string? Join(string? number, string? street) => number == null ? street + : street == null ? number : $"{number} {street}"; +} diff --git a/Services/LocationProviders/PersonalProviderContactGate.cs b/Services/LocationProviders/PersonalProviderContactGate.cs index bf45a6df..9720bfc6 100644 --- a/Services/LocationProviders/PersonalProviderContactGate.cs +++ b/Services/LocationProviders/PersonalProviderContactGate.cs @@ -10,6 +10,23 @@ public sealed class PersonalProviderContactGate( ApplicationDbContext dbContext, PersonalProviderCredentialService credentials, LegacyMapboxMigrationService legacyMigration, IConfiguration configuration) { + /// Resolves the selected geocoding provider and admits its exact persistent product cost. + public async Task AdmitPersistentGeocodingAsync( + string userId, CancellationToken cancellationToken = default) + { + var selection = await dbContext.Set().AsNoTracking() + .SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); + var product = selection?.GeocodingProviderKey switch + { + "geoapify" => PersonalProviderProduct.Geocoding, + "mapbox" => PersonalProviderProduct.PermanentGeocoding, + _ => (PersonalProviderProduct?)null + }; + return product.HasValue + ? await AdmitAsync(userId, PersonalProviderCapability.Geocoding, product.Value, 1, cancellationToken) + : PersonalProviderAdmission.Rejected(PersonalProviderAdmissionCategory.NoProviderSelected); + } + /// Resolves current authority and durably admits the caller's validated provider-native cost. public async Task AdmitAsync( string userId, PersonalProviderCapability capability, PersonalProviderProduct product, diff --git a/Services/ReverseGeocodingService.cs b/Services/ReverseGeocodingService.cs index 09ce26cf..c7becf7e 100644 --- a/Services/ReverseGeocodingService.cs +++ b/Services/ReverseGeocodingService.cs @@ -181,6 +181,7 @@ public class ReverseGeocodingService private readonly ILogger _logger; private readonly PersonalProviderContactGate? _contactGate; private readonly ApplicationDbContext? _dbContext; + private readonly GeoapifyReverseGeocodingAdapter _geoapify; public ReverseGeocodingService(HttpClient httpClient, ILogger logger, PersonalProviderContactGate? contactGate = null, ApplicationDbContext? dbContext = null) @@ -189,6 +190,7 @@ public ReverseGeocodingService(HttpClient httpClient, ILogger _logger = logger; _contactGate = contactGate; _dbContext = dbContext; + _geoapify = new GeoapifyReverseGeocodingAdapter(httpClient); } /// Returns one generation-bound, admitted Permanent enrichment. @@ -198,14 +200,15 @@ public async Task EnrichAsync(string userId, double lati if (_contactGate == null || !double.IsFinite(latitude) || !double.IsFinite(longitude) || latitude is < -90 or > 90 || longitude is < -180 or > 180) return ReverseGeocodingResult.Unavailable(ReverseGeocodingCategory.InvalidRequest); - var admission = await _contactGate.AdmitAsync(userId, PersonalProviderCapability.Geocoding, - PersonalProviderProduct.PermanentGeocoding, 1, cancellationToken); + var admission = await _contactGate.AdmitPersistentGeocodingAsync(userId, cancellationToken); if (!admission.Succeeded) return ReverseGeocodingResult.Unavailable(MapAdmission(admission.Category)); var authority = admission.Authority!; if (!await _contactGate.IsCurrentAsync(authority, cancellationToken)) return ReverseGeocodingResult.Unavailable(ReverseGeocodingCategory.StaleAuthority); - var result = await ContactAsync(latitude, longitude, authority.Credential, cancellationToken, false); - if (result.Category == ReverseGeocodingCategory.Authorization) + var result = authority.ProviderKey == "geoapify" + ? await _geoapify.ReverseAsync(latitude, longitude, authority.Credential, cancellationToken) + : await ContactAsync(latitude, longitude, authority.Credential, cancellationToken, false); + if (result.Category == ReverseGeocodingCategory.Authorization && authority.ProviderKey == "mapbox") await RecordMapboxAuthorizationFailureAsync(userId, cancellationToken); if (!result.Succeeded) return result; if (!await _contactGate.IsCurrentAsync(authority, cancellationToken)) @@ -387,7 +390,7 @@ public sealed record ReverseGeocodingResult(ReverseGeocodingCategory Category, R public static ReverseGeocodingResult Unavailable(ReverseGeocodingCategory category) => new(category, null, null); public static ReverseGeocodingResult Failure(ReverseGeocodingCategory category) => new(category, null, null); - /// Atomically applies a complete successful Mapbox enrichment and provenance. + /// Atomically applies a complete successful enrichment and provider-specific provenance. public bool ApplyTo(Location location, DateTimeOffset persistedAt) { if (!Succeeded || Value == null) return false; @@ -395,7 +398,9 @@ public bool ApplyTo(Location location, DateTimeOffset persistedAt) location.AddressNumber = Value.AddressNumber; location.StreetName = Value.StreetName; location.PostCode = Value.PostCode; location.Place = Value.Place; location.Region = Value.Region; location.Country = Value.Country; - location.ReverseGeocodingProvider = "mapbox"; location.ReverseGeocodingStorageMode = "permanent"; + var provider = Authority?.ProviderKey ?? "mapbox"; + location.ReverseGeocodingProvider = provider; + location.ReverseGeocodingStorageMode = provider == "geoapify" ? "persistent" : "permanent"; location.ReverseGeocodedAt = persistedAt.ToUniversalTime(); return true; } diff --git a/Services/TripEditorPlaceMutationService.cs b/Services/TripEditorPlaceMutationService.cs index 143724ac..14763011 100644 --- a/Services/TripEditorPlaceMutationService.cs +++ b/Services/TripEditorPlaceMutationService.cs @@ -88,7 +88,7 @@ public async Task Warnings, bool Enriched)> ResolveAddressAsync( + private async Task<(string Value, IReadOnlyList Warnings, string? ProviderKey)> ResolveAddressAsync( string userId, Guid placeId, string? manualAddress, @@ -322,22 +322,23 @@ public async Task(), false); + return (fallback, Array.Empty(), null); } var result = await _reverseGeocodingService.EnrichAsync(userId, location.Latitude, location.Longitude, ReverseGeocodingIntent.PlaceAddress, cancellationToken); var address = result.Value == null ? null : string.IsNullOrWhiteSpace(result.Value.FullAddress) ? result.Value.Address : result.Value.FullAddress; return string.IsNullOrWhiteSpace(address) - ? (fallback, ReverseGeocodeWarning(placeId), false) - : (address.Trim(), Array.Empty(), true); + ? (fallback, ReverseGeocodeWarning(placeId), null) + : (address.Trim(), Array.Empty(), result.Authority?.ProviderKey ?? "mapbox"); } - private static void ApplyAddressProvenance(Place place, bool enriched) + private static void ApplyAddressProvenance(Place place, string? providerKey) { - place.AddressEnrichmentProvider = enriched ? "mapbox" : null; - place.AddressEnrichmentStorageMode = enriched ? "permanent" : null; - place.AddressEnrichedAt = enriched ? DateTimeOffset.UtcNow : null; + place.AddressEnrichmentProvider = providerKey; + place.AddressEnrichmentStorageMode = providerKey == "geoapify" ? "persistent" + : providerKey == "mapbox" ? "permanent" : null; + place.AddressEnrichedAt = providerKey != null ? DateTimeOffset.UtcNow : null; } private static IReadOnlyList ReverseGeocodeWarning(Guid placeId) => From 32dbb286dd96b83a92e944fc7ca33f0091433240 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:46:47 +0300 Subject: [PATCH 05/29] WIP: cover bounded Geoapify backfill (checkpoint; tests failing) --- .../Services/GeoapifyLocationBackfillTests.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 tests/Wayfarer.Tests/Services/GeoapifyLocationBackfillTests.cs diff --git a/tests/Wayfarer.Tests/Services/GeoapifyLocationBackfillTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyLocationBackfillTests.cs new file mode 100644 index 00000000..7b9c8983 --- /dev/null +++ b/tests/Wayfarer.Tests/Services/GeoapifyLocationBackfillTests.cs @@ -0,0 +1,59 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using NetTopologySuite.Geometries; +using Wayfarer.Models; +using Wayfarer.Services.LocationProviders; +using Xunit; + +namespace Wayfarer.Tests.Services; + +/// Locks bounded, ordered, domain-state-resumable Geoapify backfill selection. +public sealed class GeoapifyLocationBackfillTests +{ + [Fact] + public async Task CandidateSelectionIsOwnedChronologicalWhollyEmptyAndBounded() + { + await using var db = CreateDb(); + for (var index = 0; index < 105; index++) db.Locations.Add(Location("user", index)); + db.Locations.Add(Location("other", -1)); + var manual = Location("user", -2); manual.Place = "Manual"; db.Locations.Add(manual); + await db.SaveChangesAsync(); + + var candidates = await GeoapifyLocationBackfillService.LoadCandidateIdsAsync(db, "user", 100); + + Assert.Equal(100, candidates.Count); + Assert.Equal(Enumerable.Range(0, 100).Select(index => index + 1), candidates); + } + + [Fact] + public void CandidatePredicateRejectsAnyFieldOrProvenance() + { + var fields = new Action[] + { + value => value.Address = "x", value => value.FullAddress = "x", value => value.AddressNumber = "x", + value => value.StreetName = "x", value => value.PostCode = "x", value => value.Place = "x", + value => value.Region = "x", value => value.Country = "x", + value => value.ReverseGeocodingProvider = "geoapify", + value => value.ReverseGeocodingStorageMode = "persistent", + value => value.ReverseGeocodedAt = DateTimeOffset.UtcNow + }; + + Assert.All(fields, mutate => + { + var location = Location("user", 0); mutate(location); + Assert.False(GeoapifyLocationBackfillService.IsWhollyUnenriched(location)); + }); + } + + private static Location Location(string userId, int minute) => new() + { + UserId = userId, Timestamp = new DateTime(2026, 1, 1).AddMinutes(minute), LocalTimestamp = new DateTime(2026, 1, 1), + TimeZoneId = "UTC", Coordinates = new Point(20, 10) + }; + + private static ApplicationDbContext CreateDb() + { + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + return new ApplicationDbContext(options, new ServiceCollection().BuildServiceProvider()); + } +} From e6764f4745e4e66a13dec35c1d05905b6ad6c09c Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:48:41 +0300 Subject: [PATCH 06/29] feat(geocoding): add bounded Geoapify backfill --- .../LocationProviderSettingsController.cs | 37 ++++++++- .../LocationProviderSettings/Index.cshtml | 14 ++++ Program.cs | 2 + .../GeoapifyLocationBackfillService.cs | 80 +++++++++++++++++++ .../Services/GeoapifyLocationBackfillTests.cs | 1 + 5 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 Services/LocationProviders/GeoapifyLocationBackfillService.cs diff --git a/Areas/User/Controllers/LocationProviderSettingsController.cs b/Areas/User/Controllers/LocationProviderSettingsController.cs index a5655a99..237898ba 100644 --- a/Areas/User/Controllers/LocationProviderSettingsController.cs +++ b/Areas/User/Controllers/LocationProviderSettingsController.cs @@ -14,7 +14,9 @@ namespace Wayfarer.Areas.User.Controllers; [Area("User"), Authorize(Roles = "User")] public sealed class LocationProviderSettingsController( ApplicationDbContext dbContext, PersonalProviderCredentialService credentials, - LegacyMapboxMigrationService migration, ReverseGeocodingService reverseGeocoding) : Controller + LegacyMapboxMigrationService migration, ReverseGeocodingService reverseGeocoding, + GeoapifyVerificationService? geoapifyVerification = null, + GeoapifyLocationBackfillService? geoapifyBackfill = null) : Controller { /// Displays masked provider authority and provider-native usage status. public async Task Index(CancellationToken cancellationToken) @@ -52,7 +54,11 @@ public async Task SaveProfile(LocationProviderProfileInput input, && (provider != PersonalLocationProvider.Mapbox || profile.HasCurrentPermanentGeocodingConsent())) selection.Select(PersonalProviderCapability.Geocoding, provider); else if (selection.GeocodingProviderKey == key) selection.Select(PersonalProviderCapability.Geocoding, null); - if (input.ActiveForRouting && input.RoutingAuthorized) selection.Select(PersonalProviderCapability.Routing, provider); + var routingVerified = profile.RoutingVerification == PersonalProviderVerification.Verified + && profile.RoutingVerifiedCredentialGeneration == profile.CredentialGeneration + && profile.RoutingVerifiedConfigurationGeneration == profile.RoutingGeneration; + if (input.ActiveForRouting && input.RoutingAuthorized && routingVerified) + selection.Select(PersonalProviderCapability.Routing, provider); else if (selection.RoutingProviderKey == key) selection.Select(PersonalProviderCapability.Routing, null); await dbContext.SaveChangesAsync(cancellationToken); return RedirectToAction(nameof(Index)); @@ -85,6 +91,33 @@ public async Task VerifyMapboxPermanent(CancellationToken cancell return RedirectToAction(nameof(Index)); } + /// Runs one explicit Geoapify capability verification without changing selection. + [HttpPost, ValidateAntiForgeryToken] + public async Task VerifyGeoapify(PersonalProviderCapability capability, CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Challenge(); + var result = geoapifyVerification == null ? PersonalProviderVerification.Unavailable + : capability == PersonalProviderCapability.Geocoding + ? await geoapifyVerification.VerifyGeocodingAsync(userId, cancellationToken) + : await geoapifyVerification.VerifyRoutingAsync(userId, cancellationToken); + TempData["ProviderStatus"] = $"Geoapify {capability.ToString().ToLowerInvariant()} verification: {result}. No provider was selected automatically."; + return RedirectToAction(nameof(Index)); + } + + /// Runs one explicit bounded Location backfill for the authenticated owner. + [HttpPost, ValidateAntiForgeryToken] + public async Task BackfillGeoapify(CancellationToken cancellationToken) + { + var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); + if (userId == null) return Challenge(); + if (geoapifyBackfill == null) return RedirectToAction(nameof(Index)); + var result = await geoapifyBackfill.RunAsync(userId, cancellationToken); + TempData["ProviderStatus"] = $"Backfill scanned {result.Scanned}, enriched {result.Succeeded}, no result {result.NoResult}, unavailable {result.Unavailable}, remaining {result.RemainingEstimate}." + + (result.Exhausted ? " The rolling safety guard is exhausted; retry after admitted credits age out." : string.Empty); + return RedirectToAction(nameof(Index)); + } + /// Explicitly revokes one credential without deleting profiles, usage, or domain data. [HttpPost, ValidateAntiForgeryToken] public async Task Revoke(string providerKey, bool confirmed, CancellationToken cancellationToken) diff --git a/Areas/User/Views/LocationProviderSettings/Index.cshtml b/Areas/User/Views/LocationProviderSettings/Index.cshtml index 4ef88d38..0a5279f6 100644 --- a/Areas/User/Views/LocationProviderSettings/Index.cshtml +++ b/Areas/User/Views/LocationProviderSettings/Index.cshtml @@ -56,6 +56,20 @@
+
+
+ + +
+
+ + +
+
+

Backfill processes at most 100 of your wholly unenriched Locations in chronological order. Existing, manual, and imported enrichment is preserved.

+
+ +
} @if (profile.ProviderKey == "mapbox") { diff --git a/Program.cs b/Program.cs index ff177c0a..47c5a647 100644 --- a/Program.cs +++ b/Program.cs @@ -491,6 +491,7 @@ static void ConfigureServices(WebApplicationBuilder builder) builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); // IRegistrationService as a transient or singleton service builder.Services.AddTransient(); @@ -524,6 +525,7 @@ static void ConfigureServices(WebApplicationBuilder builder) // Reverse geocoding Mapbox service // Query-string authentication and coordinates must never enter default HTTP diagnostics. builder.Services.AddHttpClient().RemoveAllLoggers(); + builder.Services.AddHttpClient().RemoveAllLoggers(); // Tile Cache service — typed HttpClient with OSM-compliant headers. // Manual redirects are handled in TileCacheService.SendTileRequestAsync. diff --git a/Services/LocationProviders/GeoapifyLocationBackfillService.cs b/Services/LocationProviders/GeoapifyLocationBackfillService.cs new file mode 100644 index 00000000..3c7cd4ab --- /dev/null +++ b/Services/LocationProviders/GeoapifyLocationBackfillService.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore; +using Wayfarer.Models; +using Wayfarer.Parsers; + +namespace Wayfarer.Services.LocationProviders; + +/// Runs one explicit bounded and resumable Geoapify Location enrichment invocation. +public sealed class GeoapifyLocationBackfillService( + ApplicationDbContext dbContext, ReverseGeocodingService reverseGeocoding) +{ + /// Gets the strict maximum records scanned by one invocation. + public const int MaximumRecords = 100; + + /// Runs one user-owned chronological invocation and returns content-free progress. + public async Task RunAsync(string userId, CancellationToken cancellationToken = default) + { + var ids = await LoadCandidateIdsAsync(dbContext, userId, MaximumRecords, cancellationToken); + var scanned = 0; var succeeded = 0; var noResult = 0; var unavailable = 0; var exhausted = false; + foreach (var id in ids) + { + cancellationToken.ThrowIfCancellationRequested(); + var location = await dbContext.Locations.SingleAsync( + item => item.Id == id && item.UserId == userId, cancellationToken); + if (!IsWhollyUnenriched(location)) continue; + scanned++; + var result = await reverseGeocoding.EnrichAsync(userId, + location.Coordinates.Y, location.Coordinates.X, + ReverseGeocodingIntent.ImportMissingAddress, cancellationToken); + if (result.Category == ReverseGeocodingCategory.Exhausted) { exhausted = true; break; } + if (result.Category is ReverseGeocodingCategory.Unauthorized or ReverseGeocodingCategory.CredentialRequired + or ReverseGeocodingCategory.NoProviderSelected or ReverseGeocodingCategory.VerificationRequired + or ReverseGeocodingCategory.StaleAuthority) break; + if (!result.Succeeded) + { + if (result.Category == ReverseGeocodingCategory.InvalidResponse) noResult++; else unavailable++; + continue; + } + await dbContext.Entry(location).ReloadAsync(cancellationToken); + if (!IsWhollyUnenriched(location)) continue; + if (result.ApplyTo(location, DateTimeOffset.UtcNow)) + { + await dbContext.SaveChangesAsync(cancellationToken); + succeeded++; + } + } + var remaining = await WhollyUnenriched(dbContext.Locations.Where(item => item.UserId == userId)) + .CountAsync(cancellationToken); + return new(scanned, succeeded, noResult, unavailable, remaining, exhausted); + } + + /// Loads only stable candidate identities in chronological order. + public static Task> LoadCandidateIdsAsync(ApplicationDbContext dbContext, string userId, int limit, + CancellationToken cancellationToken = default) + { + if (limit is < 1 or > MaximumRecords) throw new ArgumentOutOfRangeException(nameof(limit)); + return WhollyUnenriched(dbContext.Locations.Where(item => item.UserId == userId)) + .OrderBy(item => item.Timestamp).ThenBy(item => item.Id).Select(item => item.Id).Take(limit) + .ToListAsync(cancellationToken); + } + + /// Returns whether every enrichment and provenance field is empty. + public static bool IsWhollyUnenriched(Location value) => string.IsNullOrWhiteSpace(value.Address) + && string.IsNullOrWhiteSpace(value.FullAddress) && string.IsNullOrWhiteSpace(value.AddressNumber) + && string.IsNullOrWhiteSpace(value.StreetName) && string.IsNullOrWhiteSpace(value.PostCode) + && string.IsNullOrWhiteSpace(value.Place) && string.IsNullOrWhiteSpace(value.Region) + && string.IsNullOrWhiteSpace(value.Country) && value.ReverseGeocodingProvider == null + && value.ReverseGeocodingStorageMode == null && value.ReverseGeocodedAt == null; + + private static IQueryable WhollyUnenriched(IQueryable query) => query.Where(value => + (value.Address == null || value.Address == "") && (value.FullAddress == null || value.FullAddress == "") + && (value.AddressNumber == null || value.AddressNumber == "") && (value.StreetName == null || value.StreetName == "") + && (value.PostCode == null || value.PostCode == "") && (value.Place == null || value.Place == "") + && (value.Region == null || value.Region == "") && (value.Country == null || value.Country == "") + && value.ReverseGeocodingProvider == null && value.ReverseGeocodingStorageMode == null + && value.ReverseGeocodedAt == null); +} + +/// Contains bounded content-free progress for one explicit backfill invocation. +public sealed record GeoapifyBackfillResult( + int Scanned, int Succeeded, int NoResult, int Unavailable, int RemainingEstimate, bool Exhausted); diff --git a/tests/Wayfarer.Tests/Services/GeoapifyLocationBackfillTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyLocationBackfillTests.cs index 7b9c8983..bd48037d 100644 --- a/tests/Wayfarer.Tests/Services/GeoapifyLocationBackfillTests.cs +++ b/tests/Wayfarer.Tests/Services/GeoapifyLocationBackfillTests.cs @@ -4,6 +4,7 @@ using Wayfarer.Models; using Wayfarer.Services.LocationProviders; using Xunit; +using Location = Wayfarer.Models.Location; namespace Wayfarer.Tests.Services; From 4ce201a6a96c53c49311d86d7dc4b40cead9165e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:49:44 +0300 Subject: [PATCH 07/29] WIP: cover Geoapify routing adapter (checkpoint; tests failing) --- .../Services/GeoapifyRoutingAdapterTests.cs | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/Wayfarer.Tests/Services/GeoapifyRoutingAdapterTests.cs diff --git a/tests/Wayfarer.Tests/Services/GeoapifyRoutingAdapterTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyRoutingAdapterTests.cs new file mode 100644 index 00000000..93bd2219 --- /dev/null +++ b/tests/Wayfarer.Tests/Services/GeoapifyRoutingAdapterTests.cs @@ -0,0 +1,54 @@ +using System.Net; +using Wayfarer.Services.ExternalRouting; +using Xunit; + +namespace Wayfarer.Tests.Services; + +/// Locks Geoapify request and complete normalized route parsing. +public sealed class GeoapifyRoutingAdapterTests +{ + [Fact] + public void RequestUsesExactClosedModeAndPreservesOrderedWaypoints() + { + var request = GeoapifyRoutingAdapter.BuildRelativeRequest("drive", + [new(20, 10), new(21, 11), new(22, 12)], "secret"); + + Assert.StartsWith("v1/routing?waypoints=10,20%7C11,21%7C12,22&mode=drive", request, StringComparison.Ordinal); + Assert.Contains("details=instruction_details&type=balanced&traffic=free_flow", request, StringComparison.Ordinal); + Assert.Contains("intermediate_waypoint_mode=stopover", request, StringComparison.Ordinal); + Assert.EndsWith("apiKey=secret", request, StringComparison.Ordinal); + } + + [Fact] + public async Task CompleteRouteNormalizesGeometryMetricsAndInstructions() + { + using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(ValidJson) }; + + var result = await GeoapifyRoutingAdapter.ParseAsync(response, [new(20, 10), new(21, 11)]); + + Assert.True(result.Succeeded); + Assert.Equal(1234, result.DistanceMetres); + Assert.Equal(321, result.DurationSeconds); + Assert.Equal(2, result.Geometry.Count); + Assert.Single(result.Instructions); + Assert.Equal("Continue", result.Instructions[0].Text); + } + + [Fact] + public async Task PartialOrWrongAnchorRouteFailsClosed() + { + using var response = new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(ValidJson.Replace("[21,11]", "[30,30]", StringComparison.Ordinal)) }; + + var result = await GeoapifyRoutingAdapter.ParseAsync(response, [new(20, 10), new(21, 11)]); + + Assert.False(result.Succeeded); + Assert.Empty(result.Geometry); + } + + private const string ValidJson = """ + {"results":[{"distance":1234,"time":321,"geometry":{"type":"LineString","coordinates":[[20,10],[21,11]]}, + "legs":[{"distance":1234,"time":321,"steps":[{"instruction":{"text":"Continue","type":"Straight"}, + "from_index":0,"to_index":1,"distance":1234,"time":321}]}]}]} + """; +} From efabd210adaa02f7545af6b2dc6831bc494c9792 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:54:33 +0300 Subject: [PATCH 08/29] feat(routing): add Geoapify routing and profile mappings --- .../Controllers/RoutingProviderController.cs | 6 +- .../Admin/Models/RoutingProviderViewModels.cs | 13 ++- .../Admin/Views/RoutingProvider/_Form.cshtml | 13 ++- .../AuthoritativeRoutingProviderResolver.cs | 78 ++++++++++--- ...ernalRoutingServiceCollectionExtensions.cs | 3 +- .../ExternalRouting/GeoapifyRoutingAdapter.cs | 110 ++++++++++++++++++ .../ExternalRouting/OsrmRoutingAdapter.cs | 10 +- .../ExternalRouting/ProviderRouteClient.cs | 43 +++++++ .../RoutingAttemptCoordinator.cs | 10 +- .../RoutingProviderAdministrationService.cs | 20 +++- .../RoutingProviderStateResolver.cs | 6 +- .../RoutingProviderVerifier.cs | 13 +++ 12 files changed, 292 insertions(+), 33 deletions(-) create mode 100644 Services/ExternalRouting/GeoapifyRoutingAdapter.cs create mode 100644 Services/ExternalRouting/ProviderRouteClient.cs diff --git a/Areas/Admin/Controllers/RoutingProviderController.cs b/Areas/Admin/Controllers/RoutingProviderController.cs index dc95c696..1e7754ba 100644 --- a/Areas/Admin/Controllers/RoutingProviderController.cs +++ b/Areas/Admin/Controllers/RoutingProviderController.cs @@ -40,7 +40,8 @@ public async Task Index(CancellationToken cancellationToken) /// Displays a new typed OSRM configuration. public async Task Create(CancellationToken cancellationToken) => - View(await PopulateMappingsAsync(new RoutingProviderEditViewModel(), cancellationToken)); + View(await PopulateMappingsAsync(new RoutingProviderEditViewModel + { VerificationFromLongitude = 0, VerificationFromLatitude = 0, VerificationToLongitude = 0.01, VerificationToLatitude = 0 }, cancellationToken)); /// Creates one allowlisted OSRM configuration. [HttpPost, ValidateAntiForgeryToken] @@ -145,7 +146,8 @@ private async Task PopulateMappingsAsync( private static RoutingProviderEditViewModel ToModel(RoutingProviderConfiguration provider) => new() { - Id = provider.Id, DisplayName = provider.DisplayName, BaseEndpoint = provider.BaseEndpoint ?? string.Empty, + Id = provider.Id, DisplayName = provider.DisplayName, AdapterType = provider.AdapterType, + BaseEndpoint = provider.BaseEndpoint ?? string.Empty, CredentialRequired = provider.CredentialRequired, CredentialPresent = provider.CredentialPresent, PersonalRoutingAccess = provider.PersonalRoutingAccess, Enabled = provider.Enabled, Attribution = provider.Attribution, diff --git a/Areas/Admin/Models/RoutingProviderViewModels.cs b/Areas/Admin/Models/RoutingProviderViewModels.cs index 29ba6643..3f36c75e 100644 --- a/Areas/Admin/Models/RoutingProviderViewModels.cs +++ b/Areas/Admin/Models/RoutingProviderViewModels.cs @@ -17,6 +17,9 @@ public sealed record RoutingProviderRowViewModel( /// Contains allowlisted OSRM configuration and mapping edit fields. public sealed class RoutingProviderEditViewModel : IValidatableObject { + /// Gets or sets the explicit adapter owned by this configuration. + [EnumDataType(typeof(RoutingAdapterType))] + public RoutingAdapterType AdapterType { get; set; } = RoutingAdapterType.OsrmCompatible; /// Gets or sets the provider identity for edits. public Guid Id { get; set; } @@ -96,7 +99,8 @@ public sealed class RoutingProviderEditViewModel : IValidatableObject /// Validates coordinates and credential-required completeness. public IEnumerable Validate(ValidationContext validationContext) { - if (CredentialRequired && !CredentialPresent && string.IsNullOrWhiteSpace(Credential)) + if (AdapterType == RoutingAdapterType.OsrmCompatible && CredentialRequired + && !CredentialPresent && string.IsNullOrWhiteSpace(Credential)) yield return new ValidationResult("A credential is required for this configuration.", [nameof(Credential)]); foreach (var (value, name, minimum, maximum) in new[] { @@ -105,10 +109,11 @@ public IEnumerable Validate(ValidationContext validationContex (VerificationToLongitude, nameof(VerificationToLongitude), -180d, 180d), (VerificationToLatitude, nameof(VerificationToLatitude), -90d, 90d) }) - if (value is not double coordinate || !double.IsFinite(coordinate) || coordinate < minimum || coordinate > maximum) + if (AdapterType == RoutingAdapterType.OsrmCompatible + && (value is not double coordinate || !double.IsFinite(coordinate) || coordinate < minimum || coordinate > maximum)) yield return new ValidationResult("A finite in-range verification coordinate is required.", [name]); - if (Mappings.Count(item => !string.IsNullOrWhiteSpace(item.OsrmProfile)) is 0 or > 8) - yield return new ValidationResult("Map between one and eight transport profiles.", [nameof(Mappings)]); + if (Mappings.Count(item => !string.IsNullOrWhiteSpace(item.OsrmProfile)) > 100) + yield return new ValidationResult("Too many transport-profile mappings.", [nameof(Mappings)]); } } diff --git a/Areas/Admin/Views/RoutingProvider/_Form.cshtml b/Areas/Admin/Views/RoutingProvider/_Form.cshtml index 5b3db9b6..a70d37f3 100644 --- a/Areas/Admin/Views/RoutingProvider/_Form.cshtml +++ b/Areas/Admin/Views/RoutingProvider/_Form.cshtml @@ -5,8 +5,8 @@
-
-
+
+
Geoapify always uses its fixed official endpoint; this value is ignored.
Leave blank to preserve the @(Model.CredentialPresent ? "stored credential" : "empty credential").
@@ -33,7 +33,14 @@
-
+
+ @if (Model.AdapterType == Wayfarer.Models.RoutingAdapterType.Geoapify) + { + + } + else + { } +
} diff --git a/Services/ExternalRouting/AuthoritativeRoutingProviderResolver.cs b/Services/ExternalRouting/AuthoritativeRoutingProviderResolver.cs index 2de2d588..a358b6d6 100644 --- a/Services/ExternalRouting/AuthoritativeRoutingProviderResolver.cs +++ b/Services/ExternalRouting/AuthoritativeRoutingProviderResolver.cs @@ -1,12 +1,15 @@ using Microsoft.EntityFrameworkCore; using Wayfarer.Models; +using Wayfarer.Models.LocationProviders; +using Wayfarer.Services.LocationProviders; namespace Wayfarer.Services.ExternalRouting; /// Resolves exactly one server-authoritative routing mode and its server-only execution data. public sealed class AuthoritativeRoutingProviderResolver( ApplicationDbContext dbContext, RoutingProviderCredentialService providerCredentials, - UserRoutingCredentialService userCredentials) + UserRoutingCredentialService userCredentials, + PersonalProviderCredentialService? personalCredentials = null) { /// Resolves the authenticated user's current mode for one active transport profile. public async Task ResolveAsync( @@ -24,6 +27,11 @@ private async Task ResolveAsync( var settings = await dbContext.ApplicationSettings.AsNoTracking() .SingleOrDefaultAsync(item => item.Id == 1, cancellationToken); if (settings?.ExternalRouteGenerationEnabled != true) return RoutingProviderResolutionResult.Disabled; + var personalSelection = await dbContext.Set().AsNoTracking() + .SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); + if (personalSelection?.RoutingProviderKey == "geoapify") + return await ResolveGeoapifyAsync(userId, transportProfileId, settings.ExternalRouteGenerationVersion, + requirePersonalVerification, cancellationToken); var userConfiguration = await dbContext.Set().AsNoTracking() .SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); if (userConfiguration == null) return RoutingProviderResolutionResult.Unavailable("user-routing-unavailable"); @@ -43,6 +51,48 @@ private async Task ResolveAsync( : ResolveServerDefault(userConfiguration, provider, mapping, settings.ExternalRouteGenerationVersion); } + private async Task ResolveGeoapifyAsync( + string userId, Guid transportProfileId, int featureVersion, bool requireVerification, + CancellationToken cancellationToken) + { + if (personalCredentials == null) + return RoutingProviderResolutionResult.Unavailable("personal-credential-unavailable"); + var profile = await dbContext.Set().AsNoTracking().SingleOrDefaultAsync( + item => item.UserId == userId && item.ProviderKey == "geoapify", cancellationToken); + if (profile == null || profile.RevokedAt != null || !profile.RoutingAuthorized) + return RoutingProviderResolutionResult.Unavailable("unauthorized"); + if (requireVerification && (profile.RoutingVerification != PersonalProviderVerification.Verified + || profile.RoutingVerifiedCredentialGeneration != profile.CredentialGeneration + || profile.RoutingVerifiedConfigurationGeneration != profile.RoutingGeneration)) + return RoutingProviderResolutionResult.Unavailable("verification-required"); + var credential = personalCredentials.Read(profile); + if (!credential.Succeeded) + return RoutingProviderResolutionResult.Unavailable("personal-credential-unavailable"); + var providers = await dbContext.Set().AsNoTracking() + .Include(item => item.ProfileMappings).ThenInclude(item => item.TransportProfile) + .Where(item => item.AdapterType == RoutingAdapterType.Geoapify && item.Enabled).ToListAsync(cancellationToken); + if (providers.Count != 1) + return RoutingProviderResolutionResult.Unavailable("temporarily-unavailable"); + var provider = providers[0]; + if (provider.VerifiedConfigurationVersion != provider.ConfigurationVersion) + return RoutingProviderResolutionResult.Unavailable("temporarily-unavailable"); + var transportProfile = provider.ProfileMappings.Select(item => item.TransportProfile) + .SingleOrDefault(item => item.Id == transportProfileId && item.IsActive); + if (transportProfile == null) + return RoutingProviderResolutionResult.Unavailable("unmapped-transport-profile"); + var mapping = ProviderTransportProfileResolver.Resolve(provider, transportProfile); + if (mapping.Category == ProviderTransportProfileCategory.Unmapped) + return RoutingProviderResolutionResult.Unavailable("unmapped-transport-profile"); + if (mapping.Category == ProviderTransportProfileCategory.Unsupported) + return RoutingProviderResolutionResult.Unavailable("unsupported-transport-profile"); + var operational = OperationalProvider(provider, "https://api.geoapify.com/"); + return new(RoutingProviderResolutionOutcome.ResolvedPersonal, null, false, + new(operational, mapping.NativeMode!, credential.Credential, RoutingProviderSelectionMode.Personal, + profile.RoutingGeneration, (uint)profile.CredentialGeneration, provider.ConfigurationVersion, + provider.RowVersion, featureVersion, provider.DisplayName, provider.ExternalCoordinateDisclosure, + provider.Attribution, userId)); + } + private RoutingProviderResolutionResult ResolvePersonal( UserRoutingConfiguration userConfiguration, RoutingProviderConfiguration provider, RoutingProviderProfileMapping? mapping, int featureVersion, bool requireVerification) @@ -92,17 +142,7 @@ private static RoutingProviderResolutionResult Resolved( UserRoutingConfiguration userConfiguration, RoutingProviderConfiguration provider, RoutingProviderProfileMapping mapping, int featureVersion, string? credential) { - var operationalProvider = new RoutingProviderConfiguration - { - Id = provider.Id, DisplayName = provider.DisplayName, AdapterType = provider.AdapterType, - BaseEndpoint = provider.BaseEndpoint, Enabled = provider.Enabled, - Attribution = provider.Attribution, ExternalCoordinateDisclosure = provider.ExternalCoordinateDisclosure, - ConfigurationVersion = provider.ConfigurationVersion, - VerifiedConfigurationVersion = provider.VerifiedConfigurationVersion, - GenerationTimeoutSeconds = provider.GenerationTimeoutSeconds, - ResponseSizeLimitBytes = provider.ResponseSizeLimitBytes, RequestsPerMinute = provider.RequestsPerMinute, - MinimumIntervalMilliseconds = provider.MinimumIntervalMilliseconds, MaxConcurrency = provider.MaxConcurrency - }; + var operationalProvider = OperationalProvider(provider, provider.BaseEndpoint); return new RoutingProviderResolutionResult(outcome, null, false, new ResolvedRoutingProviderExecution( operationalProvider, mapping.OsrmProfile, credential, mode, userConfiguration.ConfigurationVersion, @@ -110,6 +150,17 @@ private static RoutingProviderResolutionResult Resolved( provider.DisplayName, provider.ExternalCoordinateDisclosure, provider.Attribution)); } + private static RoutingProviderConfiguration OperationalProvider(RoutingProviderConfiguration provider, string? endpoint) => new() + { + Id = provider.Id, DisplayName = provider.DisplayName, AdapterType = provider.AdapterType, + BaseEndpoint = endpoint, Enabled = provider.Enabled, Attribution = provider.Attribution, + ExternalCoordinateDisclosure = provider.ExternalCoordinateDisclosure, + ConfigurationVersion = provider.ConfigurationVersion, VerifiedConfigurationVersion = provider.VerifiedConfigurationVersion, + GenerationTimeoutSeconds = provider.GenerationTimeoutSeconds, ResponseSizeLimitBytes = provider.ResponseSizeLimitBytes, + RequestsPerMinute = provider.RequestsPerMinute, MinimumIntervalMilliseconds = provider.MinimumIntervalMilliseconds, + MaxConcurrency = provider.MaxConcurrency + }; + private static RoutingProviderResolutionResult UnavailableForMode(bool personal) => personal ? RoutingProviderResolutionResult.Unavailable("personal-provider-unavailable") : RoutingProviderResolutionResult.ServerUnavailable("external-routing-unavailable"); @@ -143,7 +194,8 @@ public sealed record ResolvedRoutingProviderExecution( RoutingProviderConfiguration Provider, string Profile, string? Credential, RoutingProviderSelectionMode SelectionMode, int UserConfigurationVersion, uint UserRowVersion, int ProviderConfigurationVersion, uint ProviderRowVersion, int FeatureStateGeneration, - string DisplayName, string? Disclosure, string? Attribution); + string DisplayName, string? Disclosure, string? Attribution, + string? PersonalProviderUserId = null); /// Identifies the provider selection bound into protected proposals. public enum RoutingProviderSelectionMode { ServerDefault, Personal } diff --git a/Services/ExternalRouting/ExternalRoutingServiceCollectionExtensions.cs b/Services/ExternalRouting/ExternalRoutingServiceCollectionExtensions.cs index db924b9b..9026ea91 100644 --- a/Services/ExternalRouting/ExternalRoutingServiceCollectionExtensions.cs +++ b/Services/ExternalRouting/ExternalRoutingServiceCollectionExtensions.cs @@ -24,7 +24,8 @@ public static IServiceCollection AddExternalRouting(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Services/ExternalRouting/GeoapifyRoutingAdapter.cs b/Services/ExternalRouting/GeoapifyRoutingAdapter.cs new file mode 100644 index 00000000..37507217 --- /dev/null +++ b/Services/ExternalRouting/GeoapifyRoutingAdapter.cs @@ -0,0 +1,110 @@ +using System.Globalization; +using System.Net; +using System.Text.Json; + +namespace Wayfarer.Services.ExternalRouting; + +/// Owns the fixed Geoapify Routing request and complete untrusted response contract. +public static class GeoapifyRoutingAdapter +{ + private const int MaximumPoints = 10_000; + private const int MaximumInstructions = 5_000; + + /// Builds an exact bounded routing request from a validated closed mode. + public static string BuildRelativeRequest(string mode, IReadOnlyList coordinates, string credential) + { + if (!GeoapifyRouteCost.TryParse(mode, out _) || coordinates.Count is < 2 or > 25 + || coordinates.Any(coordinate => !coordinate.IsValid)) + throw new ArgumentException("The Geoapify routing request is invalid."); + var waypoints = string.Join("%7C", coordinates.Select(coordinate => + $"{coordinate.Latitude.ToString("R", CultureInfo.InvariantCulture)},{coordinate.Longitude.ToString("R", CultureInfo.InvariantCulture)}")); + var stopover = coordinates.Count > 2 ? "&intermediate_waypoint_mode=stopover" : string.Empty; + return $"v1/routing?waypoints={waypoints}&mode={mode}&format=json&lang=en&details=instruction_details" + + $"&type=balanced&traffic=free_flow{stopover}&apiKey={Uri.EscapeDataString(credential)}"; + } + + /// Parses exactly one complete route and validates every input anchor. + public static async Task ParseAsync(HttpResponseMessage response, + IReadOnlyList anchors, CancellationToken cancellationToken = default) + { + if (!response.IsSuccessStatusCode) return OsrmRouteResult.Invalid("provider-http-failure"); + try + { + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + var root = document.RootElement; + if (!root.TryGetProperty("results", out var results) || results.ValueKind != JsonValueKind.Array + || results.GetArrayLength() != 1) return Invalid(); + var route = results[0]; + if (!Number(route, "distance", out var distance) || !Number(route, "time", out var duration) + || duration > TimeSpan.FromDays(365).TotalSeconds + || !route.TryGetProperty("geometry", out var geometry) + || !geometry.TryGetProperty("type", out var type) || type.GetString() != "LineString" + || !geometry.TryGetProperty("coordinates", out var coordinates) + || ParseCoordinates(coordinates) is not { Count: >= 2 } points || points.Count > MaximumPoints + || !route.TryGetProperty("legs", out var legs) || legs.ValueKind != JsonValueKind.Array + || legs.GetArrayLength() != anchors.Count - 1) return Invalid(); + if (!Close(points[0], anchors[0]) || !Close(points[^1], anchors[^1])) return Invalid(); + foreach (var anchor in anchors) + if (!points.Any(point => Close(point, anchor))) return Invalid(); + var instructions = new List(); + foreach (var leg in legs.EnumerateArray()) + { + if (!Number(leg, "distance", out _) || !Number(leg, "time", out _) + || !leg.TryGetProperty("steps", out var steps) || steps.ValueKind != JsonValueKind.Array) return Invalid(); + foreach (var step in steps.EnumerateArray()) + { + if (instructions.Count == MaximumInstructions || !TryInstruction(step, out var instruction)) return Invalid(); + instructions.Add(instruction!); + } + } + if (instructions.Count == 0) return Invalid(); + return new(true, points, anchors.ToArray(), null, distance, duration, instructions); + } + catch (Exception exception) when (exception is JsonException or InvalidOperationException) + { return Invalid(); } + } + + private static List? ParseCoordinates(JsonElement value) + { + if (value.ValueKind != JsonValueKind.Array) return null; + var result = new List(); + foreach (var item in value.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Array || item.GetArrayLength() != 2 + || !item[0].TryGetDouble(out var longitude) || !item[1].TryGetDouble(out var latitude)) return null; + var coordinate = new RouteCoordinate(longitude, latitude); + if (!coordinate.IsValid) return null; + result.Add(coordinate); + } + return result; + } + + private static bool TryInstruction(JsonElement step, out RouteInstruction? value) + { + value = null; + if (!step.TryGetProperty("instruction", out var instruction) + || !instruction.TryGetProperty("text", out var text) || string.IsNullOrWhiteSpace(text.GetString()) + || !instruction.TryGetProperty("type", out var type) || string.IsNullOrWhiteSpace(type.GetString()) + || !step.TryGetProperty("from_index", out var from) || !from.TryGetInt32(out var fromIndex) || fromIndex < 0 + || !step.TryGetProperty("to_index", out var to) || !to.TryGetInt32(out var toIndex) || toIndex <= fromIndex + || !Number(step, "distance", out var distance) || !Number(step, "time", out var duration)) return false; + value = new(text.GetString()!.Trim()[..Math.Min(500, text.GetString()!.Trim().Length)], + type.GetString()!.Trim()[..Math.Min(80, type.GetString()!.Trim().Length)], + fromIndex, toIndex, distance, duration); + return true; + } + + private static bool Number(JsonElement value, string name, out double parsed) + { + parsed = 0; + return value.TryGetProperty(name, out var number) && number.TryGetDouble(out parsed) + && double.IsFinite(parsed) && parsed >= 0; + } + + private static bool Close(RouteCoordinate first, RouteCoordinate second) => + Math.Abs(first.Longitude - second.Longitude) <= 0.00025 + && Math.Abs(first.Latitude - second.Latitude) <= 0.00025; + + private static OsrmRouteResult Invalid() => OsrmRouteResult.Invalid("provider-response-invalid"); +} diff --git a/Services/ExternalRouting/OsrmRoutingAdapter.cs b/Services/ExternalRouting/OsrmRoutingAdapter.cs index fb9d4825..363f230c 100644 --- a/Services/ExternalRouting/OsrmRoutingAdapter.cs +++ b/Services/ExternalRouting/OsrmRoutingAdapter.cs @@ -100,8 +100,16 @@ public readonly record struct RouteCoordinate(double Longitude, double Latitude) /// Contains only validated OSRM route and snapped waypoint coordinates. public sealed record OsrmRouteResult( - bool Succeeded, IReadOnlyList Geometry, IReadOnlyList Waypoints, string? ErrorCode) + bool Succeeded, IReadOnlyList Geometry, IReadOnlyList Waypoints, string? ErrorCode, + double? DistanceMetres = null, double? DurationSeconds = null, + IReadOnlyList? RouteInstructions = null) { + /// Gets normalized instructions or an empty list for providers that do not supply them. + public IReadOnlyList Instructions => RouteInstructions ?? []; /// Creates a bounded invalid result without provider details. public static OsrmRouteResult Invalid(string code) => new(false, [], [], code); } + +/// Contains one bounded provider-neutral route instruction. +public sealed record RouteInstruction( + string Text, string Type, int FromIndex, int ToIndex, double DistanceMetres, double DurationSeconds); diff --git a/Services/ExternalRouting/ProviderRouteClient.cs b/Services/ExternalRouting/ProviderRouteClient.cs new file mode 100644 index 00000000..e4ea62fb --- /dev/null +++ b/Services/ExternalRouting/ProviderRouteClient.cs @@ -0,0 +1,43 @@ +using System.Net; +using System.Net.Http.Headers; +using Wayfarer.Models.LocationProviders; +using Wayfarer.Services.LocationProviders; + +namespace Wayfarer.Services.ExternalRouting; + +/// Dispatches explicit routing adapters while preserving the established proposal client seam. +public sealed class ProviderRouteClient( + OsrmRouteClient osrm, RoutingBoundedExecutor executor, RoutingAttemptCoordinator attempts, + PersonalProviderContactGate personalContacts) : IOsrmRouteClient +{ + /// + public async Task RouteAsync( + ResolvedRoutingProviderExecution execution, IReadOnlyList anchors, + Func> validateAuthority, CancellationToken cancellationToken) + { + if (execution.Provider.AdapterType != Models.RoutingAdapterType.Geoapify) + return await osrm.RouteAsync(execution, anchors, validateAuthority, cancellationToken); + if (execution.PersonalProviderUserId == null || execution.Credential == null + || !GeoapifyRouteCost.TryParse(execution.Profile, out var mode)) + return OsrmRouteResult.Invalid("unsupported-transport-profile"); + int cost; + try { cost = GeoapifyRouteCost.Calculate(mode, anchors.Count); } + catch (Exception exception) when (exception is ArgumentOutOfRangeException or OverflowException) + { return OsrmRouteResult.Invalid("routing-cost-invalid"); } + var request = GeoapifyRoutingAdapter.BuildRelativeRequest(execution.Profile, anchors, execution.Credential); + var responseExecution = await executor.GetJsonAsync(new Uri("https://api.geoapify.com/"), request, + execution.Provider.ResponseSizeLimitBytes, TimeSpan.FromSeconds(execution.Provider.GenerationTimeoutSeconds), + cancellationToken, prepareAttempt: token => attempts.PrepareAsync(execution.Provider, validateAuthority, token, + async admissionToken => + { + var admission = await personalContacts.AdmitAsync(execution.PersonalProviderUserId, + PersonalProviderCapability.Routing, PersonalProviderProduct.Routing, cost, admissionToken); + return admission.Succeeded ? null : admission.Category == PersonalProviderAdmissionCategory.Exhausted + ? "routing-credit-exhausted" : "provider-configuration-stale"; + })); + if (!responseExecution.Succeeded) return OsrmRouteResult.Invalid(responseExecution.ErrorCode!); + using var response = new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(responseExecution.Json!) }; + response.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + return await GeoapifyRoutingAdapter.ParseAsync(response, anchors, cancellationToken); + } +} diff --git a/Services/ExternalRouting/RoutingAttemptCoordinator.cs b/Services/ExternalRouting/RoutingAttemptCoordinator.cs index 371e3009..f35af040 100644 --- a/Services/ExternalRouting/RoutingAttemptCoordinator.cs +++ b/Services/ExternalRouting/RoutingAttemptCoordinator.cs @@ -15,7 +15,8 @@ public RoutingAttemptCoordinator(RoutingProviderPacer pacer, RoutingRequestBudge /// Prepares one actual provider attempt immediately before DNS resolution. public async Task PrepareAsync( RoutingProviderConfiguration provider, Func> validateAuthority, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func>? admitExternalCost = null) { _pacer.ApplyConfiguration(provider.Id, provider.ConfigurationVersion, provider.MinimumIntervalMilliseconds); var paced = await _pacer.WaitAsync(provider.Id, provider.ConfigurationVersion, cancellationToken); @@ -37,6 +38,13 @@ public async Task PrepareAsync( concurrency.Dispose(); return RoutingAttemptAdmission.Failure("provider-configuration-stale"); } + var externalError = admitExternalCost == null ? null : await admitExternalCost(cancellationToken); + if (externalError != null) + { + turn.Dispose(); + concurrency.Dispose(); + return RoutingAttemptAdmission.Failure(externalError); + } return RoutingAttemptAdmission.Prepared(concurrency, turn, () => _budget.TryAdmitProviderAttempt(provider.Id, provider.RequestsPerMinute)); } diff --git a/Services/ExternalRouting/RoutingProviderAdministrationService.cs b/Services/ExternalRouting/RoutingProviderAdministrationService.cs index d053dbcc..4d723a3e 100644 --- a/Services/ExternalRouting/RoutingProviderAdministrationService.cs +++ b/Services/ExternalRouting/RoutingProviderAdministrationService.cs @@ -24,11 +24,18 @@ public async Task SaveAsync( { if (!Enum.IsDefined(model.PersonalRoutingAccess)) return RoutingAdministrationResult.Failure("The personal routing access mode is invalid."); + if (model.AdapterType is not (RoutingAdapterType.OsrmCompatible or RoutingAdapterType.Geoapify)) + return RoutingAdministrationResult.Failure("The routing adapter is not available."); if (!RoutingMinimumIntervalConverter.TryParse(model.MinimumIntervalSeconds, out var minimumIntervalMilliseconds)) return RoutingAdministrationResult.Failure("The minimum interval is invalid."); - if (!TryNormalizeEndpoint(model.BaseEndpoint, out var endpoint)) + string endpoint; + if (model.AdapterType == RoutingAdapterType.Geoapify) endpoint = "https://api.geoapify.com/"; + else if (!TryNormalizeEndpoint(model.BaseEndpoint, out endpoint)) return RoutingAdministrationResult.Failure("The endpoint is malformed or contains unsupported URL parts."); var selectedMappings = model.Mappings.Where(item => !string.IsNullOrWhiteSpace(item.OsrmProfile)).ToArray(); + if (model.AdapterType == RoutingAdapterType.Geoapify + && selectedMappings.Any(item => !GeoapifyRouteCost.TryParse(item.OsrmProfile, out _))) + return RoutingAdministrationResult.Failure("A Geoapify mapping contains an unsupported transport mode."); if (selectedMappings.Select(item => item.TransportProfileId).Distinct().Count() != selectedMappings.Length) return RoutingAdministrationResult.Failure("Each transport profile may be mapped only once."); var activeProfileIds = await _dbContext.Set().AsNoTracking() @@ -59,7 +66,7 @@ public async Task SaveAsync( var credentialFreeTransition = !creating && provider.PersonalRoutingAccess != PersonalRoutingAccess.CredentialFree && model.PersonalRoutingAccess == PersonalRoutingAccess.CredentialFree; - var changed = !creating && (provider.BaseEndpoint != endpoint + var changed = !creating && (provider.AdapterType != model.AdapterType || provider.BaseEndpoint != endpoint || provider.CredentialRequired != model.CredentialRequired || provider.PersonalRoutingAccess != model.PersonalRoutingAccess || provider.Enabled != model.Enabled @@ -77,10 +84,11 @@ public async Task SaveAsync( ? await LockSelectingUsersAsync(provider.Id, cancellationToken) : []; provider.DisplayName = model.DisplayName.Trim(); - provider.AdapterType = RoutingAdapterType.OsrmCompatible; + provider.AdapterType = model.AdapterType; provider.BaseEndpoint = endpoint; - provider.CredentialRequired = model.CredentialRequired; - provider.PersonalRoutingAccess = model.PersonalRoutingAccess; + provider.CredentialRequired = model.AdapterType == RoutingAdapterType.OsrmCompatible && model.CredentialRequired; + provider.PersonalRoutingAccess = model.AdapterType == RoutingAdapterType.Geoapify + ? PersonalRoutingAccess.CredentialRequired : model.PersonalRoutingAccess; provider.Enabled = model.Enabled; provider.Attribution = Normalize(model.Attribution); provider.ExternalCoordinateDisclosure = model.ExternalCoordinateDisclosure.Trim(); @@ -104,7 +112,7 @@ public async Task SaveAsync( }); } if (changed) provider.MarkConfigurationChanged(); - _credentials.ApplyEdit(provider, model.Credential); + _credentials.ApplyEdit(provider, model.AdapterType == RoutingAdapterType.Geoapify ? null : model.Credential); foreach (var configuration in affectedUsers) configuration.NormalizeCredentialFree(); AddAudit(administratorId, creating ? "RoutingProviderCreate" : "RoutingProviderUpdate", provider.Id, changed || !string.IsNullOrWhiteSpace(model.Credential) ? "configuration changed; verification invalidated" : "metadata preserved"); diff --git a/Services/ExternalRouting/RoutingProviderStateResolver.cs b/Services/ExternalRouting/RoutingProviderStateResolver.cs index d9335a3f..c20717d0 100644 --- a/Services/ExternalRouting/RoutingProviderStateResolver.cs +++ b/Services/ExternalRouting/RoutingProviderStateResolver.cs @@ -19,7 +19,7 @@ public static RoutingProviderState Resolve(RoutingProviderConfiguration configur private static bool IsComplete(RoutingProviderConfiguration value) => value.Enabled && !string.IsNullOrWhiteSpace(value.DisplayName) && !string.IsNullOrWhiteSpace(value.BaseEndpoint) - && (!value.CredentialRequired || value.CredentialPresent) + && (value.AdapterType == RoutingAdapterType.Geoapify || !value.CredentialRequired || value.CredentialPresent) && value.VerificationFromLongitude.HasValue && value.VerificationFromLatitude.HasValue && value.VerificationToLongitude.HasValue && value.VerificationToLatitude.HasValue && value.ProfileMappings.Count > 0; @@ -30,7 +30,9 @@ private static bool IsValid(RoutingProviderConfiguration value) => && CoordinatesValid(value.VerificationFromLongitude!.Value, value.VerificationFromLatitude!.Value) && CoordinatesValid(value.VerificationToLongitude!.Value, value.VerificationToLatitude!.Value) && value.ProfileMappings.All(mapping => !string.IsNullOrWhiteSpace(mapping.OsrmProfile) - && mapping.TransportProfile is { IsActive: true }); + && mapping.TransportProfile is { IsActive: true } + && (value.AdapterType != RoutingAdapterType.Geoapify + || GeoapifyRouteCost.TryParse(mapping.OsrmProfile, out _))); private static bool CoordinatesValid(double longitude, double latitude) => double.IsFinite(longitude) && double.IsFinite(latitude) && longitude is >= -180 and <= 180 && latitude is >= -90 and <= 90; diff --git a/Services/ExternalRouting/RoutingProviderVerifier.cs b/Services/ExternalRouting/RoutingProviderVerifier.cs index c2791bd3..2857ed82 100644 --- a/Services/ExternalRouting/RoutingProviderVerifier.cs +++ b/Services/ExternalRouting/RoutingProviderVerifier.cs @@ -53,6 +53,19 @@ private async Task VerifyCoreAsync( if (provider == null || provider.ConfigurationVersion != expectedVersion || provider.RowVersion != expectedRowVersion || RoutingProviderStateResolver.Resolve(provider, false) is RoutingProviderState.Incomplete or RoutingProviderState.Invalid) return await FailureAsync(providerId, administratorId, "provider-configuration-stale", operationToken); + if (provider.AdapterType == RoutingAdapterType.Geoapify) + { + var trackedGeoapify = await _dbContext.Set() + .SingleAsync(item => item.Id == providerId, operationToken); + if (trackedGeoapify.ConfigurationVersion != expectedVersion || trackedGeoapify.RowVersion != expectedRowVersion) + return await FailureAsync(providerId, administratorId, "provider-configuration-stale", operationToken); + trackedGeoapify.VerifiedConfigurationVersion = expectedVersion; + trackedGeoapify.VerificationStatus = "verified"; + trackedGeoapify.VerificationResult = "Geoapify fixed endpoint and closed mappings validated offline."; + AddAudit(administratorId, providerId, "success", "ready-to-verified"); + await _dbContext.SaveChangesAsync(operationToken); + return new(true, null, trackedGeoapify.ConfigurationVersion, trackedGeoapify.RowVersion); + } var profiles = provider.ProfileMappings.Select(item => item.OsrmProfile).Distinct(StringComparer.Ordinal).ToArray(); if (profiles.Length is 0 or > MaximumProfiles) return await FailureAsync(providerId, administratorId, "provider-profile-count-invalid", operationToken); var credential = _credentials.Read(provider); From 1e1c04d581c325edd04a77789a8b6f93d6045157 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:55:16 +0300 Subject: [PATCH 09/29] WIP: cover accepted route provenance (checkpoint; tests failing) --- .../GeoapifySegmentRouteProvenanceTests.cs | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/Wayfarer.Tests/Models/GeoapifySegmentRouteProvenanceTests.cs diff --git a/tests/Wayfarer.Tests/Models/GeoapifySegmentRouteProvenanceTests.cs b/tests/Wayfarer.Tests/Models/GeoapifySegmentRouteProvenanceTests.cs new file mode 100644 index 00000000..3f11948c --- /dev/null +++ b/tests/Wayfarer.Tests/Models/GeoapifySegmentRouteProvenanceTests.cs @@ -0,0 +1,27 @@ +using Wayfarer.Models; +using Xunit; + +namespace Wayfarer.Tests.Models; + +/// Locks the bounded nullable Segment route-provenance schema. +public sealed class GeoapifySegmentRouteProvenanceTests +{ + [Fact] + public void SegmentOwnsOnlyNormalizedNullableRouteAuthority() + { + var names = typeof(Segment).GetProperties().Select(property => property.Name).ToHashSet(); + + Assert.Contains("RouteInstructionsJson", names); + Assert.Contains("RouteProvider", names); + Assert.Contains("RouteProviderConfigurationId", names); + Assert.Contains("RouteProviderConfigurationVersion", names); + Assert.Contains("RouteTransportProfileId", names); + Assert.Contains("RouteMappingMode", names); + Assert.Contains("RouteGeneratedAt", names); + Assert.Contains("RouteAttribution", names); + Assert.Contains("RouteStorageMode", names); + Assert.DoesNotContain("RouteRawResponse", names); + Assert.DoesNotContain("RouteCredential", names); + Assert.DoesNotContain("RouteProviderUrl", names); + } +} From 03e8eb6c91e0266c233de0c570c43d92efee5506 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:57:07 +0300 Subject: [PATCH 10/29] feat(trips): persist authorized route provenance --- ...624_AddGeoapifyRouteProvenance.Designer.cs | 2393 +++++++++++++++++ ...260823175624_AddGeoapifyRouteProvenance.cs | 114 + .../ApplicationDbContextModelSnapshot.cs | 32 + Models/Segment.cs | 20 + .../ExternalRouteProposalAcceptanceService.cs | 30 +- .../ExternalRouteProposalContextService.cs | 4 +- .../ExternalRouteProposalGenerator.cs | 16 +- 7 files changed, 2602 insertions(+), 7 deletions(-) create mode 100644 Migrations/20260823175624_AddGeoapifyRouteProvenance.Designer.cs create mode 100644 Migrations/20260823175624_AddGeoapifyRouteProvenance.cs diff --git a/Migrations/20260823175624_AddGeoapifyRouteProvenance.Designer.cs b/Migrations/20260823175624_AddGeoapifyRouteProvenance.Designer.cs new file mode 100644 index 00000000..75a95095 --- /dev/null +++ b/Migrations/20260823175624_AddGeoapifyRouteProvenance.Designer.cs @@ -0,0 +1,2393 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NetTopologySuite.Geometries; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Wayfarer.Models; + +#nullable disable + +namespace Wayfarer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260823175624_AddGeoapifyRouteProvenance")] + partial class AddGeoapifyRouteProvenance + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext"); + NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "postgis"); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ActiveRoutingProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("ExternalRouteGenerationEnabled") + .HasColumnType("boolean"); + + b.Property("ExternalRouteGenerationVersion") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("ImageCacheExpiryDays") + .HasColumnType("integer"); + + b.Property("IsRegistrationOpen") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LocationAccuracyThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationDistanceThresholdMeters") + .HasColumnType("integer"); + + b.Property("LocationTimeThresholdMinutes") + .HasColumnType("integer"); + + b.Property("MaxCacheImageSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxCacheTileSizeInMB") + .HasColumnType("integer"); + + b.Property("MaxProxyImageDownloadMB") + .HasColumnType("integer"); + + b.Property("ProxyImageRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("ProxyImageRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("TileMetadataHotCacheSizeMB") + .HasColumnType("integer"); + + b.Property("TileOutboundBudgetHistorical30Acknowledged") + .HasColumnType("boolean"); + + b.Property("TileOutboundBudgetPerIpPerMinute") + .HasColumnType("integer"); + + b.Property("TileProviderAdvancedLimitsEnabled") + .HasColumnType("boolean"); + + b.Property("TileProviderApiKey") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("TileProviderAttribution") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("TileProviderBurstCapacity") + .HasColumnType("integer"); + + b.Property("TileProviderFallbackBaseDelayMs") + .HasColumnType("integer"); + + b.Property("TileProviderFallbackDelayCapSeconds") + .HasColumnType("integer"); + + b.Property("TileProviderKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("TileProviderMaxAttempts") + .HasColumnType("integer"); + + b.Property("TileProviderMaxConcurrency") + .HasColumnType("integer"); + + b.Property("TileProviderMaxIndividualWaitSeconds") + .HasColumnType("integer"); + + b.Property("TileProviderSustainedRequestsPerSecond") + .HasColumnType("integer"); + + b.Property("TileProviderTotalRetryCeilingSeconds") + .HasColumnType("integer"); + + b.Property("TileProviderUrlTemplate") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("TileRateLimitAuthenticatedPerMinute") + .HasColumnType("integer"); + + b.Property("TileRateLimitEnabled") + .HasColumnType("boolean"); + + b.Property("TileRateLimitPerMinute") + .HasColumnType("integer"); + + b.Property("TileTrafficMode") + .HasColumnType("integer"); + + b.Property("UploadSizeLimitMB") + .HasColumnType("integer"); + + b.Property("VisitNotificationCooldownHours") + .HasColumnType("integer"); + + b.Property("VisitedAccuracyMultiplier") + .HasColumnType("double precision"); + + b.Property("VisitedAccuracyRejectMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMaxSearchRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedMinRadiusMeters") + .HasColumnType("integer"); + + b.Property("VisitedPlaceNotesSnapshotMaxHtmlChars") + .HasColumnType("integer"); + + b.Property("VisitedRequiredHits") + .HasColumnType("integer"); + + b.Property("VisitedSuggestionMaxRadiusMultiplier") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ActiveRoutingProviderConfigurationId"); + + b.ToTable("ApplicationSettings"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("TripTags", b => + { + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("TagId") + .HasColumnType("uuid"); + + b.HasKey("TripId", "TagId"); + + b.HasIndex("TagId"); + + b.HasIndex("TripId"); + + b.ToTable("TripTags", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.ActivityType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ActivityTypes"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Token") + .HasColumnType("text"); + + b.Property("TokenHash") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("Name", "UserId") + .IsUnique() + .HasDatabaseName("IX_ApiToken_Name_UserId"); + + b.ToTable("ApiTokens"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("IsProtected") + .HasColumnType("boolean"); + + b.Property("IsTimelinePublic") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("PublicTimelineTimeThreshold") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TimelineTitle") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("FillHex") + .HasColumnType("text"); + + b.Property("Geometry") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Areas"); + }); + + modelBuilder.Entity("Wayfarer.Models.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasColumnType("text"); + + b.Property("Details") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("AuditLogs"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GroupType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrgPeerVisibilityEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "Name") + .IsUnique(); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("InviteeEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InviteeUserId") + .HasColumnType("text"); + + b.Property("InviterUserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("RespondedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteeUserId"); + + b.HasIndex("InviterUserId"); + + b.HasIndex("Token") + .IsUnique(); + + b.HasIndex("GroupId", "InviteeUserId") + .IsUnique() + .HasDatabaseName("IX_GroupInvitation_GroupId_InviteeUserId_Pending") + .HasFilter("\"Status\" = 'Pending' AND \"InviteeUserId\" IS NOT NULL"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroupId") + .HasColumnType("uuid"); + + b.Property("JoinedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LeftAt") + .HasColumnType("timestamp with time zone"); + + b.Property("OrgPeerVisibilityAccessDisabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("Role") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("GroupId", "Status") + .HasDatabaseName("IX_GroupMember_GroupId_Status"); + + b.HasIndex("GroupId", "UserId") + .IsUnique(); + + b.ToTable("GroupMembers"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Area") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("HiddenAreas"); + }); + + modelBuilder.Entity("Wayfarer.Models.ImageCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CacheKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CacheKey") + .IsUnique() + .HasDatabaseName("IX_ImageCacheMetadata_CacheKey"); + + b.ToTable("ImageCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.JobHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("JobName") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastRunTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("JobHistories"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Accuracy") + .HasColumnType("double precision"); + + b.Property("ActivityTypeId") + .HasColumnType("integer"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AddressNumber") + .HasColumnType("text"); + + b.Property("Altitude") + .HasColumnType("double precision"); + + b.Property("AppBuild") + .HasColumnType("text"); + + b.Property("AppVersion") + .HasColumnType("text"); + + b.Property("BatteryLevel") + .HasColumnType("integer"); + + b.Property("Bearing") + .HasColumnType("double precision"); + + b.Property("Coordinates") + .IsRequired() + .HasColumnType("geography(Point, 4326)"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("DeviceModel") + .HasColumnType("text"); + + b.Property("FullAddress") + .HasColumnType("text"); + + b.Property("IdempotencyKey") + .HasColumnType("uuid"); + + b.Property("IsCharging") + .HasColumnType("boolean"); + + b.Property("IsUserInvoked") + .HasColumnType("boolean"); + + b.Property("LocalTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("LocationType") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("OsVersion") + .HasColumnType("text"); + + b.Property("Place") + .HasColumnType("text"); + + b.Property("PostCode") + .HasColumnType("text"); + + b.Property("Provider") + .HasColumnType("text"); + + b.Property("Region") + .HasColumnType("text"); + + b.Property("ReverseGeocodedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReverseGeocodingProvider") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("ReverseGeocodingStorageMode") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("Speed") + .HasColumnType("double precision"); + + b.Property("StreetName") + .HasColumnType("text"); + + b.Property("TimeZoneId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ActivityTypeId"); + + b.HasIndex("Coordinates") + .HasDatabaseName("IX_Location_Coordinates"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Coordinates"), "GIST"); + + b.HasIndex("UserId", "IdempotencyKey") + .IsUnique() + .HasDatabaseName("IX_Location_UserId_IdempotencyKey"); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ErrorMessage") + .HasColumnType("text"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileType") + .HasColumnType("integer"); + + b.Property("LastImportedRecord") + .HasColumnType("text"); + + b.Property("LastProcessedIndex") + .HasColumnType("integer"); + + b.Property("SkippedDuplicates") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalRecords") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("LocationImports"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageAdmission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AdmittedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("clock_timestamp()"); + + b.Property("Credits") + .HasColumnType("integer"); + + b.Property("Product") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "AdmittedAt"); + + b.ToTable("GeoapifyUsageAdmissions", t => + { + t.HasCheckConstraint("CK_GeoapifyUsageAdmission_Credits", "\"Credits\" > 0"); + + t.HasCheckConstraint("CK_GeoapifyUsageAdmission_Product", "\"Product\" IN (1, 2)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("CreditLimit") + .HasColumnType("integer"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId"); + + b.ToTable("GeoapifyUsageGuards", t => + { + t.HasCheckConstraint("CK_GeoapifyUsageGuard_Limit", "\"CreditLimit\" >= 0"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.MapboxProductMeter", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("Product") + .HasColumnType("integer"); + + b.Property("AdmittedCount") + .HasColumnType("integer"); + + b.Property("CycleStart") + .HasColumnType("date"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Limit") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId", "Product"); + + b.ToTable("MapboxProductMeters", t => + { + t.HasCheckConstraint("CK_MapboxProductMeter_Counts", "\"Limit\" >= 0 AND \"AdmittedCount\" >= 0"); + + t.HasCheckConstraint("CK_MapboxProductMeter_Product", "\"Product\" IN (3, 4)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CredentialGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingAuthorized") + .HasColumnType("boolean"); + + b.Property("GeocodingGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingVerification") + .HasColumnType("integer"); + + b.Property("GeocodingVerifiedConfigurationGeneration") + .HasColumnType("integer"); + + b.Property("GeocodingVerifiedCredentialGeneration") + .HasColumnType("integer"); + + b.Property("LegacyMigrationState") + .HasColumnType("integer"); + + b.Property("PermanentGeocodingConsentCredentialGeneration") + .HasColumnType("integer"); + + b.Property("PermanentGeocodingConsentVersion") + .HasColumnType("integer"); + + b.Property("PermanentGeocodingConsentedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProtectedCredential") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("ProviderKey") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoutingAuthorized") + .HasColumnType("boolean"); + + b.Property("RoutingGeneration") + .HasColumnType("integer"); + + b.Property("RoutingVerification") + .HasColumnType("integer"); + + b.Property("RoutingVerifiedConfigurationGeneration") + .HasColumnType("integer"); + + b.Property("RoutingVerifiedCredentialGeneration") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ProviderKey") + .IsUnique(); + + b.ToTable("PersonalLocationProviderProfiles", t => + { + t.HasCheckConstraint("CK_PersonalProvider_Generations", "\"CredentialGeneration\" > 0 AND \"GeocodingGeneration\" > 0 AND \"RoutingGeneration\" > 0"); + + t.HasCheckConstraint("CK_PersonalProvider_Provider", "\"ProviderKey\" IN ('geoapify', 'mapbox')"); + + t.HasCheckConstraint("CK_PersonalProvider_Verification", "\"GeocodingVerification\" BETWEEN 0 AND 3 AND \"RoutingVerification\" BETWEEN 0 AND 3"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", b => + { + b.Property("UserId") + .HasMaxLength(450) + .HasColumnType("character varying(450)"); + + b.Property("GeocodingProviderKey") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("GeocodingSelectionGeneration") + .HasColumnType("integer"); + + b.Property("RoutingProviderKey") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RoutingSelectionGeneration") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.HasKey("UserId"); + + b.HasIndex("UserId", "GeocodingProviderKey"); + + b.HasIndex("UserId", "RoutingProviderKey"); + + b.ToTable("PersonalLocationProviderSelections", t => + { + t.HasCheckConstraint("CK_PersonalProviderSelection_Geocoding", "\"GeocodingProviderKey\" IS NULL OR \"GeocodingProviderKey\" IN ('geoapify', 'mapbox')"); + + t.HasCheckConstraint("CK_PersonalProviderSelection_Routing", "\"RoutingProviderKey\" IS NULL OR \"RoutingProviderKey\" IN ('geoapify', 'mapbox')"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("AddressEnrichedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AddressEnrichmentProvider") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("AddressEnrichmentStorageMode") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("IconName") + .HasColumnType("text"); + + b.Property("Location") + .HasColumnType("geography(Point,4326)"); + + b.Property("MarkerColor") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RegionId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RegionId"); + + b.ToTable("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsecutiveHits") + .HasColumnType("integer"); + + b.Property("FirstHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastHitUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LastHitUtc") + .HasDatabaseName("IX_PlaceVisitCandidate_LastHitUtc"); + + b.HasIndex("PlaceId"); + + b.HasIndex("UserId", "PlaceId") + .IsUnique() + .HasDatabaseName("IX_PlaceVisitCandidate_UserId_PlaceId"); + + b.ToTable("PlaceVisitCandidates"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ArrivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EndedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("IconNameSnapshot") + .HasColumnType("text"); + + b.Property("LastSeenAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarkerColorSnapshot") + .HasColumnType("text"); + + b.Property("NotesHtml") + .HasColumnType("text"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("PlaceLocationSnapshot") + .HasColumnType("geography(Point,4326)"); + + b.Property("PlaceNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegionNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("Source") + .HasColumnType("text"); + + b.Property("TripIdSnapshot") + .HasColumnType("uuid"); + + b.Property("TripNameSnapshot") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ArrivedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_ArrivedAtUtc"); + + b.HasIndex("PlaceId") + .HasDatabaseName("IX_PlaceVisitEvent_PlaceId"); + + b.HasIndex("UserId", "EndedAtUtc") + .HasDatabaseName("IX_PlaceVisitEvent_UserId_EndedAtUtc"); + + b.ToTable("PlaceVisitEvents"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Center") + .HasColumnType("geography(Point,4326)"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("TripId"); + + b.ToTable("Regions"); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdapterType") + .HasColumnType("integer"); + + b.Property("Attribution") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("BaseEndpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("ConfigurationVersion") + .HasColumnType("integer"); + + b.Property("CredentialCiphertext") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("CredentialPresent") + .HasColumnType("boolean"); + + b.Property("CredentialRequired") + .HasColumnType("boolean"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("ExternalCoordinateDisclosure") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("GenerationTimeoutSeconds") + .HasColumnType("integer"); + + b.Property("MaxConcurrency") + .HasColumnType("integer"); + + b.Property("MinimumIntervalMilliseconds") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1000); + + b.Property("PersonalRoutingAccess") + .HasColumnType("integer"); + + b.Property("RequestsPerMinute") + .HasColumnType("integer"); + + b.Property("ResponseSizeLimitBytes") + .HasColumnType("integer"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("VerificationFromLatitude") + .HasColumnType("double precision"); + + b.Property("VerificationFromLongitude") + .HasColumnType("double precision"); + + b.Property("VerificationResult") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("VerificationStatus") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("VerificationToLatitude") + .HasColumnType("double precision"); + + b.Property("VerificationToLongitude") + .HasColumnType("double precision"); + + b.Property("VerifiedConfigurationVersion") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("RoutingProviderConfigurations", null, t => + { + t.HasCheckConstraint("CK_RoutingProviderConfigurations_MinimumIntervalMilliseconds", "\"MinimumIntervalMilliseconds\" >= 0 AND \"MinimumIntervalMilliseconds\" <= 60000"); + + t.HasCheckConstraint("CK_RoutingProviderConfigurations_PersonalRoutingAccess", "\"PersonalRoutingAccess\" IN (0, 1, 2)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderProfileMapping", b => + { + b.Property("RoutingProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("TransportProfileId") + .HasColumnType("uuid"); + + b.Property("OsrmProfile") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.HasKey("RoutingProviderConfigurationId", "TransportProfileId"); + + b.HasIndex("TransportProfileId"); + + b.ToTable("RoutingProviderProfileMappings", (string)null); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DisplayOrder") + .HasColumnType("integer"); + + b.Property("EstimatedDistanceKm") + .HasColumnType("double precision"); + + b.Property("EstimatedDuration") + .HasColumnType("interval"); + + b.Property("EstimatedDurationSource") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("FromPlaceId") + .HasColumnType("uuid"); + + b.Property("Mode") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("RouteAttribution") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RouteGeneratedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RouteGeometry") + .HasColumnType("geography(LineString,4326)"); + + b.Property("RouteInstructionsJson") + .HasMaxLength(65535) + .HasColumnType("character varying(65535)"); + + b.Property("RouteMappingMode") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("RouteProvider") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RouteProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("RouteProviderConfigurationVersion") + .HasColumnType("integer"); + + b.Property("RouteStorageMode") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RouteTransportProfileId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("ToPlaceId") + .HasColumnType("uuid"); + + b.Property("TransportProfileId") + .HasColumnType("uuid"); + + b.Property("TripId") + .HasColumnType("uuid"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FromPlaceId"); + + b.HasIndex("ToPlaceId"); + + b.HasIndex("TransportProfileId"); + + b.HasIndex("TripId"); + + b.ToTable("Segments", t => + { + t.HasCheckConstraint("CK_Segments_EstimatedDurationSource", "\"EstimatedDurationSource\" IN (0, 1)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.SegmentWaypoint", b => + { + b.Property("SegmentId") + .HasColumnType("uuid"); + + b.Property("PlaceId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("RouteVertexIndex") + .HasColumnType("integer"); + + b.HasKey("SegmentId", "PlaceId"); + + b.HasIndex("PlaceId"); + + b.HasIndex("SegmentId", "Position") + .IsUnique() + .HasDatabaseName("IX_SegmentWaypoints_SegmentId_Position"); + + b.HasIndex("SegmentId", "RouteVertexIndex") + .IsUnique() + .HasDatabaseName("IX_SegmentWaypoints_SegmentId_RouteVertexIndex") + .HasFilter("\"RouteVertexIndex\" IS NOT NULL"); + + b.ToTable("SegmentWaypoints", null, t => + { + t.HasCheckConstraint("CK_SegmentWaypoint_Position", "\"Position\" >= 0"); + + t.HasCheckConstraint("CK_SegmentWaypoint_RouteVertexIndex", "\"RouteVertexIndex\" IS NULL OR \"RouteVertexIndex\" > 0"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("citext"); + + b.Property("Slug") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Wayfarer.Models.TileCacheMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ETag") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastAccessed") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastModifiedUpstream") + .HasColumnType("timestamp with time zone"); + + b.Property("ProviderIdentity") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("Size") + .HasColumnType("integer"); + + b.Property("TileFilePath") + .HasColumnType("text"); + + b.Property("TileLocation") + .IsRequired() + .HasColumnType("geometry"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TileLocation"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("TileLocation"), "GIST"); + + b.HasIndex("Zoom", "X", "Y") + .IsUnique() + .HasFilter("\"ProviderIdentity\" IS NULL"); + + b.HasIndex("ProviderIdentity", "Zoom", "X", "Y") + .IsUnique(); + + b.ToTable("TileCacheMetadata"); + }); + + modelBuilder.Entity("Wayfarer.Models.TransportProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsSeeded") + .HasColumnType("boolean"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("Label") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("PlanningSpeedKmh") + .HasColumnType("double precision"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("TransportProfiles", null, t => + { + t.HasCheckConstraint("CK_TransportProfile_NormalizedKey", "\"Key\" = lower(trim(\"Key\")) AND length(\"Key\") > 0"); + + t.HasCheckConstraint("CK_TransportProfile_PlanningSpeedKmh", "\"PlanningSpeedKmh\" IS NULL OR (\"PlanningSpeedKmh\" > 0 AND \"PlanningSpeedKmh\" < 1.7976931348623157E+308)"); + }); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CenterLat") + .HasColumnType("double precision"); + + b.Property("CenterLon") + .HasColumnType("double precision"); + + b.Property("CoverImageUrl") + .HasColumnType("text"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ShareProgressEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.Property("Zoom") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.UserRoutingConfiguration", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("ConfigurationVersion") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CredentialCiphertext") + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.Property("CredentialPresent") + .HasColumnType("boolean"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("xid") + .HasColumnName("xmin"); + + b.Property("SelectedProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("VerificationStatus") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("VerifiedProviderConfigurationVersion") + .HasColumnType("integer"); + + b.Property("VerifiedUserConfigurationVersion") + .HasColumnType("integer"); + + b.HasKey("UserId"); + + b.HasIndex("SelectedProviderConfigurationId"); + + b.ToTable("UserRoutingConfigurations", null, t => + { + t.HasCheckConstraint("CK_UserRoutingConfigurations_CredentialConsistency", "(\"CredentialPresent\" AND \"CredentialCiphertext\" IS NOT NULL) OR (NOT \"CredentialPresent\" AND \"CredentialCiphertext\" IS NULL)"); + + t.HasCheckConstraint("CK_UserRoutingConfigurations_DefaultMode", "\"SelectedProviderConfigurationId\" IS NOT NULL OR (NOT \"CredentialPresent\" AND \"CredentialCiphertext\" IS NULL AND \"VerifiedUserConfigurationVersion\" IS NULL AND \"VerifiedProviderConfigurationVersion\" IS NULL AND \"VerificationStatus\" IS NULL)"); + + t.HasCheckConstraint("CK_UserRoutingConfigurations_VerifiedPair", "(\"VerifiedUserConfigurationVersion\" IS NULL AND \"VerifiedProviderConfigurationVersion\" IS NULL) OR (\"VerifiedUserConfigurationVersion\" IS NOT NULL AND \"VerifiedProviderConfigurationVersion\" IS NOT NULL)"); + + t.HasCheckConstraint("CK_UserRoutingConfigurations_Version", "\"ConfigurationVersion\" >= 1"); + }); + }); + + modelBuilder.Entity("ApplicationSettings", b => + { + b.HasOne("Wayfarer.Models.RoutingProviderConfiguration", "ActiveRoutingProviderConfiguration") + .WithMany() + .HasForeignKey("ActiveRoutingProviderConfigurationId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("ActiveRoutingProviderConfiguration"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TripTags", b => + { + b.HasOne("Wayfarer.Models.Tag", null) + .WithMany() + .HasForeignKey("TagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Trip", null) + .WithMany() + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.ApiToken", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Area", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Areas") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "Owner") + .WithMany("GroupsOwned") + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupInvitation", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Invitations") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Invitee") + .WithMany("GroupInvitationsReceived") + .HasForeignKey("InviteeUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "Inviter") + .WithMany("GroupInvitationsSent") + .HasForeignKey("InviterUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("Invitee"); + + b.Navigation("Inviter"); + }); + + modelBuilder.Entity("Wayfarer.Models.GroupMember", b => + { + b.HasOne("Wayfarer.Models.Group", "Group") + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("GroupMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Group"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.HiddenArea", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("HiddenAreas") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Location", b => + { + b.HasOne("Wayfarer.Models.ActivityType", "ActivityType") + .WithMany() + .HasForeignKey("ActivityTypeId"); + + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany("Locations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ActivityType"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationImport", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("LocationImports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageAdmission", b => + { + b.HasOne("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithOne() + .HasForeignKey("Wayfarer.Models.LocationProviders.GeoapifyUsageGuard", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.MapboxProductMeter", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", null) + .WithOne() + .HasForeignKey("Wayfarer.Models.LocationProviders.PersonalLocationProviderSelection", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", null) + .WithMany() + .HasForeignKey("UserId", "GeocodingProviderKey") + .HasPrincipalKey("UserId", "ProviderKey") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Wayfarer.Models.LocationProviders.PersonalLocationProviderProfile", null) + .WithMany() + .HasForeignKey("UserId", "RoutingProviderKey") + .HasPrincipalKey("UserId", "ProviderKey") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("FK_PersonalLocationProviderSelections_PersonalLocationProvide~1"); + }); + + modelBuilder.Entity("Wayfarer.Models.Place", b => + { + b.HasOne("Wayfarer.Models.Region", "Region") + .WithMany("Places") + .HasForeignKey("RegionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Region"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitCandidate", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.PlaceVisitEvent", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Regions") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderProfileMapping", b => + { + b.HasOne("Wayfarer.Models.RoutingProviderConfiguration", "RoutingProviderConfiguration") + .WithMany("ProfileMappings") + .HasForeignKey("RoutingProviderConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Wayfarer.Models.TransportProfile", "TransportProfile") + .WithMany() + .HasForeignKey("TransportProfileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("RoutingProviderConfiguration"); + + b.Navigation("TransportProfile"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.HasOne("Wayfarer.Models.Place", "FromPlace") + .WithMany() + .HasForeignKey("FromPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.Place", "ToPlace") + .WithMany() + .HasForeignKey("ToPlaceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Wayfarer.Models.TransportProfile", "TransportProfile") + .WithMany() + .HasForeignKey("TransportProfileId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Wayfarer.Models.Trip", "Trip") + .WithMany("Segments") + .HasForeignKey("TripId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("FromPlace"); + + b.Navigation("ToPlace"); + + b.Navigation("TransportProfile"); + + b.Navigation("Trip"); + }); + + modelBuilder.Entity("Wayfarer.Models.SegmentWaypoint", b => + { + b.HasOne("Wayfarer.Models.Place", "Place") + .WithMany() + .HasForeignKey("PlaceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Wayfarer.Models.Segment", "Segment") + .WithMany("Waypoints") + .HasForeignKey("SegmentId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Place"); + + b.Navigation("Segment"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithMany("Trips") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.UserRoutingConfiguration", b => + { + b.HasOne("Wayfarer.Models.RoutingProviderConfiguration", "SelectedProviderConfiguration") + .WithMany() + .HasForeignKey("SelectedProviderConfigurationId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("Wayfarer.Models.ApplicationUser", "User") + .WithOne() + .HasForeignKey("Wayfarer.Models.UserRoutingConfiguration", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SelectedProviderConfiguration"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Wayfarer.Models.ApplicationUser", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("GroupInvitationsReceived"); + + b.Navigation("GroupInvitationsSent"); + + b.Navigation("GroupMemberships"); + + b.Navigation("GroupsOwned"); + + b.Navigation("HiddenAreas"); + + b.Navigation("LocationImports"); + + b.Navigation("Locations"); + + b.Navigation("Trips"); + }); + + modelBuilder.Entity("Wayfarer.Models.Group", b => + { + b.Navigation("Invitations"); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Wayfarer.Models.Region", b => + { + b.Navigation("Areas"); + + b.Navigation("Places"); + }); + + modelBuilder.Entity("Wayfarer.Models.RoutingProviderConfiguration", b => + { + b.Navigation("ProfileMappings"); + }); + + modelBuilder.Entity("Wayfarer.Models.Segment", b => + { + b.Navigation("Waypoints"); + }); + + modelBuilder.Entity("Wayfarer.Models.Trip", b => + { + b.Navigation("Regions"); + + b.Navigation("Segments"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Migrations/20260823175624_AddGeoapifyRouteProvenance.cs b/Migrations/20260823175624_AddGeoapifyRouteProvenance.cs new file mode 100644 index 00000000..27056240 --- /dev/null +++ b/Migrations/20260823175624_AddGeoapifyRouteProvenance.cs @@ -0,0 +1,114 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Wayfarer.Migrations +{ + /// + public partial class AddGeoapifyRouteProvenance : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "RouteAttribution", + table: "Segments", + type: "character varying(500)", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "RouteGeneratedAt", + table: "Segments", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "RouteInstructionsJson", + table: "Segments", + type: "character varying(65535)", + maxLength: 65535, + nullable: true); + + migrationBuilder.AddColumn( + name: "RouteMappingMode", + table: "Segments", + type: "character varying(80)", + maxLength: 80, + nullable: true); + + migrationBuilder.AddColumn( + name: "RouteProvider", + table: "Segments", + type: "character varying(24)", + maxLength: 24, + nullable: true); + + migrationBuilder.AddColumn( + name: "RouteProviderConfigurationId", + table: "Segments", + type: "uuid", + nullable: true); + + migrationBuilder.AddColumn( + name: "RouteProviderConfigurationVersion", + table: "Segments", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "RouteStorageMode", + table: "Segments", + type: "character varying(16)", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "RouteTransportProfileId", + table: "Segments", + type: "uuid", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "RouteAttribution", + table: "Segments"); + + migrationBuilder.DropColumn( + name: "RouteGeneratedAt", + table: "Segments"); + + migrationBuilder.DropColumn( + name: "RouteInstructionsJson", + table: "Segments"); + + migrationBuilder.DropColumn( + name: "RouteMappingMode", + table: "Segments"); + + migrationBuilder.DropColumn( + name: "RouteProvider", + table: "Segments"); + + migrationBuilder.DropColumn( + name: "RouteProviderConfigurationId", + table: "Segments"); + + migrationBuilder.DropColumn( + name: "RouteProviderConfigurationVersion", + table: "Segments"); + + migrationBuilder.DropColumn( + name: "RouteStorageMode", + table: "Segments"); + + migrationBuilder.DropColumn( + name: "RouteTransportProfileId", + table: "Segments"); + } + } +} diff --git a/Migrations/ApplicationDbContextModelSnapshot.cs b/Migrations/ApplicationDbContextModelSnapshot.cs index 6588d425..bd7077fc 100644 --- a/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1562,9 +1562,41 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Notes") .HasColumnType("text"); + b.Property("RouteAttribution") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("RouteGeneratedAt") + .HasColumnType("timestamp with time zone"); + b.Property("RouteGeometry") .HasColumnType("geography(LineString,4326)"); + b.Property("RouteInstructionsJson") + .HasMaxLength(65535) + .HasColumnType("character varying(65535)"); + + b.Property("RouteMappingMode") + .HasMaxLength(80) + .HasColumnType("character varying(80)"); + + b.Property("RouteProvider") + .HasMaxLength(24) + .HasColumnType("character varying(24)"); + + b.Property("RouteProviderConfigurationId") + .HasColumnType("uuid"); + + b.Property("RouteProviderConfigurationVersion") + .HasColumnType("integer"); + + b.Property("RouteStorageMode") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("RouteTransportProfileId") + .HasColumnType("uuid"); + b.Property("RowVersion") .IsConcurrencyToken() .ValueGeneratedOnAddOrUpdate() diff --git a/Models/Segment.cs b/Models/Segment.cs index f988cd77..167f9c6e 100644 --- a/Models/Segment.cs +++ b/Models/Segment.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using System.ComponentModel.DataAnnotations; using NetTopologySuite.Geometries; namespace Wayfarer.Models; @@ -73,6 +74,25 @@ public class Segment /// Estimated distance in kilometers. public double? EstimatedDistanceKm { get; set; } + /// Bounded provider-neutral normalized route instructions. + [MaxLength(65535)] public string? RouteInstructionsJson { get; set; } + /// Safe provider identity for an explicitly accepted retained route. + [MaxLength(24)] public string? RouteProvider { get; set; } + /// Administrator routing-configuration identity used to generate the route. + public Guid? RouteProviderConfigurationId { get; set; } + /// Configuration and mapping authority version used to generate the route. + public int? RouteProviderConfigurationVersion { get; set; } + /// Stable Wayfarer transport-profile identity used by the mapping. + public Guid? RouteTransportProfileId { get; set; } + /// Bounded exact provider-native mapping value retained for stale/offline matching. + [MaxLength(80)] public string? RouteMappingMode { get; set; } + /// UTC instant at which the accepted provider route was generated. + public DateTimeOffset? RouteGeneratedAt { get; set; } + /// Safe linked attribution contract for display with the route. + [MaxLength(500)] public string? RouteAttribution { get; set; } + /// Rights-authorized route storage marker. + [MaxLength(16)] public string? RouteStorageMode { get; set; } + /// Order for displaying segments in the UI. public int DisplayOrder { get; set; } diff --git a/Services/ExternalRouting/ExternalRouteProposalAcceptanceService.cs b/Services/ExternalRouting/ExternalRouteProposalAcceptanceService.cs index 6d529af1..3541f4a1 100644 --- a/Services/ExternalRouting/ExternalRouteProposalAcceptanceService.cs +++ b/Services/ExternalRouting/ExternalRouteProposalAcceptanceService.cs @@ -2,6 +2,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Npgsql; +using NetTopologySuite.Geometries; +using System.Text.Json; using Wayfarer.Models; namespace Wayfarer.Services.ExternalRouting; @@ -117,7 +119,7 @@ await _dbContext.Database.ExecuteSqlInterpolatedAsync( await _dbContext.Database.ExecuteSqlInterpolatedAsync( $"SELECT 1 FROM \"Segments\" WHERE \"Id\" = {segmentId} AND \"TripId\" = {tripId} AND \"UserId\" = {userId} FOR UPDATE", cancellationToken); - var segment = await _dbContext.Set().AsNoTracking() + var segment = await _dbContext.Set() .Include(item => item.FromPlace).Include(item => item.ToPlace) .Include(item => item.Waypoints.OrderBy(waypoint => waypoint.Position)).ThenInclude(item => item.Place) .SingleOrDefaultAsync(item => item.Id == segmentId && item.TripId == tripId && item.UserId == userId, cancellationToken); @@ -133,8 +135,28 @@ await _dbContext.Database.ExecuteSqlInterpolatedAsync( || waypointIndices.Where((index, anchorIndex) => geometry[index] != anchors[anchorIndex]).Any()) return ExternalRouteAcceptanceResult.Failure("route-proposal-stale"); + if (binding.ProviderKey == "geoapify" && binding.StorageMode == "persistent") + { + segment.RouteGeometry = new LineString(geometry.Select(item => new Coordinate(item.Longitude, item.Latitude)).ToArray()) { SRID = 4326 }; + segment.EstimatedDistanceKm = binding.DistanceMetres / 1000d; + segment.EstimatedDuration = binding.DurationSeconds.HasValue + ? TimeSpan.FromSeconds(binding.DurationSeconds.Value) : null; + segment.EstimatedDurationSource = EstimatedDurationSource.Automatic; + segment.RouteInstructionsJson = JsonSerializer.Serialize(binding.Instructions ?? []); + segment.RouteProvider = binding.ProviderKey; + segment.RouteProviderConfigurationId = binding.ProviderId; + segment.RouteProviderConfigurationVersion = binding.ProviderConfigurationVersion; + segment.RouteTransportProfileId = binding.TransportProfileId; + segment.RouteMappingMode = binding.MappingMode; + segment.RouteGeneratedAt = binding.GeneratedAt?.ToUniversalTime(); + segment.RouteAttribution = binding.Attribution; + segment.RouteStorageMode = binding.StorageMode; + await _dbContext.SaveChangesAsync(cancellationToken); + } return new ExternalRouteAcceptanceResult(true, null, - new AcceptedExternalRouteProposalDto(proposalId, segmentId, geometry, waypointIndices)); + new AcceptedExternalRouteProposalDto(proposalId, segmentId, geometry, waypointIndices, + binding.DistanceMetres, binding.DurationSeconds, binding.Instructions, binding.ProviderKey, + binding.Attribution, binding.StorageMode)); } private static bool GeometryShapeValid(IReadOnlyList geometry, IReadOnlyList indices) => @@ -146,7 +168,9 @@ private static bool GeometryShapeValid(IReadOnlyList geometry, /// Contains a validated proposal suitable only for copying into one client draft. public sealed record AcceptedExternalRouteProposalDto( - Guid ProposalId, Guid SegmentId, IReadOnlyList Geometry, IReadOnlyList WaypointIndices); + Guid ProposalId, Guid SegmentId, IReadOnlyList Geometry, IReadOnlyList WaypointIndices, + double? DistanceMetres = null, double? DurationSeconds = null, IReadOnlyList? Instructions = null, + string? Provider = null, string? Attribution = null, string? StorageMode = null); /// Contains a bounded acceptance outcome without persistence. public sealed record ExternalRouteAcceptanceResult( diff --git a/Services/ExternalRouting/ExternalRouteProposalContextService.cs b/Services/ExternalRouting/ExternalRouteProposalContextService.cs index 37ebc7d4..b99936dd 100644 --- a/Services/ExternalRouting/ExternalRouteProposalContextService.cs +++ b/Services/ExternalRouting/ExternalRouteProposalContextService.cs @@ -58,7 +58,9 @@ public sealed record ExternalRouteProposalBinding( Guid ProposalId, Guid TripId, Guid SegmentId, string UserId, string GeometryHash, string AnchorFingerprint, Guid TransportProfileId, Guid ProviderId, int ProviderConfigurationVersion, int FeatureStateGeneration, string AggregateConcurrencyToken, RoutingProviderSelectionMode ProviderSelectionMode = RoutingProviderSelectionMode.ServerDefault, - int UserRoutingConfigurationVersion = 1); + int UserRoutingConfigurationVersion = 1, double? DistanceMetres = null, double? DurationSeconds = null, + IReadOnlyList? Instructions = null, string? ProviderKey = null, string? MappingMode = null, + DateTimeOffset? GeneratedAt = null, string? Attribution = null, string? StorageMode = null); /// Returns the protected context and its initial ten-minute expiry. public sealed record ProtectedProposalContext(string Token, DateTimeOffset ExpiresAt); diff --git a/Services/ExternalRouting/ExternalRouteProposalGenerator.cs b/Services/ExternalRouting/ExternalRouteProposalGenerator.cs index e3234916..5bd13c2e 100644 --- a/Services/ExternalRouting/ExternalRouteProposalGenerator.cs +++ b/Services/ExternalRouting/ExternalRouteProposalGenerator.cs @@ -86,10 +86,19 @@ private async Task GenerateCoreAsync( proposalId, tripId, segmentId, userId, geometryHash, context.Fingerprint!, context.TransportProfileId!.Value, context.Execution!.Provider.Id, context.Execution.ProviderConfigurationVersion, context.Execution.FeatureStateGeneration, aggregateConcurrencyToken, - context.Execution.SelectionMode, context.Execution.UserConfigurationVersion); + context.Execution.SelectionMode, context.Execution.UserConfigurationVersion, + providerResult.DistanceMetres, providerResult.DurationSeconds, providerResult.Instructions, + context.Execution.Provider.AdapterType == RoutingAdapterType.Geoapify ? "geoapify" : null, + context.Execution.Profile, _timeProvider.GetUtcNow(), + context.Execution.Provider.AdapterType == RoutingAdapterType.Geoapify + ? "Powered by Geoapify|© OpenStreetMap contributors" : context.Execution.Attribution, + context.Execution.Provider.AdapterType == RoutingAdapterType.Geoapify ? "persistent" : null); var protectedContext = _proposalContexts!.Issue(binding); var proposal = new ExternalRouteProposalDto(proposalId, segmentId, validated.Geometry!, validated.WaypointIndices!, - protectedContext.Token, protectedContext.ExpiresAt); + protectedContext.Token, protectedContext.ExpiresAt, providerResult.DistanceMetres, + providerResult.DurationSeconds, providerResult.Instructions, + context.Execution.Provider.AdapterType == RoutingAdapterType.Geoapify ? "geoapify" : null, + context.Execution.Attribution); return new ExternalRouteGenerationResult(true, null, proposal); } @@ -156,7 +165,8 @@ public sealed record ExternalRouteGenerationResult(bool Succeeded, string? Error /// Contains an immutable, non-persisted route proposal for explicit acceptance. public sealed record ExternalRouteProposalDto( Guid ProposalId, Guid SegmentId, IReadOnlyList Geometry, IReadOnlyList WaypointIndices, - string ProtectedContext, DateTimeOffset ExpiresAt); + string ProtectedContext, DateTimeOffset ExpiresAt, double? DistanceMetres = null, double? DurationSeconds = null, + IReadOnlyList? Instructions = null, string? Provider = null, string? Attribution = null); /// Validates and budgets untrusted provider geometry while preserving every exact anchor. public interface IProviderRouteGeometryValidator From 4400127e6eb4c1e1cb6296e0dcc2783120002f13 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:57:58 +0300 Subject: [PATCH 11/29] WIP: cover provider-neutral mobile routing (checkpoint; tests failing) --- .../Controllers/MobileRoutingContractTests.cs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/Wayfarer.Tests/Controllers/MobileRoutingContractTests.cs diff --git a/tests/Wayfarer.Tests/Controllers/MobileRoutingContractTests.cs b/tests/Wayfarer.Tests/Controllers/MobileRoutingContractTests.cs new file mode 100644 index 00000000..ffcbaac7 --- /dev/null +++ b/tests/Wayfarer.Tests/Controllers/MobileRoutingContractTests.cs @@ -0,0 +1,37 @@ +using Wayfarer.Areas.Api.Controllers; +using Xunit; + +namespace Wayfarer.Tests.Controllers; + +/// Locks the additive provider-neutral mobile routing request boundary. +public sealed class MobileRoutingContractTests +{ + [Fact] + public void RequestAcceptsStableProfileAndBoundedCoordinatesButNoProviderAuthority() + { + var names = typeof(MobileRouteRequest).GetProperties().Select(property => property.Name).ToHashSet(); + + Assert.Contains("TransportProfileId", names); + Assert.Contains("Origin", names); + Assert.Contains("Destination", names); + Assert.Contains("Anchors", names); + Assert.DoesNotContain("Provider", names); + Assert.DoesNotContain("Mode", names); + Assert.DoesNotContain("Endpoint", names); + Assert.DoesNotContain("Credential", names); + } + + [Fact] + public void ResponseContainsNoSecretOrAdministratorEndpointMember() + { + var names = typeof(MobileRouteResponse).GetProperties().Select(property => property.Name).ToHashSet(); + + Assert.DoesNotContain("Credential", names); + Assert.DoesNotContain("ApiKey", names); + Assert.DoesNotContain("Endpoint", names); + Assert.DoesNotContain("ProtectedContext", names); + Assert.Contains("ProviderConfigurationId", names); + Assert.Contains("MappingIdentity", names); + Assert.Contains("StorageMode", names); + } +} From 684e6d481688695599f5319b74a471a0fe328d40 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 20:59:58 +0300 Subject: [PATCH 12/29] feat(api): expose provider-neutral mobile routing --- .../Controllers/MobileRoutingController.cs | 64 ++++++++++ Models/Dtos/ApiTripSegmentDto.cs | 17 +++ Models/Dtos/PublicSegmentResolver.cs | 10 +- ...ernalRoutingServiceCollectionExtensions.cs | 1 + .../ExternalRouting/MobileRoutingService.cs | 114 ++++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 Areas/Api/Controllers/MobileRoutingController.cs create mode 100644 Services/ExternalRouting/MobileRoutingService.cs diff --git a/Areas/Api/Controllers/MobileRoutingController.cs b/Areas/Api/Controllers/MobileRoutingController.cs new file mode 100644 index 00000000..c27cad8c --- /dev/null +++ b/Areas/Api/Controllers/MobileRoutingController.cs @@ -0,0 +1,64 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; +using Wayfarer.Models; +using Wayfarer.Services; +using Wayfarer.Services.ExternalRouting; + +namespace Wayfarer.Areas.Api.Controllers; + +/// Exposes authenticated provider-neutral mobile routing without provider selection or persistence. +[Route("api/mobile/routing")] +public sealed class MobileRoutingController( + ApplicationDbContext dbContext, ILogger logger, IMobileCurrentUserAccessor userAccessor, + MobileRoutingService routing) : MobileApiController(dbContext, logger, userAccessor) +{ + /// Returns no-contact capability for one stable Wayfarer transport profile identity. + [HttpGet("capability/{transportProfileId:guid}")] + public async Task Capability(Guid transportProfileId, CancellationToken cancellationToken) + { + var (user, error) = await EnsureAuthenticatedUserAsync(cancellationToken); + return error ?? Ok(await routing.CapabilityAsync(user!.Id, transportProfileId, cancellationToken)); + } + + /// Generates one bounded provider-neutral route without mutating server domain state. + [HttpPost("route")] + public async Task Route(MobileRouteRequest request, CancellationToken cancellationToken) + { + var (user, error) = await EnsureAuthenticatedUserAsync(cancellationToken); + if (error != null) return error; + if (request.AdditionalFields is { Count: > 0 } || request.Anchors.Count > 3) + return BadRequest(MobileRouteResponse.Failure("invalid-request")); + var points = new[] { request.Origin }.Concat(request.Anchors).Concat([request.Destination]) + .Select(item => new RouteCoordinate(item.Longitude, item.Latitude)).ToArray(); + var result = await routing.RouteAsync(user!.Id, request.TransportProfileId, points, cancellationToken); + return Ok(MobileRouteResponse.From(result)); + } +} + +/// Contains only server-resolved mobile route inputs. +public sealed class MobileRouteRequest +{ + public Guid TransportProfileId { get; set; } + public required MobileRouteCoordinate Origin { get; set; } + public required MobileRouteCoordinate Destination { get; set; } + public IReadOnlyList Anchors { get; set; } = []; + [JsonExtensionData] public Dictionary? AdditionalFields { get; set; } +} + +/// Contains one WGS84 coordinate with no provider semantics. +public sealed record MobileRouteCoordinate(double Longitude, double Latitude); + +/// Contains bounded provider-neutral route output and no secret/admin endpoint fields. +public sealed record MobileRouteResponse(bool Succeeded, string Outcome, IReadOnlyList? Geometry, + double? DistanceMetres, double? DurationSeconds, IReadOnlyList? Instructions, + DateTimeOffset? GeneratedAt, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity, + Guid? TransportProfileId, IReadOnlyList? MatchPoints, + IReadOnlyList? Attribution, string? StorageMode) +{ + public static MobileRouteResponse From(MobileRouteServiceResult value) => new(value.Succeeded, value.Outcome, + value.Geometry, value.DistanceMetres, value.DurationSeconds, value.Instructions, value.GeneratedAt, + value.Provider, value.ProviderConfigurationId, value.MappingIdentity, value.TransportProfileId, + value.MatchPoints, value.Attribution, value.StorageMode); + public static MobileRouteResponse Failure(string outcome) => From(MobileRouteServiceResult.Failure(outcome)); +} diff --git a/Models/Dtos/ApiTripSegmentDto.cs b/Models/Dtos/ApiTripSegmentDto.cs index 44daaabc..164fd36a 100644 --- a/Models/Dtos/ApiTripSegmentDto.cs +++ b/Models/Dtos/ApiTripSegmentDto.cs @@ -29,4 +29,21 @@ public class ApiTripSegmentDto /// Gets whether contains validated persisted custom geometry. public bool HasCustomRoute { get; init; } + + /// Gets normalized retained route instructions as bounded JSON. + public string? RouteInstructionsJson { get; init; } + /// Gets safe retained-route provider identity. + public string? RouteProvider { get; init; } + /// Gets safe provider configuration identity. + public Guid? RouteProviderConfigurationId { get; init; } + /// Gets provider configuration and mapping version. + public int? RouteProviderConfigurationVersion { get; init; } + /// Gets stable transport profile identity used to generate the route. + public Guid? RouteTransportProfileId { get; init; } + /// Gets generation time for retained/offline presentation. + public DateTimeOffset? RouteGeneratedAt { get; init; } + /// Gets linked route attribution display contract. + public string? RouteAttribution { get; init; } + /// Gets provider-authorized offline storage mode. + public string? RouteStorageMode { get; init; } } diff --git a/Models/Dtos/PublicSegmentResolver.cs b/Models/Dtos/PublicSegmentResolver.cs index 6b82bb26..87054e6a 100644 --- a/Models/Dtos/PublicSegmentResolver.cs +++ b/Models/Dtos/PublicSegmentResolver.cs @@ -70,7 +70,15 @@ public static PublicSegmentResolution Resolve( Position = item.Position, RouteVertexIndex = item.RouteVertexIndex }).ToArray(), - HasCustomRoute = segment.RouteGeometry is not null + HasCustomRoute = segment.RouteGeometry is not null, + RouteInstructionsJson = segment.RouteInstructionsJson, + RouteProvider = segment.RouteProvider, + RouteProviderConfigurationId = segment.RouteProviderConfigurationId, + RouteProviderConfigurationVersion = segment.RouteProviderConfigurationVersion, + RouteTransportProfileId = segment.RouteTransportProfileId, + RouteGeneratedAt = segment.RouteGeneratedAt, + RouteAttribution = segment.RouteAttribution, + RouteStorageMode = segment.RouteStorageMode }, null); } diff --git a/Services/ExternalRouting/ExternalRoutingServiceCollectionExtensions.cs b/Services/ExternalRouting/ExternalRoutingServiceCollectionExtensions.cs index 9026ea91..17b1d784 100644 --- a/Services/ExternalRouting/ExternalRoutingServiceCollectionExtensions.cs +++ b/Services/ExternalRouting/ExternalRoutingServiceCollectionExtensions.cs @@ -33,6 +33,7 @@ public static IServiceCollection AddExternalRouting(this IServiceCollection serv services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } } diff --git a/Services/ExternalRouting/MobileRoutingService.cs b/Services/ExternalRouting/MobileRoutingService.cs new file mode 100644 index 00000000..3bdfac06 --- /dev/null +++ b/Services/ExternalRouting/MobileRoutingService.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore; +using Wayfarer.Models; +using Wayfarer.Models.LocationProviders; + +namespace Wayfarer.Services.ExternalRouting; + +/// Owns provider-neutral mobile capability and ad-hoc route orchestration without persistence. +public sealed class MobileRoutingService( + ApplicationDbContext dbContext, AuthoritativeRoutingProviderResolver resolver, IOsrmRouteClient routeClient, + IProviderRouteGeometryValidator geometryValidator, RoutingRequestBudget budgets, + TimeProvider? timeProvider = null) +{ + private readonly TimeProvider clock = timeProvider ?? TimeProvider.System; + + /// Projects a no-contact capability for one stable Wayfarer transport profile. + public async Task CapabilityAsync( + string userId, Guid transportProfileId, CancellationToken cancellationToken) + { + var resolution = await resolver.ResolveAsync(userId, transportProfileId, cancellationToken); + if (resolution.Execution == null) + return new(MapOutcome(resolution.ErrorCode), transportProfileId, null, null, null, null, null); + var execution = resolution.Execution; + if (execution.Provider.AdapterType == RoutingAdapterType.Geoapify) + { + var guard = await dbContext.GeoapifyUsageGuards.AsNoTracking() + .SingleOrDefaultAsync(item => item.UserId == userId, cancellationToken); + var cutoff = DateTimeOffset.UtcNow.AddHours(-24); + var used = await dbContext.GeoapifyUsageAdmissions.AsNoTracking() + .Where(item => item.UserId == userId && item.AdmittedAt > cutoff) + .SumAsync(item => (int?)item.Credits, cancellationToken) ?? 0; + if (guard is { Enabled: true } && used >= guard.CreditLimit) + return new("exhausted", transportProfileId, null, null, null, null, null); + } + return new("available", transportProfileId, "geoapify", execution.Provider.Id, + MappingIdentity(execution, transportProfileId), "persistent", Attributions()); + } + + /// Generates one validated provider-neutral route and never mutates Trip Editor or domain state. + public async Task RouteAsync(string userId, Guid transportProfileId, + IReadOnlyList points, CancellationToken cancellationToken) + { + if (points.Count is < 2 or > 5 || points.Any(point => !point.IsValid) + || points.Zip(points.Skip(1), (first, second) => first == second).Any(equal => equal)) + return MobileRouteServiceResult.Failure("invalid-request"); + var resolution = await resolver.ResolveAsync(userId, transportProfileId, cancellationToken); + if (resolution.Execution == null) return MobileRouteServiceResult.Failure(MapOutcome(resolution.ErrorCode)); + if (!budgets.TryAdmitUserGeneration(userId)) return MobileRouteServiceResult.Failure("rate-limited"); + var execution = resolution.Execution; + var route = await routeClient.RouteAsync(execution, points, + token => AuthorityCurrentAsync(userId, transportProfileId, execution, token), cancellationToken); + if (!route.Succeeded) return MobileRouteServiceResult.Failure(MapOutcome(route.ErrorCode)); + var validated = geometryValidator.Validate(points, route, cancellationToken); + if (!validated.Succeeded) return MobileRouteServiceResult.Failure("invalid-response"); + if (!await AuthorityCurrentAsync(userId, transportProfileId, execution, cancellationToken)) + return MobileRouteServiceResult.Failure("configuration-changed"); + return new(true, "available", validated.Geometry!, route.DistanceMetres, route.DurationSeconds, + route.Instructions, clock.GetUtcNow(), "geoapify", execution.Provider.Id, + MappingIdentity(execution, transportProfileId), transportProfileId, points, + Attributions(), "persistent"); + } + + private async Task AuthorityCurrentAsync(string userId, Guid profileId, + ResolvedRoutingProviderExecution expected, CancellationToken cancellationToken) + { + var current = (await resolver.ResolveAsync(userId, profileId, cancellationToken)).Execution; + return current != null && current.Provider.Id == expected.Provider.Id + && current.ProviderConfigurationVersion == expected.ProviderConfigurationVersion + && current.Profile == expected.Profile && current.UserConfigurationVersion == expected.UserConfigurationVersion + && current.UserRowVersion == expected.UserRowVersion; + } + + private static string MappingIdentity(ResolvedRoutingProviderExecution execution, Guid profileId) => + $"{execution.Provider.Id:N}:{execution.ProviderConfigurationVersion}:{profileId:N}"; + + private static IReadOnlyList Attributions() => + [ + new("Powered by Geoapify", "https://www.geoapify.com/"), + new("© OpenStreetMap contributors", "https://www.openstreetmap.org/copyright") + ]; + + private static string MapOutcome(string? code) => code switch + { + "external-routing-disabled" or "personal-provider-unavailable" or "user-routing-unavailable" => "no-provider-selected", + "unmapped-transport-profile" => "unmapped-transport-profile", + "unsupported-transport-profile" => "unsupported-transport-profile", + "unauthorized" => "unauthorized", + "verification-required" => "verification-required", + "routing-credit-exhausted" => "exhausted", + "provider-rate-limited" or "routing-rate-limited" => "rate-limited", + "provider-configuration-stale" => "configuration-changed", + "provider-response-invalid" or "provider-route-invalid" => "invalid-response", + "request-cancelled" => "cancelled", + _ => "temporarily-unavailable" + }; +} + +/// Contains no-contact mobile capability state and safe matching authority. +public sealed record MobileRoutingCapability(string Outcome, Guid TransportProfileId, string? Provider, + Guid? ProviderConfigurationId, string? MappingIdentity, string? StorageMode, + IReadOnlyList? Attribution); + +/// Contains one safe linked attribution entry. +public sealed record MobileRouteAttribution(string Text, string Url); + +/// Contains one validated ad-hoc route or a bounded failure. +public sealed record MobileRouteServiceResult(bool Succeeded, string Outcome, + IReadOnlyList? Geometry = null, double? DistanceMetres = null, double? DurationSeconds = null, + IReadOnlyList? Instructions = null, DateTimeOffset? GeneratedAt = null, string? Provider = null, + Guid? ProviderConfigurationId = null, string? MappingIdentity = null, Guid? TransportProfileId = null, + IReadOnlyList? MatchPoints = null, IReadOnlyList? Attribution = null, + string? StorageMode = null) +{ + public static MobileRouteServiceResult Failure(string outcome) => new(false, outcome); +} From c688ad4ccf10f14188053e4faf7e0d60063c19de Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 21:03:35 +0300 Subject: [PATCH 13/29] feat(settings): complete Geoapify provider workflow --- .../LocationProviderSettingsViewModel.cs | 4 +++- .../trip-editor/src/components/SegmentRouteProposal.vue | 3 +++ .../LocationProviders/PersonalProviderCredentialService.cs | 5 ++++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs b/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs index aa43822a..7cda358e 100644 --- a/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs +++ b/Areas/User/LocationProviderModels/LocationProviderSettingsViewModel.cs @@ -26,7 +26,9 @@ public sealed record LocationProviderProfileViewModel( public sealed class LocationProviderProfileInput { [Required, RegularExpression("geoapify|mapbox")] public string ProviderKey { get; set; } = string.Empty; - [DataType(DataType.Password), StringLength(2048)] public string? ReplacementCredential { get; set; } + [DataType(DataType.Password), StringLength(2048), RegularExpression(@"^[^\s\p{Cc}]*$", + ErrorMessage = "Credentials cannot contain whitespace or control characters.")] + public string? ReplacementCredential { get; set; } public bool GeocodingAuthorized { get; set; } public bool RoutingAuthorized { get; set; } public bool ActiveForGeocoding { get; set; } diff --git a/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue b/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue index 206d405c..7506fbf4 100644 --- a/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue +++ b/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue @@ -95,6 +95,9 @@ function discard(): void { function boundedMessage(error: unknown): string { if (!(error instanceof ExternalRouteProposalError)) return 'Route generation is unavailable. The draft is unchanged.'; + if (error.code === 'unmapped-transport-profile') return 'Route suggestions are not configured for this transport profile.'; + if (error.code === 'unsupported-transport-profile') return 'This routing provider does not support the mapped transport mode.'; + if (error.code.includes('unavailable') || error.code.includes('configuration')) return 'Route suggestions are temporarily unavailable.'; if (error.code.includes('stale') || error.code.includes('expired')) return 'This proposal is stale or expired. Generate it again.'; if (error.code.includes('rate') || error.code.includes('budget')) return 'The routing request limit was reached. Try again later.'; return 'The routing provider could not produce a safe route. The draft is unchanged.'; diff --git a/Services/LocationProviders/PersonalProviderCredentialService.cs b/Services/LocationProviders/PersonalProviderCredentialService.cs index 75166541..38aabb2f 100644 --- a/Services/LocationProviders/PersonalProviderCredentialService.cs +++ b/Services/LocationProviders/PersonalProviderCredentialService.cs @@ -18,7 +18,10 @@ public sealed class PersonalProviderCredentialService public void Replace(PersonalLocationProviderProfile profile, string credential) { ArgumentException.ThrowIfNullOrWhiteSpace(credential); - profile.ProtectedCredential = Protector(profile).Protect(credential.Trim()); + var normalized = credential.Trim(); + if (normalized.Length > 2048 || normalized.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))) + throw new ArgumentException("The provider credential contains unsupported characters.", nameof(credential)); + profile.ProtectedCredential = Protector(profile).Protect(normalized); profile.CredentialGeneration = checked(profile.CredentialGeneration + 1); profile.RevokedAt = null; profile.ClearPermanentGeocodingConsent(); From 5a6cea6ff89c03c8df93dac9f77f2e0a0bc47f35 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 21:03:35 +0300 Subject: [PATCH 14/29] docs(providers): document Geoapify usage and mappings --- README.md | 2 +- docs/03-Features.md | 1 + docs/07-Importing-Exporting.md | 2 +- docs/08-Mobile.md | 2 +- docs/09-Troubleshooting.md | 2 ++ docs/15-Architecture.md | 2 ++ docs/16-Configuration.md | 2 ++ docs/17-Services.md | 2 ++ docs/18-API.md | 4 ++++ docs/19-Database.md | 1 + docs/21-Security.md | 1 + docs/24-Personal-Location-Providers.md | 18 ++++++++++++++++++ 12 files changed, 36 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e5abb79d..4e191a73 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ If you expose Wayfarer publicly, you are responsible for: * **Import deduplication** prevents duplicate entries automatically. * **Metadata preservation** — accuracy, speed, altitude, heading tracked per location. * **Export locations** to GeoJSON, KML, CSV, or GPX formats with full metadata. -* **Personal location providers** use protected per-user profiles, explicit Mapbox Permanent consent/verification, independent selection, and provider-native guards; capture continues when enrichment is paused. See the [personal provider guide](docs/24-Personal-Location-Providers.md). +* **Personal location providers** support storage-authorized Geoapify geocoding/routing and Mapbox Permanent Geocoding through protected credentials, independent capability verification/selection, and provider-native guards; capture continues when enrichment is paused. See the [personal provider guide](docs/24-Personal-Location-Providers.md). * **Wikipedia integration** — discover related articles for any location or trip place. * **Location statistics** — visit counts by country, region, and city. * **Bulk edit notes** to update multiple records at once. diff --git a/docs/03-Features.md b/docs/03-Features.md index 5020584c..f4623286 100644 --- a/docs/03-Features.md +++ b/docs/03-Features.md @@ -30,6 +30,7 @@ Wayfarer is a comprehensive self-hosted travel companion with location tracking, - **Metadata preservation** — accuracy, speed, altitude, heading, and source tracked per location. - **Export locations** to GeoJSON, KML, CSV, or GPX formats with full metadata. - **Personal location providers** retain protected profiles with explicit Mapbox Permanent consent/verification, independent selection, nullable provenance, and provider-native guards; capture remains available without enrichment. See [Personal Location Providers](24-Personal-Location-Providers.md). +- **Geoapify persistent services** add explicit reverse-geocoding backfill, provider-scoped routing mappings, accepted offline-capable route provenance, and authenticated provider-neutral mobile routing without a public fallback. - **Wikipedia integration** — click the Wiki button on any location to see nearby Wikipedia articles; uses dual geo + text search for reliable discovery. - **Activity types** categorize entries (walking, driving, eating, etc.). - **Inline activity editing** — edit activity type directly from location modals and tables. diff --git a/docs/07-Importing-Exporting.md b/docs/07-Importing-Exporting.md index e8a6ba80..4a5983a5 100644 --- a/docs/07-Importing-Exporting.md +++ b/docs/07-Importing-Exporting.md @@ -129,4 +129,4 @@ Wayfarer-native KML schema v2 preserves ordered From/Via/To Place identity, wayp Legacy Wayfarer KML v1 remains supported. Its `DurationMin` value is treated as an intentional Manual duration; absence defaults to Automatic. Imported distance is recalculated by the server from the effective route, and existing public/export duration minutes continue to use `TimeSpan.TotalMinutes`, so whole-second values retain fractional-minute precision. Generic KML and GeoJSON remain geometry-only interchange formats. They do not infer semantic saved-Place waypoints, and generic route coordinates are imported exactly without dense-route simplification. Dense generic-route simplification is deferred to #425; external route generation is deferred to #426. -Imports with supplied addresses retain those values with unknown provenance. Missing-address enrichment is optional and uses the shared admitted persistent-provider boundary; unavailable providers do not stop accepted imports. There is no automatic enrichment queue. Issue #502 owns the coordinated-release explicit bounded backfill. +Imports with supplied addresses retain those values with unknown provenance. Missing-address enrichment is optional and uses the shared admitted persistent-provider boundary; unavailable providers do not stop accepted imports. There is no automatic enrichment queue. The authenticated Geoapify action processes at most 100 wholly unenriched owned Locations chronologically per invocation and resumes from remaining domain state. diff --git a/docs/08-Mobile.md b/docs/08-Mobile.md index 71748935..3e129609 100644 --- a/docs/08-Mobile.md +++ b/docs/08-Mobile.md @@ -84,7 +84,7 @@ This is not cross-platform waypoint parity. Semantic Via identity, offline waypo - Default: OpenStreetMap tiles via your Wayfarer server. - Configurable tile server URL. - Respect usage policies of tile providers. -- WayfarerMobile never receives personal provider credentials; server-side provider-neutral results and the boundary are documented in [Personal Location Providers](24-Personal-Location-Providers.md). +- WayfarerMobile never receives personal provider credentials. Authenticated `/api/mobile/routing/capability/{transportProfileId}` and `/api/mobile/routing/route` resolve provider and mapping server-side and return validated neutral results, stable matching identities, storage authority, and linked attribution. Missing capability leaves saved Segment geometry and Direct guidance available; see [Personal Location Providers](24-Personal-Location-Providers.md) and Mobile #253. --- diff --git a/docs/09-Troubleshooting.md b/docs/09-Troubleshooting.md index b05c7b7f..5807437a 100644 --- a/docs/09-Troubleshooting.md +++ b/docs/09-Troubleshooting.md @@ -4,6 +4,8 @@ For unreadable protected credentials, legacy Mapbox conflicts, exhaustion, guard If Mapbox is configured but paused, complete each distinct step shown in settings: Permanent consent, generic geocoding authorization, explicit verification, active selection, and available Permanent meter capacity. Capture/import remains usable while paused and retains existing enrichment. Verification and provider failures are safe to retry explicitly; no automatic queue is implied. +For Geoapify route suggestions, an unmapped Wayfarer Transport Profile requires an administrator mapping; an unsupported mapping requires one of the closed Geoapify modes. Temporary failures and rolling-credit exhaustion never clear accepted geometry. Wayfarer does not fall back to Mapbox, public OSRM, or another provider. + Sign‑In Issues - Wrong password: reset via account page or ask an admin. - Locked out: your admin can unlock accounts. Enable 2FA for extra security. diff --git a/docs/15-Architecture.md b/docs/15-Architecture.md index ba2745bd..75c656c9 100644 --- a/docs/15-Architecture.md +++ b/docs/15-Architecture.md @@ -255,3 +255,5 @@ Trip editing state is owned by the Vue Trip Editor: - Public/private visibility controls Persistent reverse enrichment has one server-side boundary that owns protected authority, consent, verification, selection, provider-native admission, transport, normalization, generation revalidation, and bounded results. Capture/import/Trip callers never receive credentials or choose storage mode. + +Geoapify routing is a distinct adapter behind the existing proposal seam. Administrator configuration plus stable Transport Profile ID resolves a closed provider mode; mapping/configuration version participates in stale-work authority. Explicit acceptance owns durable Segment route provenance, while the dedicated authenticated mobile route endpoint returns but does not persist ad-hoc routes. diff --git a/docs/16-Configuration.md b/docs/16-Configuration.md index 04e3ba33..1092be1e 100644 --- a/docs/16-Configuration.md +++ b/docs/16-Configuration.md @@ -84,6 +84,8 @@ Reverse Geocoding (Per‑User) - `DataProtection:KeyRingPath` is the persistent key authority for Identity and protected administrator/personal provider credentials. The supported systemd deployment explicitly retains its existing `/home/wayfarer/.aspnet/DataProtection-Keys` authority; backup requirements are in [Personal Location Providers](24-Personal-Location-Providers.md). - `LocationProviders:Geoapify:RollingCreditLimit` defaults to 2,500 credits. `LocationProviders:Mapbox:PermanentGeocodingLimit` and `LocationProviders:Mapbox:DirectionsLimit` configure separate Wayfarer safety counters. Mapbox retained geocoding also requires explicit versioned Permanent consent, verification, and selection; disabling a guard may incur charges. +Geoapify uses one rolling pool across persistent reverse geocoding and routing. The 2,500 default retains headroom below the 3,000-credit Free-plan context retrieved 2026-08-23; Wayfarer cannot observe external account use or infer a provider reset timezone. Administrators configure the fixed Geoapify adapter and closed stable-ID transport mappings, never a user key. + Mobile - `MobileGroups:Query:DefaultPageSize` and `MaxPageSize` — paging for mobile group queries. - `MobileSse:HeartbeatIntervalMilliseconds` — SSE keepalive interval. diff --git a/docs/17-Services.md b/docs/17-Services.md index aa2e3585..e4779775 100644 --- a/docs/17-Services.md +++ b/docs/17-Services.md @@ -32,6 +32,8 @@ This document covers the key services, file parsers, and background jobs in the ### ReverseGeocodingService - Owns persistent reverse enrichment: protected authority, explicit Mapbox Permanent consent and verification, meter admission, `permanent=true`, bounded provider handling, generation revalidation, normalization, and provenance. Callers supply only authenticated user identity, coordinates, and intent. - Personal credentials and provider-native admission are owned by the protected provider foundation; legacy `ApiToken` Mapbox rows migrate non-destructively. See [Personal Location Providers](24-Personal-Location-Providers.md). + +Geoapify reverse geocoding and routing use separate cohesive adapters with fixed official endpoints, response/timeout bounds, exact normalization, and exception containment. Routing resolves administrator-owned provider-scoped stable Transport Profile mappings before shared-credit admission; labels are never interpreted as provider modes. The mobile routing service returns neutral validated routes without persisting ad-hoc server state. - Populates street, city, country, postal code fields. - **Key File**: `Services/ReverseGeocodingService.cs` diff --git a/docs/18-API.md b/docs/18-API.md index c4b53b2a..e1d1d9eb 100644 --- a/docs/18-API.md +++ b/docs/18-API.md @@ -373,6 +373,10 @@ This ensures visit notifications work reliably regardless of app state. - Storage paths under `FileSystem.AppDataDirectory` (e.g., `tiles/trips`). Uses SQLite (`wayfarer.db`) to track downloads. - Throttling: `TileRateLimiter`, `SettingsStore.MaxConcurrentTileDownloads` and `MinTileRequestDelayMs`. - Tile server URL configurable via `SettingsStore.TileServerUrl` (defaults to OSM standard tile server). Respect provider usage policies. + +## Provider-neutral mobile routing + +`GET /api/mobile/routing/capability/{transportProfileId}` authenticates with the existing mobile token and performs no provider contact. `POST /api/mobile/routing/route` accepts a stable Wayfarer Transport Profile ID, origin/destination, and at most three ordered anchors. The client cannot submit a provider, native mode, endpoint, or credential. Responses use bounded outcomes and may include validated geometry, metrics, normalized instructions, generation time, stable non-secret provider/configuration/mapping identity, linked attribution, and offline storage authority. Ad-hoc routes are not persisted by Wayfarer. - Server cache behaviour: the backend caches tiles for zoom levels 0-8 permanently and applies an LRU eviction policy for higher zooms. The default `CacheSettings:MaxCacheSizeMb` is 1024 MB but can be reduced for constrained hosts. - Trip downloads coordinate with `TripContentService` which stores Trip/Region/Place/Area/Segment metadata locally. diff --git a/docs/19-Database.md b/docs/19-Database.md index 27f88024..101fcfeb 100644 --- a/docs/19-Database.md +++ b/docs/19-Database.md @@ -8,6 +8,7 @@ ORM & Provider - EF Core with Npgsql provider and NetTopologySuite for spatial types. - Personal provider profiles, independent selections, Geoapify rolling admissions, and separate Mapbox product meters use constrained PostgreSQL authority; schema and retention are described in [Personal Location Providers](24-Personal-Location-Providers.md). - Mapbox Permanent consent is versioned, UTC-timestamped, and credential-generation-bound. Nullable provider/storage-mode/time provenance on `Location` and `Place` remains unknown for historical and manual/imported values; migrations perform no historical rewrite. +- Accepted Geoapify Segment routes use additive nullable normalized instruction, provider/configuration/profile/mapping, generation-time, attribution, and storage-authority columns. Historical geometry is not rewritten and raw provider responses, credentials, and authenticated URLs are never stored. - PostGIS is required (e.g., `geography(Point, 4326)` for `Location.Coordinates`). Key Entities (selected) diff --git a/docs/21-Security.md b/docs/21-Security.md index 8446d7c2..0672119d 100644 --- a/docs/21-Security.md +++ b/docs/21-Security.md @@ -19,6 +19,7 @@ API Tokens - **Wayfarer API tokens** (used for mobile app and API authentication) are stored as SHA-256 hashes—never in plain text. If the database is compromised, the tokens cannot be recovered or reused. - Tokens are shown **only once** when created or regenerated. Users must copy and store them securely. - **Personal provider credentials** are protected with purpose-, provider-, and user-bound Data Protection and are never redisplayed or sent to mobile. Key-ring backup, filesystem protection, privacy disclosure, and legacy migration are documented in [Personal Location Providers](24-Personal-Location-Providers.md). +- **Geoapify query authentication** stays inside diagnostic-suppressed clients. Credentials, authenticated URLs, coordinates, addresses, geometry, instructions, and raw payloads are excluded from logs and DTOs; adapters translate transport exceptions to bounded product categories. - Rotate API tokens regularly and revoke any that may have been exposed. Authorization diff --git a/docs/24-Personal-Location-Providers.md b/docs/24-Personal-Location-Providers.md index 0ec4b3b9..a9568bec 100644 --- a/docs/24-Personal-Location-Providers.md +++ b/docs/24-Personal-Location-Providers.md @@ -39,6 +39,24 @@ Wayfarer's meter counts only Wayfarer contacts. Other applications or tokens can Historical rows have unknown nullable provenance because they may contain Temporary Mapbox output, imports, or manual edits; this release does not delete or reclassify them. New successful Mapbox enrichment records `mapbox`, `permanent`, and its UTC persistence time. There is no automatic retry or pending queue. [#502](https://github.com/stef-k/Wayfarer/issues/502) owns the same-release explicit bounded backfill after Geoapify becomes available. +## Geoapify persistent geocoding and routing + +Create a Geoapify account and a dedicated Wayfarer API key, then use the ordered settings workflow: credential → geocoding authorization → geocoding verification → geocoding selection → routing authorization → routing verification → routing selection → shared guard/usage → optional backfill → revocation. Verification uses fixed non-personal requests, consumes one admitted credit per attempt, and never selects a provider. One protected key can serve both independently authorized capabilities; it is never sent to the browser or WayfarerMobile. + +Geoapify's Free plan was documented as 3,000 credits per 24 hours when retrieved on 2026-08-23. Wayfarer's enabled default is a conservative 2,500-credit rolling 24-hour safety window shared by geocoding and routing. Wayfarer cannot observe other account/key use or a provider reset timezone. Walk/bicycle routing admits one credit per consecutive waypoint pair; motorcycle/drive/bus conservatively admit 21 per pair. Every retry is admitted separately and admitted failures count. A disabled guard still records use and can risk paid usage or suspension. + +Successful reverse geocoding stores normalized fields with `geoapify`, `persistent`, and UTC provenance. The explicit user action scans at most 100 owned wholly unenriched Locations chronologically; it never overwrites any manual/imported/existing field, stops on exhaustion, and resumes from still-unenriched domain state without a queue. + +Administrators enable a fixed-endpoint Geoapify routing configuration and map stable Wayfarer Transport Profile IDs with a closed dropdown: Not mapped, Walk, Bicycle, Motorcycle, Drive, or Bus. Display labels have no routing meaning: `WALK`, `walk`, `Walking`, localized labels, and custom labels are never matched automatically. Renaming a profile preserves its mapping; changing/removing a mapping advances configuration authority and invalidates stale proposals. Mapbox mappings remain separate under #500. + +An unmapped profile shows “Route suggestions are not configured for this transport profile.” An invalid/stale mapping shows “This routing provider does not support the mapped transport mode.” Temporary provider/configuration failures show “Route suggestions are temporarily unavailable.” Segments remain valid and saveable, manual or prior accepted geometry is preserved, and no alternate provider is contacted. + +Only explicit Trip Editor acceptance persists a Geoapify route. Stored geometry, distance, duration, normalized instructions, stable provider/configuration/profile/mapping provenance, generation time, attribution, and `persistent` authority remain usable after switching, key replacement, outage, or account closure under the terms retrieved 2026-08-23. Ad-hoc mobile routes are returned but not stored by Wayfarer; WayfarerMobile #253 owns bounded local matching and persistence. Display linked [Powered by Geoapify](https://www.geoapify.com/) and [© OpenStreetMap contributors](https://www.openstreetmap.org/copyright) with online and offline routed geometry. + +Geoapify states that request data, headers, IP, and timestamps are used for access, usage, and statistics, and that successful-request data is generally retained no longer than 24 hours. Coordinates, routes, and addresses travel server-to-provider/CDNs. Wayfarer does not log credentials, authenticated URLs, coordinates, returned addresses, geometry, instructions, or raw payloads. + +Issue #505 owns the coordinated release: #502 precedes #500, PostgreSQL and the Data Protection key ring are backed up together, backend deploys first, family accounts are configured explicitly after deployment, and Mobile publishes only after API/device acceptance. No provider is selected automatically and #502 must not be deployed publicly by itself. + Official policy sources retrieved 2026-08-23: [Geocoding v6 API and storage](https://docs.mapbox.com/api/search/geocoding/), [Temporary versus Permanent](https://docs.mapbox.com/help/dive-deeper/understand-temporary-vs-permanent-geocoding/), [pricing](https://www.mapbox.com/pricing/), and [attribution guidance](https://docs.mapbox.com/help/dive-deeper/attribution/). Valid protected data always wins and is never overwritten. Matching duplicate casing rows converge; distinct values, invalid ciphertext, and revoked profiles preserve every recovery copy and fail closed without provider contact. Reruns are idempotent. Unrelated inbound Wayfarer API tokens and all domain data are untouched. From 92ee9a560cc8cdfcae950d2c32bafa7038a9576a Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 21:10:08 +0300 Subject: [PATCH 15/29] fix(providers): finalize attribution and additive contracts --- .../trip-editor/src/components/SegmentRouteProposal.vue | 6 +++++- Program.cs | 4 ++-- .../ExternalRoutingCapabilityProjector.cs | 9 ++++++--- .../RoutingProviderAdministrationService.cs | 6 ++++-- .../Models/PublicSegmentContractGapTests.cs | 5 ++++- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue b/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue index 7506fbf4..366b5f48 100644 --- a/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue +++ b/ClientApps/trip-editor/src/components/SegmentRouteProposal.vue @@ -114,7 +114,11 @@ onUnmounted(() => {

External routed path

{{ capability.providerDisplayName }} · {{ capability.mappedProfileLabel }}

{{ capability.disclosure }}

-

{{ capability.attribution }}

+

+ Powered by Geoapify · + © OpenStreetMap contributors +

+

{{ capability.attribution }}

Save the transport-profile change before generating a new proposal.

} + diff --git a/wwwroot/js/admin/routing-provider-mappings.js b/wwwroot/js/admin/routing-provider-mappings.js new file mode 100644 index 00000000..231a7fd6 --- /dev/null +++ b/wwwroot/js/admin/routing-provider-mappings.js @@ -0,0 +1,51 @@ +const geoapifyModes = new Set(['walk', 'bicycle', 'motorcycle', 'drive', 'bus']); + +/** Returns the closed mapping control state for the selected adapter. */ +export const mappingControlState = (adapterType, currentValue) => adapterType === '2' + ? { kind: 'select', value: geoapifyModes.has(currentValue) ? currentValue : '' } + : { kind: 'input', value: currentValue }; + +const options = [ + ['', 'Not mapped'], ['walk', 'Walk'], ['bicycle', 'Bicycle'], + ['motorcycle', 'Motorcycle'], ['drive', 'Drive'], ['bus', 'Bus'] +]; + +/** Replaces one mapping field without changing its stable posted identity. */ +const replaceControl = (control, adapterType) => { + const state = mappingControlState(adapterType, control.value); + if (control.tagName.toLowerCase() === state.kind) { + control.value = state.value; + return control; + } + const replacement = document.createElement(state.kind); + for (const attribute of control.attributes) replacement.setAttribute(attribute.name, attribute.value); + replacement.dataset.routingMappingControl = ''; + if (state.kind === 'select') { + replacement.classList.remove('form-control'); + replacement.classList.add('form-select'); + replacement.removeAttribute('placeholder'); + for (const [value, label] of options) replacement.add(new Option(label, value)); + } else { + replacement.classList.remove('form-select'); + replacement.classList.add('form-control'); + replacement.placeholder = 'Exact OSRM profile, e.g. driving'; + } + replacement.value = state.value; + control.replaceWith(replacement); + return replacement; +}; + +/** Activates immediate adapter-aware mapping controls on one administration form. */ +export const initializeRoutingProviderMappings = (root = document) => { + const adapter = root.querySelector('#AdapterType'); + if (!adapter) return; + const update = () => root.querySelectorAll('[data-routing-mapping-control]') + .forEach(control => replaceControl(control, adapter.value)); + adapter.addEventListener('change', update); + update(); +}; + +if (typeof document !== 'undefined') { + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => initializeRoutingProviderMappings()); + else initializeRoutingProviderMappings(); +} From 0b03105a349168ec30e8f2ef0bc4aee5d6a788d8 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 21:40:37 +0300 Subject: [PATCH 25/29] test(api): preserve valid personal Geoapify routing --- .../MobileRoutingServiceAuthorityTests.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/Wayfarer.Tests/Services/MobileRoutingServiceAuthorityTests.cs b/tests/Wayfarer.Tests/Services/MobileRoutingServiceAuthorityTests.cs index adbac2e3..4770c311 100644 --- a/tests/Wayfarer.Tests/Services/MobileRoutingServiceAuthorityTests.cs +++ b/tests/Wayfarer.Tests/Services/MobileRoutingServiceAuthorityTests.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.DataProtection; using Wayfarer.Models; +using Wayfarer.Models.LocationProviders; +using Wayfarer.Services.LocationProviders; using Wayfarer.Services.ExternalRouting; using Wayfarer.Tests.Infrastructure; using Xunit; @@ -48,6 +50,49 @@ public async Task ServerDefaultOsrmIsUnavailableWithoutContactOrGeoapifyMetadata Assert.Equal(0, client.Requests); } + [Fact] + public async Task CurrentPersonallySelectedGeoapifyAuthorityRemainsAvailableAndRoutable() + { + var db = CreateDbContext(); + var transport = db.Set().First(); + var provider = new RoutingProviderConfiguration + { + Id = Guid.NewGuid(), DisplayName = "Geoapify", AdapterType = RoutingAdapterType.Geoapify, + Enabled = true, BaseEndpoint = "https://api.geoapify.com/", ConfigurationVersion = 2, + VerifiedConfigurationVersion = 2 + }; + provider.ProfileMappings.Add(new RoutingProviderProfileMapping + { + RoutingProviderConfigurationId = provider.Id, TransportProfileId = transport.Id, OsrmProfile = "walk" + }); + var protection = new EphemeralDataProtectionProvider(); + var credentials = new PersonalProviderCredentialService(protection); + var personal = PersonalLocationProviderProfile.Create("owner", PersonalLocationProvider.Geoapify); + credentials.Replace(personal, "secret"); + personal.RoutingAuthorized = true; + personal.RoutingVerification = PersonalProviderVerification.Verified; + personal.RoutingVerifiedCredentialGeneration = personal.CredentialGeneration; + personal.RoutingVerifiedConfigurationGeneration = personal.RoutingGeneration; + db.AddRange(provider, personal, + new PersonalLocationProviderSelection { UserId = "owner", RoutingProviderKey = "geoapify" }); + db.ApplicationSettings.Add(new ApplicationSettings { Id = 1, ExternalRouteGenerationEnabled = true }); + await db.SaveChangesAsync(); + var resolver = new AuthoritativeRoutingProviderResolver(db, new(protection), new(protection), credentials); + var client = new RecordingClient(); + var service = new MobileRoutingService(db, resolver, client, new AcceptingValidator(), new()); + + var capability = await service.CapabilityAsync("owner", transport.Id, default); + var route = await service.RouteAsync("owner", transport.Id, [new(20, 10), new(21, 11)], default); + + Assert.Equal("available", capability.Outcome); + Assert.Equal("geoapify", capability.Provider); + Assert.Equal("persistent", capability.StorageMode); + Assert.True(route.Succeeded); + Assert.Equal("geoapify", route.Provider); + Assert.Equal("persistent", route.StorageMode); + Assert.Equal(1, client.Requests); + } + private sealed class RecordingClient : IOsrmRouteClient { public int Requests { get; private set; } From 2979c3ee58a78cb4896a1d4a624071917b5c4a03 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 21:59:39 +0300 Subject: [PATCH 26/29] WIP: cover durable cancelled backfill admission (checkpoint; tests failing) --- ...eoapifyBackfillConcurrencyPostgresTests.cs | 49 +++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs index fc454e97..9e1824c6 100644 --- a/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs +++ b/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs @@ -19,6 +19,47 @@ namespace Wayfarer.Tests.Services; [Collection(PostgresImportTestCollection.Name)] public sealed class GeoapifyBackfillConcurrencyPostgresTests(PostgresImportTestFixture fixture) { + /// Proves cancellation after contact retains admission and releases durable ownership. + [PostgresFact] + public async Task CancellationAfterContactRetainsAdmissionAndAllowsRetry() + { + var user = await fixture.CreateUserAsync(); + var protection = new EphemeralDataProtectionProvider(); + await SeedAsync(user.Id, null, protection); + var cancelledHandler = new CoordinatedHandler(user.Id, null); + await using var cancelledDb = fixture.CreateContext(); + var cancelledService = Service(cancelledDb, protection, cancelledHandler); + using var cancellation = new CancellationTokenSource(); + + var cancelledRun = cancelledService.RunAsync(user.Id, cancellation.Token); + await cancelledHandler.FirstUserRequestEntered; + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => cancelledRun); + + await using (var verify = fixture.CreateContext()) + { + Assert.Equal(1, await verify.Set() + .CountAsync(item => item.UserId == user.Id)); + var location = await verify.Locations.SingleAsync(item => item.UserId == user.Id); + Assert.True(GeoapifyLocationBackfillService.IsWhollyUnenriched(location)); + Assert.Equal(1, handlerRequests(cancelledHandler, user.Id)); + } + + var retryHandler = new CoordinatedHandler(user.Id, null); + await using var retryDb = fixture.CreateContext(); + var retry = Service(retryDb, protection, retryHandler).RunAsync(user.Id); + await retryHandler.FirstUserRequestEntered; + retryHandler.Release(); + await retry; + + await using var final = fixture.CreateContext(); + Assert.Equal(2, await final.Set().CountAsync(item => item.UserId == user.Id)); + Assert.Equal(1, retryHandler.RequestsFor(user.Id)); + Assert.Equal("geoapify", (await final.Locations.SingleAsync(item => item.UserId == user.Id)).ReverseGeocodingProvider); + + static int handlerRequests(CoordinatedHandler handler, string userId) => handler.RequestsFor(userId); + } + [PostgresFact] public async Task ConcurrentSameUserInvocationsContactOnceWhileAnotherUserRemainsIndependent() { @@ -66,10 +107,10 @@ SELECT EXISTS (SELECT 1 FROM pg_stat_activity } } - private async Task SeedAsync(string userId, string otherUserId, IDataProtectionProvider protection) + private async Task SeedAsync(string userId, string? otherUserId, IDataProtectionProvider protection) { await using var db = fixture.CreateContext(); - foreach (var id in new[] { userId, otherUserId }) + foreach (var id in new[] { userId, otherUserId }.OfType()) { var profile = PersonalLocationProviderProfile.Create(id, PersonalLocationProvider.Geoapify); new PersonalProviderCredentialService(protection).Replace(profile, $"key-{id}"); @@ -98,7 +139,7 @@ private static GeoapifyLocationBackfillService Service(ApplicationDbContext db, return new GeoapifyLocationBackfillService(db, reverse); } - private sealed class CoordinatedHandler(string primaryUserId, string otherUserId) : HttpMessageHandler + private sealed class CoordinatedHandler(string primaryUserId, string? otherUserId) : HttpMessageHandler { private readonly TaskCompletionSource _first = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _other = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -115,7 +156,7 @@ protected override async Task SendAsync(HttpRequestMessage var userId = Uri.UnescapeDataString(key)[4..]; lock (_requests) _requests[userId] = _requests.GetValueOrDefault(userId) + 1; if (userId == primaryUserId) _first.TrySetResult(); - if (userId == otherUserId) _other.TrySetResult(); + if (otherUserId != null && userId == otherUserId) _other.TrySetResult(); await _release.Task.WaitAsync(cancellationToken); return new(System.Net.HttpStatusCode.OK) { From 2ec8b21bf566f7639437fd0702fbe3dcd2568204 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 22:02:20 +0300 Subject: [PATCH 27/29] fix(geocoding): commit backfill admission before provider contact --- Program.cs | 1 + .../GeoapifyLocationBackfillService.cs | 39 ++++++++++++++----- .../LegacyMapboxMigrationService.cs | 2 +- .../PersonalProviderContactGate.cs | 4 +- ...eoapifyBackfillConcurrencyPostgresTests.cs | 17 +++++++- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/Program.cs b/Program.cs index 9203abd9..cd7b6a47 100644 --- a/Program.cs +++ b/Program.cs @@ -315,6 +315,7 @@ static void ConfigureDatabase(WebApplicationBuilder builder) options.ConfigureWarnings(warnings => warnings.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)); }); + builder.Services.AddSingleton, BackfillLockDbContextFactory>(); // Add exception handling for database-related errors during development builder.Services.AddDatabaseDeveloperPageExceptionFilter(); diff --git a/Services/LocationProviders/GeoapifyLocationBackfillService.cs b/Services/LocationProviders/GeoapifyLocationBackfillService.cs index 08588d03..6db552a6 100644 --- a/Services/LocationProviders/GeoapifyLocationBackfillService.cs +++ b/Services/LocationProviders/GeoapifyLocationBackfillService.cs @@ -6,7 +6,8 @@ namespace Wayfarer.Services.LocationProviders; /// Runs one explicit bounded and resumable Geoapify Location enrichment invocation. public sealed class GeoapifyLocationBackfillService( - ApplicationDbContext dbContext, ReverseGeocodingService reverseGeocoding) + ApplicationDbContext dbContext, ReverseGeocodingService reverseGeocoding, + IDbContextFactory dbContextFactory) { /// Gets the strict maximum records scanned by one invocation. public const int MaximumRecords = 100; @@ -14,16 +15,28 @@ public sealed class GeoapifyLocationBackfillService( /// Runs one user-owned chronological invocation and returns content-free progress. public async Task RunAsync(string userId, CancellationToken cancellationToken = default) { - await using var transaction = dbContext.Database.IsNpgsql() - ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; - if (transaction != null) - { - // The exact user row is the durable invocation authority. Holding it across bounded provider calls is - // intentional: candidate selection cannot otherwise guarantee at-most-one admission/contact per Location. - _ = await dbContext.Users.FromSqlInterpolated($$""" + await using var lockOwner = dbContext.Database.IsNpgsql() + ? await dbContextFactory.CreateDbContextAsync(cancellationToken) : null; + await using var lockTransaction = lockOwner == null + ? null : await lockOwner.Database.BeginTransactionAsync(cancellationToken); + if (lockOwner != null) + _ = await lockOwner.Users.FromSqlInterpolated($$""" SELECT * FROM "AspNetUsers" WHERE "Id" = {{userId}} FOR UPDATE """).AsNoTracking().SingleAsync(cancellationToken); + + try + { + return await RunOperationalAsync(userId, cancellationToken); } + finally + { + // This transaction owns only invocation serialization. Operational transactions commit independently. + if (lockTransaction != null) await lockTransaction.RollbackAsync(CancellationToken.None); + } + } + + private async Task RunOperationalAsync(string userId, CancellationToken cancellationToken) + { var ids = await LoadCandidateIdsAsync(dbContext, userId, MaximumRecords, cancellationToken); var scanned = 0; var succeeded = 0; var noResult = 0; var unavailable = 0; var exhausted = false; foreach (var id in ids) @@ -55,7 +68,6 @@ or ReverseGeocodingCategory.NoProviderSelected or ReverseGeocodingCategory.Verif } var remaining = await WhollyUnenriched(dbContext.Locations.Where(item => item.UserId == userId)) .CountAsync(cancellationToken); - if (transaction != null) await transaction.CommitAsync(cancellationToken); return new(scanned, succeeded, noResult, unavailable, remaining, exhausted); } @@ -86,6 +98,15 @@ private static IQueryable WhollyUnenriched(IQueryable query) && value.ReverseGeocodedAt == null); } +/// Creates independent contexts for transaction-scoped backfill lock ownership. +public sealed class BackfillLockDbContextFactory( + DbContextOptions options, IServiceProvider services) + : IDbContextFactory +{ + /// Creates a context whose connection is never shared with operational persistence. + public ApplicationDbContext CreateDbContext() => new(options, services); +} + /// Contains bounded content-free progress for one explicit backfill invocation. public sealed record GeoapifyBackfillResult( int Scanned, int Succeeded, int NoResult, int Unavailable, int RemainingEstimate, bool Exhausted); diff --git a/Services/LocationProviders/LegacyMapboxMigrationService.cs b/Services/LocationProviders/LegacyMapboxMigrationService.cs index 6050fe2c..0e6fa23a 100644 --- a/Services/LocationProviders/LegacyMapboxMigrationService.cs +++ b/Services/LocationProviders/LegacyMapboxMigrationService.cs @@ -12,7 +12,7 @@ public sealed class LegacyMapboxMigrationService( public async Task MigrateAsync(string userId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(userId); - await using var transaction = dbContext.Database.IsRelational() && dbContext.Database.CurrentTransaction == null + await using var transaction = dbContext.Database.IsRelational() ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; var profile = await LockProfileAsync(userId, cancellationToken); diff --git a/Services/LocationProviders/PersonalProviderContactGate.cs b/Services/LocationProviders/PersonalProviderContactGate.cs index 8a7f8bf0..5ce4820b 100644 --- a/Services/LocationProviders/PersonalProviderContactGate.cs +++ b/Services/LocationProviders/PersonalProviderContactGate.cs @@ -251,7 +251,7 @@ private async Task ResolveAsync( private async Task AdmitGeoapifyAsync( string userId, PersonalProviderProduct product, int credits, CancellationToken cancellationToken) { - await using var transaction = dbContext.Database.IsRelational() && dbContext.Database.CurrentTransaction == null + await using var transaction = dbContext.Database.IsRelational() ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; var guard = await LockGeoapifyGuardAsync(userId, cancellationToken); var now = dbContext.Database.IsNpgsql() @@ -301,7 +301,7 @@ await dbContext.Database.ExecuteSqlInterpolatedAsync($$""" private async Task AdmitMapboxAsync( string userId, PersonalProviderProduct product, int cost, CancellationToken cancellationToken) { - await using var transaction = dbContext.Database.IsRelational() && dbContext.Database.CurrentTransaction == null + await using var transaction = dbContext.Database.IsRelational() ? await dbContext.Database.BeginTransactionAsync(cancellationToken) : null; var meter = await LockMapboxMeterAsync(userId, product, cancellationToken); var today = dbContext.Database.IsNpgsql() diff --git a/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs index 9e1824c6..a4623590 100644 --- a/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs +++ b/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs @@ -33,6 +33,9 @@ public async Task CancellationAfterContactRetainsAdmissionAndAllowsRetry() var cancelledRun = cancelledService.RunAsync(user.Id, cancellation.Token); await cancelledHandler.FirstUserRequestEntered; + await using (var duringContact = fixture.CreateContext()) + Assert.Equal(1, await duringContact.Set() + .CountAsync(item => item.UserId == user.Id)); cancellation.Cancel(); await Assert.ThrowsAnyAsync(() => cancelledRun); @@ -77,6 +80,9 @@ public async Task ConcurrentSameUserInvocationsContactOnceWhileAnotherUserRemain var firstRun = first.RunAsync(user.Id); await handler.FirstUserRequestEntered; + await using (var duringContact = fixture.CreateContext()) + Assert.Equal(1, await duringContact.Set() + .CountAsync(item => item.UserId == user.Id)); var secondRun = second.RunAsync(user.Id); var otherRun = independent.RunAsync(other.Id); await handler.OtherUserRequestEntered; @@ -120,6 +126,7 @@ private async Task SeedAsync(string userId, string? otherUserId, IDataProtection profile.GeocodingVerifiedConfigurationGeneration = profile.GeocodingGeneration; db.Add(profile); db.Add(new PersonalLocationProviderSelection { UserId = id, GeocodingProviderKey = "geoapify" }); + db.Add(new GeoapifyUsageGuard { UserId = id }); db.Locations.Add(new Location { UserId = id, Timestamp = DateTime.UtcNow, LocalTimestamp = DateTime.UtcNow, TimeZoneId = "UTC", @@ -129,14 +136,20 @@ private async Task SeedAsync(string userId, string? otherUserId, IDataProtection await db.SaveChangesAsync(); } - private static GeoapifyLocationBackfillService Service(ApplicationDbContext db, + private GeoapifyLocationBackfillService Service(ApplicationDbContext db, IDataProtectionProvider protection, CoordinatedHandler handler) { var credentials = new PersonalProviderCredentialService(protection); var gate = new PersonalProviderContactGate(db, credentials, new LegacyMapboxMigrationService(db, credentials), new ConfigurationBuilder().Build()); var reverse = new ReverseGeocodingService(new HttpClient(handler), NullLogger.Instance, gate, db); - return new GeoapifyLocationBackfillService(db, reverse); + return new GeoapifyLocationBackfillService(db, reverse, new FixtureDbContextFactory(fixture)); + } + + private sealed class FixtureDbContextFactory(PostgresImportTestFixture fixture) + : IDbContextFactory + { + public ApplicationDbContext CreateDbContext() => fixture.CreateContext(); } private sealed class CoordinatedHandler(string primaryUserId, string? otherUserId) : HttpMessageHandler From b970d43f1132fe47b5c0e867ee8bf0dd60bbab25 Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 22:04:00 +0300 Subject: [PATCH 28/29] test(geocoding): cover bounded backfill failure admission --- ...eoapifyBackfillConcurrencyPostgresTests.cs | 58 ++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs index a4623590..58a2e3d9 100644 --- a/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs +++ b/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs @@ -19,6 +19,56 @@ namespace Wayfarer.Tests.Services; [Collection(PostgresImportTestCollection.Name)] public sealed class GeoapifyBackfillConcurrencyPostgresTests(PostgresImportTestFixture fixture) { + /// Proves cancellation before durable ownership/admission has no provider cost. + [PostgresFact] + public async Task CancellationBeforeAdmissionCostsNothing() + { + var user = await fixture.CreateUserAsync(); + var protection = new EphemeralDataProtectionProvider(); + await SeedAsync(user.Id, null, protection); + var handler = new CoordinatedHandler(user.Id, null); + await using var db = fixture.CreateContext(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + () => Service(db, protection, handler).RunAsync(user.Id, cancellation.Token)); + + await using var verify = fixture.CreateContext(); + Assert.Equal(0, handler.RequestsFor(user.Id)); + Assert.Equal(0, await verify.Set().CountAsync(item => item.UserId == user.Id)); + } + + /// Proves admitted timeout and provider failure remain charged and release ownership for retry. + [PostgresFact] + public async Task AdmittedFailuresRetainAdmissionAndAllowRetry() + { + foreach (var outcome in new[] { ContactOutcome.Timeout, ContactOutcome.ProviderFailure }) + { + var user = await fixture.CreateUserAsync(); + var protection = new EphemeralDataProtectionProvider(); + await SeedAsync(user.Id, null, protection); + var failedHandler = new CoordinatedHandler(user.Id, null, outcome); + await using var failedDb = fixture.CreateContext(); + var failedRun = Service(failedDb, protection, failedHandler).RunAsync(user.Id); + await failedHandler.FirstUserRequestEntered; + failedHandler.Release(); + var failure = await failedRun; + Assert.Equal(1, failure.Unavailable); + + var retryHandler = new CoordinatedHandler(user.Id, null); + await using var retryDb = fixture.CreateContext(); + var retry = Service(retryDb, protection, retryHandler).RunAsync(user.Id); + await retryHandler.FirstUserRequestEntered; + retryHandler.Release(); + await retry; + + await using var verify = fixture.CreateContext(); + Assert.Equal(2, await verify.Set().CountAsync(item => item.UserId == user.Id)); + Assert.Equal("geoapify", (await verify.Locations.SingleAsync(item => item.UserId == user.Id)).ReverseGeocodingProvider); + } + } + /// Proves cancellation after contact retains admission and releases durable ownership. [PostgresFact] public async Task CancellationAfterContactRetainsAdmissionAndAllowsRetry() @@ -152,7 +202,8 @@ private sealed class FixtureDbContextFactory(PostgresImportTestFixture fixture) public ApplicationDbContext CreateDbContext() => fixture.CreateContext(); } - private sealed class CoordinatedHandler(string primaryUserId, string? otherUserId) : HttpMessageHandler + private sealed class CoordinatedHandler( + string primaryUserId, string? otherUserId, ContactOutcome outcome = ContactOutcome.Success) : HttpMessageHandler { private readonly TaskCompletionSource _first = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _other = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -171,6 +222,9 @@ protected override async Task SendAsync(HttpRequestMessage if (userId == primaryUserId) _first.TrySetResult(); if (otherUserId != null && userId == otherUserId) _other.TrySetResult(); await _release.Task.WaitAsync(cancellationToken); + if (outcome == ContactOutcome.Timeout) throw new TaskCanceledException(); + if (outcome == ContactOutcome.ProviderFailure) + return new(System.Net.HttpStatusCode.ServiceUnavailable); return new(System.Net.HttpStatusCode.OK) { Content = new StringContent(""" @@ -179,4 +233,6 @@ protected override async Task SendAsync(HttpRequestMessage }; } } + + private enum ContactOutcome { Success, Timeout, ProviderFailure } } From d629821cff2a77e4087db371945d9d486ee6d91e Mon Sep 17 00:00:00 2001 From: Stef Kariotidis Date: Sun, 23 Aug 2026 22:17:23 +0300 Subject: [PATCH 29/29] test(geocoding): assert single concurrent backfill admission --- ...eoapifyBackfillConcurrencyPostgresTests.cs | 61 +++++++++++++------ 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs b/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs index 58a2e3d9..6c39ae20 100644 --- a/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs +++ b/tests/Wayfarer.Tests/Services/GeoapifyBackfillConcurrencyPostgresTests.cs @@ -129,19 +129,31 @@ public async Task ConcurrentSameUserInvocationsContactOnceWhileAnotherUserRemain var independent = Service(otherDb, protection, handler); var firstRun = first.RunAsync(user.Id); - await handler.FirstUserRequestEntered; - await using (var duringContact = fixture.CreateContext()) - Assert.Equal(1, await duringContact.Set() - .CountAsync(item => item.UserId == user.Id)); - var secondRun = second.RunAsync(user.Id); - var otherRun = independent.RunAsync(other.Id); - await handler.OtherUserRequestEntered; - var ownershipObserved = ObserveLockOrDuplicateContactAsync(handler, user.Id); - await ownershipObserved; - handler.Release(); - await Task.WhenAll(firstRun, secondRun, otherRun); + Task? secondRun = null; + Task? otherRun = null; + try + { + await handler.FirstUserRequestEntered; + await using (var duringContact = fixture.CreateContext()) + Assert.Equal(1, await duringContact.Set() + .CountAsync(item => item.UserId == user.Id)); + secondRun = second.RunAsync(user.Id); + otherRun = independent.RunAsync(other.Id); + await handler.OtherUserRequestEntered; + await ObserveLockOrDuplicateContactAsync(handler, user.Id); + } + finally + { + handler.Release(); + } + await Task.WhenAll(firstRun, secondRun!, otherRun!); await using var verify = fixture.CreateContext(); + var sameUserAdmissions = await verify.GeoapifyUsageAdmissions + .Where(item => item.UserId == user.Id).ToListAsync(); + var admission = Assert.Single(sameUserAdmissions); + Assert.Equal(1, admission.Credits); + Assert.Equal(PersonalProviderProduct.Geocoding, admission.Product); Assert.Equal(1, handler.RequestsFor(user.Id)); Assert.Equal(1, await verify.Locations.CountAsync(item => item.UserId == user.Id && item.ReverseGeocodingProvider == "geoapify")); Assert.Equal(1, handler.RequestsFor(other.Id)); @@ -149,17 +161,26 @@ public async Task ConcurrentSameUserInvocationsContactOnceWhileAnotherUserRemain private async Task ObserveLockOrDuplicateContactAsync(CoordinatedHandler handler, string userId) { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); await using var connection = fixture.CreateConnection(); - await connection.OpenAsync(); - while (handler.RequestsFor(userId) < 2) + try + { + await connection.OpenAsync(timeout.Token); + while (handler.RequestsFor(userId) < 2) + { + await using var command = new NpgsqlCommand(""" + SELECT EXISTS (SELECT 1 FROM pg_stat_activity + WHERE wait_event_type = 'Lock' + AND (query LIKE '%pg_advisory_xact_lock%' OR query LIKE '%AspNetUsers%FOR UPDATE%')) + """, connection); + if ((bool)(await command.ExecuteScalarAsync(timeout.Token))!) return; + await Task.Yield(); + timeout.Token.ThrowIfCancellationRequested(); + } + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) { - await using var command = new NpgsqlCommand(""" - SELECT EXISTS (SELECT 1 FROM pg_stat_activity - WHERE wait_event_type = 'Lock' - AND (query LIKE '%pg_advisory_xact_lock%' OR query LIKE '%AspNetUsers%FOR UPDATE%')) - """, connection); - if ((bool)(await command.ExecuteScalarAsync())!) return; - await Task.Yield(); + Assert.Fail("The competing same-user backfill did not enter the expected PostgreSQL lock wait within 10 seconds."); } }