Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3c575f2
WIP: cover Geoapify verification and credits (checkpoint; tests failing)
stef-k Aug 23, 2026
0555eee
feat(providers): verify Geoapify capabilities and costs
stef-k Aug 23, 2026
404db51
WIP: cover persistent Geoapify enrichment (checkpoint; tests failing)
stef-k Aug 23, 2026
ee65b8f
feat(geocoding): add persistent Geoapify enrichment
stef-k Aug 23, 2026
32dbb28
WIP: cover bounded Geoapify backfill (checkpoint; tests failing)
stef-k Aug 23, 2026
e6764f4
feat(geocoding): add bounded Geoapify backfill
stef-k Aug 23, 2026
4ce201a
WIP: cover Geoapify routing adapter (checkpoint; tests failing)
stef-k Aug 23, 2026
efabd21
feat(routing): add Geoapify routing and profile mappings
stef-k Aug 23, 2026
1e1c04d
WIP: cover accepted route provenance (checkpoint; tests failing)
stef-k Aug 23, 2026
03e8eb6
feat(trips): persist authorized route provenance
stef-k Aug 23, 2026
4400127
WIP: cover provider-neutral mobile routing (checkpoint; tests failing)
stef-k Aug 23, 2026
684e6d4
feat(api): expose provider-neutral mobile routing
stef-k Aug 23, 2026
c688ad4
feat(settings): complete Geoapify provider workflow
stef-k Aug 23, 2026
5a6cea6
docs(providers): document Geoapify usage and mappings
stef-k Aug 23, 2026
92ee9a5
fix(providers): finalize attribution and additive contracts
stef-k Aug 23, 2026
6ce1c37
fix(providers): atomically bind Geoapify verification
stef-k Aug 23, 2026
2344f3d
fix(trips): refresh route acceptance concurrency
stef-k Aug 23, 2026
297ba32
test(providers): clarify zero-contact assertion
stef-k Aug 23, 2026
144c95b
WIP: cover final Geoapify integration corrections (checkpoint; tests …
stef-k Aug 23, 2026
d642a3c
fix(api): require personal Geoapify authority for mobile routing
stef-k Aug 23, 2026
f3a30c8
test(geocoding): observe durable backfill lock alternatives
stef-k Aug 23, 2026
582e5d8
fix(geocoding): serialize user backfill execution
stef-k Aug 23, 2026
de0ad40
fix(routing): reject incoherent Geoapify routes
stef-k Aug 23, 2026
30c2a06
fix(settings): keep provider mappings closed
stef-k Aug 23, 2026
0b03105
test(api): preserve valid personal Geoapify routing
stef-k Aug 23, 2026
2979c3e
WIP: cover durable cancelled backfill admission (checkpoint; tests fa…
stef-k Aug 23, 2026
2ec8b21
fix(geocoding): commit backfill admission before provider contact
stef-k Aug 23, 2026
b970d43
test(geocoding): cover bounded backfill failure admission
stef-k Aug 23, 2026
d629821
test(geocoding): assert single concurrent backfill admission
stef-k Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions Areas/Admin/Controllers/RoutingProviderController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ public async Task<IActionResult> Index(CancellationToken cancellationToken)

/// <summary>Displays a new typed OSRM configuration.</summary>
public async Task<IActionResult> Create(CancellationToken cancellationToken) =>
View(await PopulateMappingsAsync(new RoutingProviderEditViewModel(), cancellationToken));
View(await PopulateMappingsAsync(new RoutingProviderEditViewModel
{ VerificationFromLongitude = 0, VerificationFromLatitude = 0, VerificationToLongitude = 0.01, VerificationToLatitude = 0 }, cancellationToken));

/// <summary>Creates one allowlisted OSRM configuration.</summary>
[HttpPost, ValidateAntiForgeryToken]
Expand Down Expand Up @@ -145,7 +146,8 @@ private async Task<RoutingProviderEditViewModel> 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,
Expand Down
13 changes: 9 additions & 4 deletions Areas/Admin/Models/RoutingProviderViewModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public sealed record RoutingProviderRowViewModel(
/// <summary>Contains allowlisted OSRM configuration and mapping edit fields.</summary>
public sealed class RoutingProviderEditViewModel : IValidatableObject
{
/// <summary>Gets or sets the explicit adapter owned by this configuration.</summary>
[EnumDataType(typeof(RoutingAdapterType))]
public RoutingAdapterType AdapterType { get; set; } = RoutingAdapterType.OsrmCompatible;
/// <summary>Gets or sets the provider identity for edits.</summary>
public Guid Id { get; set; }

Expand Down Expand Up @@ -96,7 +99,8 @@ public sealed class RoutingProviderEditViewModel : IValidatableObject
/// <summary>Validates coordinates and credential-required completeness.</summary>
public IEnumerable<ValidationResult> 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[]
{
Expand All @@ -105,10 +109,11 @@ public IEnumerable<ValidationResult> 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)]);
}
}

Expand Down
15 changes: 12 additions & 3 deletions Areas/Admin/Views/RoutingProvider/_Form.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<input asp-for="ConfigurationVersion" type="hidden" /><input asp-for="CredentialPresent" type="hidden" />
<div class="row g-3">
<div class="col-md-6"><label asp-for="DisplayName" class="form-label"></label><input asp-for="DisplayName" class="form-control" /><span asp-validation-for="DisplayName" class="text-danger"></span></div>
<div class="col-md-6"><label class="form-label">Adapter</label><input class="form-control" value="OSRM-compatible" disabled /></div>
<div class="col-12"><label asp-for="BaseEndpoint" class="form-label"></label><input asp-for="BaseEndpoint" class="form-control" /><span asp-validation-for="BaseEndpoint" class="text-danger"></span></div>
<div class="col-md-6"><label asp-for="AdapterType" class="form-label">Adapter</label><select asp-for="AdapterType" class="form-select"><option value="1">OSRM-compatible</option><option value="2">Geoapify</option></select></div>
<div class="col-12"><label asp-for="BaseEndpoint" class="form-label"></label><input asp-for="BaseEndpoint" class="form-control" /><div class="form-text">Geoapify always uses its fixed official endpoint; this value is ignored.</div><span asp-validation-for="BaseEndpoint" class="text-danger"></span></div>
<div class="col-md-8"><label asp-for="Credential" class="form-label">Replacement credential</label><input asp-for="Credential" type="password" class="form-control" autocomplete="new-password" /><div class="form-text">Leave blank to preserve the @(Model.CredentialPresent ? "stored credential" : "empty credential").</div></div>
<div class="col-md-4 form-check mt-md-5"><input asp-for="CredentialRequired" class="form-check-input" /><label asp-for="CredentialRequired" class="form-check-label">Credential required</label></div>
<div class="col-md-4 form-check ms-2"><input asp-for="Enabled" class="form-check-input" /><label asp-for="Enabled" class="form-check-label">Configuration enabled</label></div>
Expand All @@ -33,8 +33,17 @@
<div class="row g-2 mb-2">
<input asp-for="Mappings[index].TransportProfileId" type="hidden" />
<div class="col-md-6"><label class="form-label" for="Mappings_@(index)__OsrmProfile">@Model.Mappings[index].TransportProfileLabel</label></div>
<div class="col-md-6"><input asp-for="Mappings[index].OsrmProfile" class="form-control" placeholder="Exact OSRM profile, e.g. driving" /></div>
<div class="col-md-6">
@if (Model.AdapterType == Wayfarer.Models.RoutingAdapterType.Geoapify)
{
<select asp-for="Mappings[index].OsrmProfile" class="form-select" data-routing-mapping-control><option value="">Not mapped</option><option value="walk">Walk</option><option value="bicycle">Bicycle</option><option value="motorcycle">Motorcycle</option><option value="drive">Drive</option><option value="bus">Bus</option></select>
}
else
{ <input asp-for="Mappings[index].OsrmProfile" class="form-control" placeholder="Exact OSRM profile, e.g. driving" data-routing-mapping-control /> }
<span asp-validation-for="Mappings[index].OsrmProfile" class="text-danger"></span>
</div>
</div>
}
</fieldset>
<button type="submit" class="btn btn-primary mt-3">Save configuration</button>
<script type="module" src="~/js/admin/routing-provider-mappings.js"></script>
64 changes: 64 additions & 0 deletions Areas/Api/Controllers/MobileRoutingController.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Exposes authenticated provider-neutral mobile routing without provider selection or persistence.</summary>
[Route("api/mobile/routing")]
public sealed class MobileRoutingController(
ApplicationDbContext dbContext, ILogger<BaseApiController> logger, IMobileCurrentUserAccessor userAccessor,
MobileRoutingService routing) : MobileApiController(dbContext, logger, userAccessor)
{
/// <summary>Returns no-contact capability for one stable Wayfarer transport profile identity.</summary>
[HttpGet("capability/{transportProfileId:guid}")]
public async Task<IActionResult> Capability(Guid transportProfileId, CancellationToken cancellationToken)
{
var (user, error) = await EnsureAuthenticatedUserAsync(cancellationToken);
return error ?? Ok(await routing.CapabilityAsync(user!.Id, transportProfileId, cancellationToken));
}

/// <summary>Generates one bounded provider-neutral route without mutating server domain state.</summary>
[HttpPost("route")]
public async Task<IActionResult> 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));
}
}

/// <summary>Contains only server-resolved mobile route inputs.</summary>
public sealed class MobileRouteRequest
{
public Guid TransportProfileId { get; set; }
public required MobileRouteCoordinate Origin { get; set; }
public required MobileRouteCoordinate Destination { get; set; }
public IReadOnlyList<MobileRouteCoordinate> Anchors { get; set; } = [];
[JsonExtensionData] public Dictionary<string, JsonElement>? AdditionalFields { get; set; }
}

/// <summary>Contains one WGS84 coordinate with no provider semantics.</summary>
public sealed record MobileRouteCoordinate(double Longitude, double Latitude);

/// <summary>Contains bounded provider-neutral route output and no secret/admin endpoint fields.</summary>
public sealed record MobileRouteResponse(bool Succeeded, string Outcome, IReadOnlyList<RouteCoordinate>? Geometry,
double? DistanceMetres, double? DurationSeconds, IReadOnlyList<RouteInstruction>? Instructions,
DateTimeOffset? GeneratedAt, string? Provider, Guid? ProviderConfigurationId, string? MappingIdentity,
Guid? TransportProfileId, IReadOnlyList<RouteCoordinate>? MatchPoints,
IReadOnlyList<MobileRouteAttribution>? 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));
}
37 changes: 35 additions & 2 deletions Areas/User/Controllers/LocationProviderSettingsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>Displays masked provider authority and provider-native usage status.</summary>
public async Task<IActionResult> Index(CancellationToken cancellationToken)
Expand Down Expand Up @@ -52,7 +54,11 @@ public async Task<IActionResult> 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));
Expand Down Expand Up @@ -85,6 +91,33 @@ public async Task<IActionResult> VerifyMapboxPermanent(CancellationToken cancell
return RedirectToAction(nameof(Index));
}

/// <summary>Runs one explicit Geoapify capability verification without changing selection.</summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}

/// <summary>Runs one explicit bounded Location backfill for the authenticated owner.</summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> 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));
}

/// <summary>Explicitly revokes one credential without deleting profiles, usage, or domain data.</summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Revoke(string providerKey, bool confirmed, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
14 changes: 14 additions & 0 deletions Areas/User/Views/LocationProviderSettings/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,20 @@
<div class="form-check"><input name="ActiveForRouting" value="true" type="checkbox" class="form-check-input" checked="@(Model.ActiveRoutingProvider == profile.ProviderKey)" /><label class="form-check-label">Active for routing</label></div>
<button class="btn btn-primary mt-2" type="submit">Save profile and selection</button>
</form>
<div class="mt-3" aria-label="Geoapify capability verification">
<form asp-action="VerifyGeoapify" method="post" class="d-inline">
<input name="capability" value="Geocoding" type="hidden" />
<button class="btn btn-outline-primary" type="submit">Verify Geoapify geocoding</button>
</form>
<form asp-action="VerifyGeoapify" method="post" class="d-inline">
<input name="capability" value="Routing" type="hidden" />
<button class="btn btn-outline-primary" type="submit">Verify Geoapify routing</button>
</form>
</div>
<p class="mt-3">Backfill processes at most 100 of your wholly unenriched Locations in chronological order. Existing, manual, and imported enrichment is preserved.</p>
<form asp-action="BackfillGeoapify" method="post">
<button class="btn btn-outline-primary" type="submit">Backfill up to 100 Locations</button>
</form>
}
@if (profile.ProviderKey == "mapbox")
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.';
Expand All @@ -111,7 +114,11 @@ onUnmounted(() => {
<h3 id="external-route-heading" class="fs-6">External routed path</h3>
<p class="small mb-1"><strong>{{ capability.providerDisplayName }}</strong> · {{ capability.mappedProfileLabel }}</p>
<p class="small mb-1">{{ capability.disclosure }}</p>
<p v-if="capability.attribution" class="small text-muted mb-2">{{ capability.attribution }}</p>
<p v-if="capability.attribution?.includes('Powered by Geoapify')" class="small text-muted mb-2">
<a href="https://www.geoapify.com/" rel="follow">Powered by Geoapify</a> ·
<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap contributors</a>
</p>
<p v-else-if="capability.attribution" class="small text-muted mb-2">{{ capability.attribution }}</p>
<p v-if="profileChanged" class="small text-warning">Save the transport-profile change before generating a new proposal.</p>
<button v-if="!state.proposal" type="button" class="btn btn-outline-info btn-sm" :disabled="state.generating || profileChanged" @click="generate">
{{ state.generating ? 'Generating…' : actionLabel }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const applyAcceptedRouteProposal = (
proposal: AcceptedExternalRouteProposal
): boolean => {
if (proposal.segmentId !== draft.id) return false;
if (proposal.aggregateConcurrencyToken) draft.aggregateConcurrencyToken = proposal.aggregateConcurrencyToken;
draft.route = { type: 'LineString', coordinates: proposal.geometry.map(item => [item.longitude, item.latitude]) };
draft.waypointRows.forEach((row, index) => { row.routeVertexIndex = proposal.waypointIndices[index + 1] ?? null; });
return true;
Expand Down
1 change: 1 addition & 0 deletions ClientApps/trip-editor/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export interface AcceptedExternalRouteProposal {
segmentId: Guid;
geometry: Array<{ longitude: number; latitude: number }>;
waypointIndices: number[];
aggregateConcurrencyToken?: string | null;
}

export interface EditorPlace {
Expand Down
Loading
Loading