feat(mileage): suggest driving distance from the from/to addresses (#1778)

* feat(mileage): suggest driving distance from the from/to addresses

When both endpoints are typed in the trip form (create mode), a debounced
lookup geocodes them via Nominatim and fetches the driving distance via
OSRM, both proxied through /api/mileage/distance so addresses leave only
our server, without user identifiers. The suggestion renders as a
click-to-apply hint under the distance field, never auto-fills, and stays
fully editable. Tooltip shows what the geocoder matched.

In-instance caching (24h hits, 10min misses) plus 1.1s politeness spacing
keep usage inside the OSM public-endpoint policies. OSMF is disclosed as a
data recipient on the privacy page.

Requested by a beta user: first-time routes had to be measured by hand;
route memory (PR #1657) only helps from the second trip onward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): resolve skeptic findings on the distance suggestion

Compliance: routing switched from router.project-osrm.org (demo server,
non-commercial use only) to FOSSGIS's routing.openstreetmap.de; the lookup
is now click-triggered ("Foresla stracka") instead of as-you-type, per
Nominatim's no-autocomplete policy; visible OpenStreetMap attribution next
to the applied suggestion; privacy page reworked to name OSMF and FOSSGIS
e.V. as independent recipients outside the sub-processor table, with an
honest note that typed addresses can themselves be personal data.

Correctness: suggestion-cache key separator changed from '|' (collidable
by address text) to newline; routes rounding to 0.0 km are no longer
suggested (the form rejects 0); the Nominatim politeness queue is bounded
at 3s wait and bails to null instead of holding request handlers open.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mileage): resolve CodeRabbit findings on the distance suggestion

A generation counter invalidates in-flight lookups when the route or km
field changes or the dialog closes, so a slow response can never write an
old route's distance into a changed form. Privacy page now states each
recipient's actual payload (Nominatim gets address texts, FOSSGIS only
coordinates), discloses the 24h in-memory server cache, and carries
today's revision date.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-21 12:19:35 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 60920ec794
commit 8249fcab5e
10 changed files with 596 additions and 6 deletions
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NextResponse } from 'next/server'
vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() }))
vi.mock('@/lib/auth/require-auth', () => ({ requireAuth: vi.fn() }))
vi.mock('@/lib/company/context', () => ({
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/sandbox/guard', () => ({ guardSandbox: vi.fn().mockResolvedValue(null) }))
vi.mock('@/lib/mileage/distance', () => ({ fetchDistanceSuggestion: vi.fn() }))
import { GET } from '../route'
import { requireAuth } from '@/lib/auth/require-auth'
import { fetchDistanceSuggestion } from '@/lib/mileage/distance'
const params = { params: Promise.resolve({}) } as never
function authed() {
vi.mocked(requireAuth).mockResolvedValue({
user: { id: 'user-1' } as never,
supabase: {} as never,
error: null,
} as never)
}
function unauthed() {
vi.mocked(requireAuth).mockResolvedValue({
user: null,
supabase: null,
error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }),
} as never)
}
function req(query: string) {
return new Request(`https://x.test/api/mileage/distance${query}`)
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('GET /api/mileage/distance', () => {
it('returns 401 when unauthenticated', async () => {
unauthed()
const res = await GET(req('?from=A%20stad&to=B%20stad'), params)
expect(res.status).toBe(401)
expect(fetchDistanceSuggestion).not.toHaveBeenCalled()
})
it('returns 400 when from/to are missing or too short', async () => {
authed()
expect((await GET(req('?from=Storgatan'), params)).status).toBe(400)
expect((await GET(req('?from=A&to=B'), params)).status).toBe(400)
expect(fetchDistanceSuggestion).not.toHaveBeenCalled()
})
it('returns the suggestion on a resolvable route', async () => {
authed()
vi.mocked(fetchDistanceSuggestion).mockResolvedValue({
distance_km: 47.3,
from_label: 'Växjö, Sverige',
to_label: 'Alvesta, Sverige',
})
const res = await GET(req('?from=V%C3%A4xj%C3%B6&to=Alvesta'), params)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.distance_km).toBe(47.3)
expect(vi.mocked(fetchDistanceSuggestion)).toHaveBeenCalledWith('Växjö', 'Alvesta')
})
it('returns data: null when no suggestion could be resolved', async () => {
authed()
vi.mocked(fetchDistanceSuggestion).mockResolvedValue(null)
const res = await GET(req('?from=hemma&to=jobbet'), params)
expect(res.status).toBe(200)
expect((await res.json()).data).toBeNull()
})
})
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { validateQuery } from '@/lib/api/validate'
import { MileageDistanceQuerySchema } from '@/lib/api/schemas'
import { fetchDistanceSuggestion } from '@/lib/mileage/distance'
import { guardSandbox } from '@/lib/sandbox/guard'
ensureInitialized()
// Proxies the OSM lookups server-side: the user's addresses never reach
// Nominatim/OSRM from their own browser, and the shared politeness caches
// live here. The sandbox guard keeps demo traffic off the public OSM
// budget, mirroring /api/currency/rate.
export const GET = withRouteContext('mileage.distance', async (request, { supabase, companyId }) => {
const params = validateQuery(request, MileageDistanceQuerySchema)
if (!params.success) return params.response
const blocked = await guardSandbox(supabase, companyId)
if (blocked) return blocked
// null simply means "no suggestion": the form shows nothing.
const suggestion = await fetchDistanceSuggestion(params.data.from, params.data.to)
return NextResponse.json({ data: suggestion })
})