From c9a2f8622fbac9cfab4aa8dd54532cfeeb6f16dd Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 24 Apr 2026 15:00:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(market):=20reverse=20geocoding=20=E2=80=94?= =?UTF-8?q?=20lat/lng=20to=20address?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements the existing forward geocoder with Nominatim's /reverse endpoint so the admin edit form can populate the address from coordinates (useful when a crawl gave us lat/lng but no street, e.g. after running crawl-enrich). Backend: - geocode.Reverse(ctx, lat, lng) hits Nominatim /reverse with addressdetails=1 and accept-language=de, reuses the 1 rps mutex already guarding forward calls. Falls through city → town → village → municipality → hamlet for small places. Returns nil when Nominatim has no match so callers can distinguish "no hit" from "all-empty address." - New DTOs ReverseGeocodeRequest/Response. - GeocodeHandler.ReverseGeocode wired at POST /reverse-geocode behind the same geocodeLimit middleware as /geocode. Frontend: - /api/reverse-geocode SvelteKit proxy mirrors /api/geocode. - MarketForm gets a second button next to "Koordinaten aus Adresse ermitteln" — "Adresse aus Koordinaten ermitteln". Writes non-empty street/city/zip back into the form; empty result surfaces "Keine Adresse gefunden." --- backend/internal/domain/market/dto.go | 17 +++ .../internal/domain/market/geocode_handler.go | 32 ++++++ backend/internal/domain/market/routes.go | 1 + backend/internal/pkg/geocode/nominatim.go | 101 ++++++++++++++++++ .../lib/components/admin/MarketForm.svelte | 76 ++++++++++++- web/src/routes/api/reverse-geocode/+server.ts | 27 +++++ 6 files changed, 253 insertions(+), 1 deletion(-) create mode 100644 web/src/routes/api/reverse-geocode/+server.ts diff --git a/backend/internal/domain/market/dto.go b/backend/internal/domain/market/dto.go index 4a444d8..4c31269 100644 --- a/backend/internal/domain/market/dto.go +++ b/backend/internal/domain/market/dto.go @@ -409,6 +409,23 @@ type GeocodeResult struct { Longitude *float64 `json:"longitude"` } +type ReverseGeocodeRequest struct { + Latitude float64 `json:"latitude" validate:"required,min=-90,max=90"` + Longitude float64 `json:"longitude" validate:"required,min=-180,max=180"` +} + +type ReverseGeocodeResult struct { + Street string `json:"street"` + City string `json:"city"` + Zip string `json:"zip"` + State string `json:"state"` + Country string `json:"country"` +} + +type ReverseGeocodeResponse struct { + Data ReverseGeocodeResult `json:"data"` +} + type ResearchResult struct { Suggestions []FieldSuggestion `json:"suggestions"` Sources []string `json:"sources"` diff --git a/backend/internal/domain/market/geocode_handler.go b/backend/internal/domain/market/geocode_handler.go index 766379b..951e51b 100644 --- a/backend/internal/domain/market/geocode_handler.go +++ b/backend/internal/domain/market/geocode_handler.go @@ -39,3 +39,35 @@ func (h *GeocodeHandler) Geocode(c *gin.Context) { }, }) } + +// ReverseGeocode resolves a lat/lng pair into an address. Returns an empty +// result object (all fields "") when Nominatim has no match — callers +// render a "nothing found" message. +func (h *GeocodeHandler) ReverseGeocode(c *gin.Context) { + var req ReverseGeocodeRequest + if apiErr := validate.BindJSON(c, &req); apiErr != nil { + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + + result, err := h.geocoder.Reverse(c.Request.Context(), req.Latitude, req.Longitude) + if err != nil { + apiErr := apierror.Internal("reverse geocoding failed") + c.JSON(apiErr.Status, apierror.NewResponse(apiErr)) + return + } + if result == nil { + c.JSON(http.StatusOK, ReverseGeocodeResponse{Data: ReverseGeocodeResult{}}) + return + } + + c.JSON(http.StatusOK, ReverseGeocodeResponse{ + Data: ReverseGeocodeResult{ + Street: result.Street, + City: result.City, + Zip: result.Zip, + State: result.State, + Country: result.Country, + }, + }) +} diff --git a/backend/internal/domain/market/routes.go b/backend/internal/domain/market/routes.go index 1c8ed64..5a8f8bb 100644 --- a/backend/internal/domain/market/routes.go +++ b/backend/internal/domain/market/routes.go @@ -11,6 +11,7 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, subH *SubmissionHandler, ge } rg.POST("/geocode", geocodeLimit, geoH.Geocode) + rg.POST("/reverse-geocode", geocodeLimit, geoH.ReverseGeocode) } func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, rh *ResearchHandler, requireAuth, requireAdmin gin.HandlerFunc) { diff --git a/backend/internal/pkg/geocode/nominatim.go b/backend/internal/pkg/geocode/nominatim.go index 479a09e..90fb97c 100644 --- a/backend/internal/pkg/geocode/nominatim.go +++ b/backend/internal/pkg/geocode/nominatim.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "strings" "sync" "time" ) @@ -27,6 +28,31 @@ type nominatimResult struct { Lon string `json:"lon"` } +// ReverseResult is the subset of Nominatim's address breakdown we expose. +// All fields may be empty; callers decide what to do with partial data. +type ReverseResult struct { + Street string + City string + Zip string + State string + Country string +} + +type nominatimReverseResult struct { + Address struct { + HouseNumber string `json:"house_number"` + Road string `json:"road"` + Postcode string `json:"postcode"` + City string `json:"city"` + Town string `json:"town"` + Village string `json:"village"` + Municipality string `json:"municipality"` + Hamlet string `json:"hamlet"` + State string `json:"state"` + Country string `json:"country"` + } `json:"address"` +} + func (g *Geocoder) Geocode(ctx context.Context, street, city, zip, country string) (*float64, *float64, error) { if city == "" && zip == "" { return nil, nil, fmt.Errorf("city or zip is required for geocoding") @@ -91,3 +117,78 @@ func (g *Geocoder) Geocode(ctx context.Context, street, city, zip, country strin return &lat, &lon, nil } + +// Reverse looks up an address for a lat/lng pair. Returns (nil, nil) when +// Nominatim has no match (e.g. coordinates in the middle of a lake). Obeys +// the same 1 rps rate limit as forward geocoding via the shared mutex. +func (g *Geocoder) Reverse(ctx context.Context, lat, lng float64) (*ReverseResult, error) { + g.mu.Lock() + if since := time.Since(g.lastReq); since < time.Second { + time.Sleep(time.Second - since) + } + g.lastReq = time.Now() + g.mu.Unlock() + + params := url.Values{} + params.Set("format", "json") + params.Set("lat", fmt.Sprintf("%f", lat)) + params.Set("lon", fmt.Sprintf("%f", lng)) + params.Set("addressdetails", "1") + params.Set("accept-language", "de") + + reqURL := "https://nominatim.openstreetmap.org/reverse?" + params.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("User-Agent", "Marktvogt/1.0") + + resp, err := g.client.Do(req) + if err != nil { + return nil, fmt.Errorf("nominatim request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("nominatim returned status %d", resp.StatusCode) + } + + var body nominatimReverseResult + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + addr := body.Address + + // Small municipalities may lack "city"; fall through the narrower tags + // until we find something. + city := firstNonEmpty(addr.City, addr.Town, addr.Village, addr.Municipality, addr.Hamlet) + + // Street = road + optional house number (German convention: "Straße 12"). + street := addr.Road + if addr.Road != "" && addr.HouseNumber != "" { + street = addr.Road + " " + addr.HouseNumber + } + + // All-empty response means "no match" — return nil so caller can show a + // friendly "nothing found" message instead of an all-blank address. + if street == "" && city == "" && addr.Postcode == "" && addr.State == "" && addr.Country == "" { + return nil, nil + } + + return &ReverseResult{ + Street: strings.TrimSpace(street), + City: city, + Zip: addr.Postcode, + State: addr.State, + Country: addr.Country, + }, nil +} + +func firstNonEmpty(candidates ...string) string { + for _, c := range candidates { + if c != "" { + return c + } + } + return "" +} diff --git a/web/src/lib/components/admin/MarketForm.svelte b/web/src/lib/components/admin/MarketForm.svelte index 41cf7d9..1cacc8d 100644 --- a/web/src/lib/components/admin/MarketForm.svelte +++ b/web/src/lib/components/admin/MarketForm.svelte @@ -62,6 +62,8 @@ let geocoding = $state(false); let geocodeError = $state(''); + let reverseGeocoding = $state(false); + let reverseGeocodeError = $state(''); function addHoursRow() { hours = [...hours, { day: 'Samstag', open: '10:00', close: '18:00' }]; @@ -121,6 +123,66 @@ } } + async function reverseGeocode() { + reverseGeocoding = true; + reverseGeocodeError = ''; + + const latRaw = document.querySelector('[name="latitude"]')?.value ?? ''; + const lonRaw = document.querySelector('[name="longitude"]')?.value ?? ''; + const lat = parseFloat(latRaw); + const lon = parseFloat(lonRaw); + + if (!Number.isFinite(lat) || !Number.isFinite(lon)) { + reverseGeocodeError = 'Koordinaten fehlen oder sind ungültig.'; + reverseGeocoding = false; + return; + } + + try { + const res = await fetch('/api/reverse-geocode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ latitude: lat, longitude: lon }) + }); + const data = await res.json(); + + if (!res.ok) { + reverseGeocodeError = data.error?.message ?? 'Adress-Lookup fehlgeschlagen.'; + return; + } + + const fieldsWritten = writeReverseResult(data); + if (fieldsWritten === 0) { + reverseGeocodeError = 'Keine Adresse gefunden.'; + } + } catch { + reverseGeocodeError = 'Adress-Lookup fehlgeschlagen.'; + } finally { + reverseGeocoding = false; + } + } + + // Writes non-empty reverse-geocode fields into the form. Returns the + // count of fields written so the caller can detect an all-empty response. + function writeReverseResult(data: Record): number { + const writes: Array<[string, string]> = [ + ['street', data.street], + ['city', data.city], + ['zip', data.zip] + ]; + let count = 0; + for (const [name, value] of writes) { + if (!value) continue; + const el = document.querySelector(`[name="${name}"]`); + if (el) { + el.value = value; + el.dispatchEvent(new Event('input', { bubbles: true })); + count++; + } + } + return count; + } + export function setHours(newHours: OpeningHoursEntry[]) { hours = newHours; } @@ -287,7 +349,7 @@ /> -
+
+ {#if reverseGeocodeError} + {reverseGeocodeError} + {/if}
diff --git a/web/src/routes/api/reverse-geocode/+server.ts b/web/src/routes/api/reverse-geocode/+server.ts new file mode 100644 index 0000000..c05ce4f --- /dev/null +++ b/web/src/routes/api/reverse-geocode/+server.ts @@ -0,0 +1,27 @@ +import { json } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import type { RequestHandler } from './$types.js'; + +type ReverseResult = { + street: string; + city: string; + zip: string; + state: string; + country: string; +}; + +export const POST: RequestHandler = async ({ request, fetch }) => { + const body = await request.json(); + + try { + const res = await apiFetch('/reverse-geocode', { + method: 'POST', + body: JSON.stringify(body), + fetch + }); + return json(res.data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Reverse geocoding failed'; + return json({ error: { message } }, { status: 500 }); + } +};