L13 Planung: Geocoding der Objekte über OpenStreetMap Nominatim

Provider-Interface (nominatim|none), strukturierte Suche mit User-Agent und Accept-Language, Drosselung 1/s je Prozess, Job geocode-site (Worker: Concurrency 1 + Limiter), Cache am Objekt, manuelle Koordinaten bleiben, Auslöser Anlage/Adressänderung/Import-Bestätigung nur per Queue, Backfill-Skript, Env-Beispiele. Tests mit Fake-Provider, Nominatim nie aufgerufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:13:26 +02:00
co-authored by Claude Opus 5
parent 604f2bfdc5
commit 213bcca3a1
14 changed files with 600 additions and 2 deletions
+42
View File
@@ -0,0 +1,42 @@
import packageJson from "../../../../package.json";
/**
* Geocoding + map configuration (L13 Planung). Defaults follow the OpenStreetMap usage policies:
* Nominatim only server side, max. 1 request/s, identifying User-Agent, results cached per site.
* For production with more than a handful of addresses a self-hosted / contracted geocoding and
* tile service is recommended (see docs/craftvia/lanes/planung.md).
*/
export const GEOCODING_PROVIDERS = ["nominatim", "none"] as const;
export type GeocodingProviderName = (typeof GEOCODING_PROVIDERS)[number];
export const DEFAULT_NOMINATIM_URL = "https://nominatim.openstreetmap.org";
export const DEFAULT_MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
export const DEFAULT_MAP_ATTRIBUTION = "© OpenStreetMap-Mitwirkende";
type Env = Record<string, string | undefined>;
const val = (v: string | undefined) => (v && v.trim() ? v.trim() : undefined);
export function geocodingProviderName(env: Env = process.env): GeocodingProviderName {
const v = val(env.GEOCODING_PROVIDER)?.toLowerCase();
return v === "none" ? "none" : "nominatim";
}
export function geocodingConfig(env: Env = process.env) {
const baseUrl = val(env.APP_BASE_URL) ?? val(env.AUTH_URL) ?? "http://localhost:3000";
return {
provider: geocodingProviderName(env),
url: (val(env.GEOCODING_URL) ?? DEFAULT_NOMINATIM_URL).replace(/\/+$/, ""),
userAgent: val(env.GEOCODING_USER_AGENT) ?? `Craftvia/${packageJson.version} (+${baseUrl})`,
timeoutMs: 8_000,
minIntervalMs: 1_000,
};
}
/** Tile URL + attribution for the live map (passed from server pages to the client). */
export function mapConfig(env: Env = process.env) {
return {
tileUrl: val(env.MAP_TILE_URL) ?? DEFAULT_MAP_TILE_URL,
attribution: val(env.MAP_ATTRIBUTION) ?? DEFAULT_MAP_ATTRIBUTION,
};
}
+24
View File
@@ -0,0 +1,24 @@
import { enqueueJob } from "@/server/jobs/queues";
import { geocodingProviderName } from "@/server/services/geo/config";
/**
* Ask the worker to geocode a site (after create / address change / import confirmation).
* Never runs inline: no external request inside a user request (OSM policy: no bulk geocoding in
* requests). If the queue is not reachable yet (lazy Redis connection) one delayed retry is made;
* otherwise the site stays without coordinates ("ohne Ortsangabe") until the next change or
* `scripts/geocode-backfill.ts`. Never throws.
*/
export async function requestSiteGeocoding(tenantId: string, siteId: string): Promise<boolean> {
if (geocodingProviderName() === "none") return false;
const payload = { tenantId, entityId: siteId };
try {
if (await enqueueJob("geocode-site", payload)) return true;
} catch (err) {
console.error("[geo] enqueue geocode-site failed:", (err as Error).message);
}
const timer = setTimeout(() => {
enqueueJob("geocode-site", payload).catch(() => undefined);
}, 1_500);
timer.unref?.();
return false;
}
+92
View File
@@ -0,0 +1,92 @@
import { isValidLatLng } from "@/lib/geo/distance";
import { writeAuditLog } from "@/server/audit";
import { dbForTenant } from "@/server/db";
import { geocodeQueryFor } from "@/server/services/geo/normalize";
import { getGeocodingProvider, type GeocodingProvider } from "@/server/services/geo/provider";
/**
* Geocode one site and cache the result on the row (job `geocode-site`, backfill script).
*
* Rules:
* - Manually entered coordinates (coordinates without `geocodedAt`) are never overwritten.
* - Unchanged address (`geocodeQuery`) with status ok/not_found → no provider call ("cached").
* - No usable address or provider `none` → status `skipped` (retried once enabled/complete).
* - Transport/HTTP error → status `failed` (the job rethrows so BullMQ retries).
* - Address changed and not found → previous automatic coordinates are removed (they would be wrong).
*/
export type GeocodeOutcome = "ok" | "not_found" | "failed" | "skipped" | "cached" | "manual" | "missing";
const SITE_SELECT = {
id: true,
street: true,
houseNumber: true,
postalCode: true,
city: true,
country: true,
latitude: true,
longitude: true,
geocodedAt: true,
geocodeStatus: true,
geocodeQuery: true,
} as const;
export async function geocodeSite(
tenantId: string,
siteId: string,
opts: { provider?: GeocodingProvider | null; now?: Date } = {},
): Promise<GeocodeOutcome> {
const db = dbForTenant(tenantId);
const site = await db.site.findFirst({ where: { id: siteId, deletedAt: null }, select: SITE_SELECT });
if (!site) return "missing";
const now = opts.now ?? new Date();
const query = geocodeQueryFor(site);
if (isValidLatLng(site) && !site.geocodedAt) {
if (site.geocodeStatus !== "skipped" || site.geocodeQuery !== query) {
await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "skipped", geocodeQuery: query } });
}
return "manual";
}
if (!query) {
const clearAuto = site.geocodedAt !== null && (site.latitude !== null || site.longitude !== null);
await db.site.update({
where: { id: site.id },
data: { geocodeStatus: "skipped", geocodeQuery: null, ...(clearAuto ? { latitude: null, longitude: null } : {}) },
});
return "skipped";
}
if (site.geocodeQuery === query && (site.geocodeStatus === "ok" || site.geocodeStatus === "not_found")) return "cached";
const provider = opts.provider === undefined ? await getGeocodingProvider() : opts.provider;
if (!provider) {
if (site.geocodeStatus !== "skipped") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "skipped" } });
return "skipped";
}
let result;
try {
result = await provider.geocode({ street: site.street, houseNumber: site.houseNumber, postalCode: site.postalCode, city: site.city, country: site.country });
} catch (err) {
console.error(`[geo] geocoding site ${site.id} failed:`, (err as Error).message);
await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "failed", geocodeQuery: query, geocodedAt: now } });
return "failed";
}
const before = { latitude: site.latitude, longitude: site.longitude, geocodeStatus: site.geocodeStatus };
const coords = result.status === "ok" ? { latitude: result.latitude, longitude: result.longitude } : { latitude: null, longitude: null };
await db.site.update({ where: { id: site.id }, data: { ...coords, geocodeStatus: result.status, geocodeQuery: query, geocodedAt: now } });
if (before.latitude !== coords.latitude || before.longitude !== coords.longitude) {
await writeAuditLog({
tenantId,
action: "update",
entity: "site",
entityId: site.id,
before,
after: { ...coords, geocodeStatus: result.status, source: "geocoding", provider: provider.name },
});
}
return result.status;
}
+58
View File
@@ -0,0 +1,58 @@
import { GeocodingError, type GeocodeAddress, type GeocodeResult, type GeocodingProvider } from "@/server/services/geo/provider";
import { nominatimLimiter } from "@/server/services/geo/rate-limit";
/**
* Nominatim (OpenStreetMap) provider. Usage policy: server side only, max. 1 request/s process-wide,
* identifying User-Agent, results are cached in Site.latitude/longitude by the caller, no bulk
* geocoding inside requests (only the geocode-site job and scripts/geocode-backfill.ts call this).
*/
type Config = { url: string; userAgent: string; timeoutMs: number };
type Deps = { fetch: typeof fetch; limiter: { acquire(): Promise<number> } };
/** Structured search URL (no free text → fewer ambiguous hits). */
export function buildNominatimUrl(baseUrl: string, address: GeocodeAddress): URL {
const url = new URL(`${baseUrl.replace(/\/+$/, "")}/search`);
url.searchParams.set("format", "jsonv2");
url.searchParams.set("limit", "1");
url.searchParams.set("addressdetails", "0");
const street = [address.houseNumber, address.street].map((s) => s?.trim()).filter(Boolean).join(" ");
if (street) url.searchParams.set("street", street);
if (address.postalCode?.trim()) url.searchParams.set("postalcode", address.postalCode.trim());
if (address.city?.trim()) url.searchParams.set("city", address.city.trim());
const country = (address.country ?? "DE").trim().toLowerCase();
if (/^[a-z]{2}$/.test(country)) url.searchParams.set("countrycodes", country);
return url;
}
export function createNominatimProvider(config: Config, deps: Deps = { fetch: globalThis.fetch, limiter: nominatimLimiter }): GeocodingProvider {
return {
name: "nominatim",
async geocode(address: GeocodeAddress): Promise<GeocodeResult> {
await deps.limiter.acquire();
let res: Response;
try {
res = await deps.fetch(buildNominatimUrl(config.url, address), {
headers: { "User-Agent": config.userAgent, "Accept-Language": "de", Accept: "application/json" },
signal: AbortSignal.timeout(config.timeoutMs),
});
} catch (err) {
throw new GeocodingError(`request failed: ${(err as Error).name}`);
}
if (!res.ok) throw new GeocodingError(`http ${res.status}`);
let body: unknown;
try {
body = await res.json();
} catch {
throw new GeocodingError("invalid response");
}
const hit = Array.isArray(body) ? (body[0] as { lat?: unknown; lon?: unknown } | undefined) : undefined;
const latitude = Number(hit?.lat);
const longitude = Number(hit?.lon);
if (!hit || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
return { status: "not_found" };
}
return { status: "ok", latitude, longitude };
},
};
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Normalized address string of a site ("hafenstraße 12|20457|hamburg|de"). Stored in
* Site.geocodeQuery next to the coordinates: an unchanged address is never geocoded twice.
* Returns null when the address is too incomplete to geocode (neither postal code nor city).
*/
export type SiteAddress = {
street?: string | null;
houseNumber?: string | null;
postalCode?: string | null;
city?: string | null;
country?: string | null;
};
const clean = (v: string | null | undefined) => (v ?? "").normalize("NFC").trim().replace(/\s+/g, " ").toLowerCase();
export function geocodeQueryFor(site: SiteAddress): string | null {
const postal = clean(site.postalCode);
const city = clean(site.city);
if (!postal && !city) return null;
const street = [clean(site.street), clean(site.houseNumber)].filter(Boolean).join(" ");
return [street, postal, city, clean(site.country) || "de"].join("|");
}
/** True when the geocoding-relevant part of the address changed. */
export function addressChanged(before: SiteAddress, after: SiteAddress): boolean {
return geocodeQueryFor(before) !== geocodeQueryFor(after);
}
+41
View File
@@ -0,0 +1,41 @@
import { geocodingConfig, geocodingProviderName } from "@/server/services/geo/config";
/** Provider-neutral geocoding contract (L13 Planung) — swap Nominatim for another service via env. */
export type GeocodeAddress = {
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
country: string | null;
};
export type GeocodeResult = { status: "ok"; latitude: number; longitude: number } | { status: "not_found" };
export interface GeocodingProvider {
readonly name: string;
/** Resolves ok/not_found; throws GeocodingError for transport/HTTP failures (retryable). */
geocode(address: GeocodeAddress): Promise<GeocodeResult>;
}
export class GeocodingError extends Error {
constructor(message: string) {
super(message);
this.name = "GeocodingError";
}
}
let override: GeocodingProvider | null | undefined;
/** Tests inject a fake provider (never Nominatim); `undefined` restores the env-based provider. */
export function setGeocodingProvider(provider: GeocodingProvider | null | undefined): void {
override = provider;
}
/** Configured provider, or null when geocoding is disabled (`GEOCODING_PROVIDER=none`). */
export async function getGeocodingProvider(): Promise<GeocodingProvider | null> {
if (override !== undefined) return override;
if (geocodingProviderName() === "none") return null;
const { createNominatimProvider } = await import("@/server/services/geo/nominatim");
return createNominatimProvider(geocodingConfig());
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Minimum-interval limiter: callers are released one after another with at least `intervalMs`
* between two slots (Nominatim policy: max. 1 request per second). Clock is injectable for tests.
*/
export type Clock = { now: () => number; sleep: (ms: number) => Promise<void> };
const realClock: Clock = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) };
export function createIntervalLimiter(intervalMs: number, clock: Clock = realClock) {
let next = 0;
return {
/** Waits for the next free slot and returns its timestamp. */
async acquire(): Promise<number> {
const now = clock.now();
const slot = Math.max(now, next);
next = slot + intervalMs;
if (slot > now) await clock.sleep(slot - now);
return slot;
},
};
}
/** Process-wide limiter for all Nominatim requests of this process (worker, backfill script). */
export const nominatimLimiter = createIntervalLimiter(1_000);