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:
@@ -0,0 +1,12 @@
|
||||
import { geocodeSite } from "@/server/services/geo/geocode-site";
|
||||
import type { JobPayload } from "../queues";
|
||||
|
||||
/**
|
||||
* Queue "geocode-site" (L13 Planung): address → coordinates for one site, cached on the row.
|
||||
* The worker runs this queue with concurrency 1 + BullMQ limiter 1/s; the Nominatim provider
|
||||
* additionally throttles process-wide. `failed` is rethrown so BullMQ retries with backoff.
|
||||
*/
|
||||
export async function process(payload: JobPayload): Promise<void> {
|
||||
const outcome = await geocodeSite(payload.tenantId, payload.entityId);
|
||||
if (outcome === "failed") throw new Error("geocoding failed (retry scheduled)");
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
@@ -8,6 +8,7 @@ import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imp
|
||||
import { createWorkOrder } from "@/server/services/work-orders/create";
|
||||
import { startExtraction, type Dispatch } from "./upload";
|
||||
import { dispatchJob } from "@/server/jobs/dispatch";
|
||||
import { requestSiteGeocoding } from "@/server/services/geo/dispatch";
|
||||
|
||||
export type ConfirmResult = { workOrderId: string; workOrderNumber: string; customerId: string; siteId: string | null; contactId: string | null };
|
||||
|
||||
@@ -168,6 +169,7 @@ export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm:
|
||||
if (result.createdCustomer) await writeAuditLog({ ...base, action: "create", entity: "customer", entityId: result.customerId, after: { source: "import", importId: job.id } });
|
||||
if (result.contactId) await writeAuditLog({ ...base, action: "create", entity: "contact", entityId: result.contactId, after: { customerId: result.customerId, source: "import" } });
|
||||
if (result.createdSite && result.siteId) await writeAuditLog({ ...base, action: "create", entity: "site", entityId: result.siteId, after: { customerId: result.customerId, source: "import" } });
|
||||
if (result.createdSite && result.siteId) void requestSiteGeocoding(ctx.tenantId, result.siteId); // L13 Planung: queued geocoding of the new site
|
||||
// work order creation is audited by services/work-orders/create (entity work_order)
|
||||
await writeAuditLog({
|
||||
...base,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { writeAuditLog } from "@/server/audit";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { siteScope } from "@/server/services/work-orders/visibility";
|
||||
import { optStr } from "@/server/services/customers/schemas";
|
||||
import { requestSiteGeocoding } from "@/server/services/geo/dispatch";
|
||||
import { addressChanged } from "@/server/services/geo/normalize";
|
||||
|
||||
export const SITE_STATUSES = ["active", "inactive", "provisional"] as const;
|
||||
|
||||
@@ -121,6 +123,7 @@ export async function createSite(ctx: ServiceCtx, input: SiteCreateInput) {
|
||||
await assertCustomerAndContact(ctx, data.customerId, data.contactId);
|
||||
const site = await ctx.db.site.create({ data: { ...data, tenantId: ctx.tenantId, country: data.country ?? "DE" } });
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "site", entityId: site.id, after: site });
|
||||
void requestSiteGeocoding(ctx.tenantId, site.id); // L13 Planung: queued, never inline
|
||||
return site;
|
||||
}
|
||||
|
||||
@@ -134,6 +137,7 @@ export async function updateSite(ctx: ServiceCtx, id: string, patch: SitePatchIn
|
||||
await assertCustomerAndContact(ctx, customerId, contactId);
|
||||
const after = await ctx.db.site.update({ where: { id }, data: { ...data, contactId } });
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "site", entityId: id, before, after });
|
||||
if (addressChanged(before, after)) void requestSiteGeocoding(ctx.tenantId, id); // L13 Planung
|
||||
return after;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user