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
+12
View File
@@ -129,6 +129,18 @@ AI_GENERATION_RETENTION_DAYS=180
# Import-Extraktion ab. 0 = unbegrenzt.
AI_MONTHLY_TOKEN_LIMIT=0
# --- Craftvia: Planung – Karten & Geocoding (L13) ---
# Adresse → Koordinaten nur serverseitig im Worker (Job geocode-site, max. 1 Anfrage/s),
# Ergebnis wird am Objekt gespeichert. nominatim | none (none = keine Verortung, Objekte
# erscheinen „ohne Ortsangabe“). Für Produktion eigenen/vertraglichen Dienst verwenden.
GEOCODING_PROVIDER=nominatim
# GEOCODING_URL=https://nominatim.openstreetmap.org
# Eindeutiger User-Agent (Nominatim-Richtlinie); leer = "Craftvia/<version> (+APP_BASE_URL)".
# GEOCODING_USER_AGENT=
# Kartenkacheln der Live-Lage; der Host wird beim Build in die CSP (img-src) übernommen.
# MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
# MAP_ATTRIBUTION=© OpenStreetMap-Mitwirkende
# --- Optionale Fundament-Variablen (Default leer) ---
# MFA_ENC_KEY: Schlüssel für TOTP-Secrets at-rest (leer = aus AUTH_SECRET abgeleitet).
# ⚠ Nach dem Setzen nicht mehr ändern.
+3 -2
View File
@@ -18,8 +18,9 @@ async function main() {
continue;
}
const processor = await load();
const concurrency = name === JOB_QUEUES.reportPdf ? 2 : 4;
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency });
const geocode = name === JOB_QUEUES.geocodeSite; // L13: OSM Nominatim policy — max. 1 request/s
const concurrency = name === JOB_QUEUES.reportPdf ? 2 : geocode ? 1 : 4;
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message));
workers.push(w);
console.info(`[worker] listening on ${name}`);
+81
View File
@@ -0,0 +1,81 @@
/**
* L13 Planung — geocode sites that have no (or outdated) coordinates, respecting the OSM Nominatim
* usage policy: strictly sequential, max. 1 request per second (process-wide limiter in the
* provider), identifying User-Agent, results cached on the site (unchanged addresses are skipped).
* Manually entered coordinates are never overwritten.
*
* Usage:
* npx tsx scripts/geocode-backfill.ts # all tenants
* npx tsx scripts/geocode-backfill.ts --tenant=demo --limit=50
* npx tsx scripts/geocode-backfill.ts --dry-run # only list what would be geocoded
* npx tsx scripts/geocode-backfill.ts --retry-not-found # also retry addresses marked not_found
*
* For larger data sets use a self-hosted or contracted geocoding service (GEOCODING_URL).
*/
import "dotenv/config";
import { isValidLatLng } from "../src/lib/geo/distance";
import { dbForTenant, prisma } from "../src/server/db";
import { geocodingConfig } from "../src/server/services/geo/config";
import { geocodeSite, type GeocodeOutcome } from "../src/server/services/geo/geocode-site";
import { geocodeQueryFor } from "../src/server/services/geo/normalize";
const arg = (name: string) => process.argv.find((a) => a.startsWith(`--${name}=`))?.split("=")[1];
const flag = (name: string) => process.argv.includes(`--${name}`);
async function main() {
const cfg = geocodingConfig();
const dryRun = flag("dry-run");
const retryNotFound = flag("retry-not-found");
const limit = Number(arg("limit") ?? Infinity);
const slug = arg("tenant");
if (cfg.provider === "none" && !dryRun) {
console.log("GEOCODING_PROVIDER=none — nichts zu tun.");
return;
}
console.log(`Provider ${cfg.provider} (${cfg.url}), User-Agent "${cfg.userAgent}", ≤ 1 Anfrage/s${dryRun ? " — Probelauf" : ""}`);
const tenants = await prisma.tenant.findMany({ where: slug ? { slug } : {}, select: { id: true, slug: true }, orderBy: { slug: "asc" } });
const totals: Record<string, number> = {};
let done = 0;
for (const tenant of tenants) {
const db = dbForTenant(tenant.id);
const sites = await db.site.findMany({
where: { deletedAt: null },
select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, country: true, latitude: true, longitude: true, geocodedAt: true, geocodeStatus: true, geocodeQuery: true },
orderBy: { createdAt: "asc" },
});
const pending = sites.filter((s) => {
const query = geocodeQueryFor(s);
if (!query) return false;
if (isValidLatLng(s) && !s.geocodedAt) return false; // manual coordinates
if (s.geocodeQuery !== query) return true;
if (s.geocodeStatus === "ok") return false;
if (s.geocodeStatus === "not_found") return retryNotFound;
return true;
});
if (!pending.length) continue;
console.log(`\n${tenant.slug}: ${pending.length} Objekt(e)`);
for (const site of pending) {
if (done >= limit) break;
done++;
if (dryRun) {
console.log(` · ${site.name} (${[site.postalCode, site.city].filter(Boolean).join(" ")})`);
continue;
}
if (retryNotFound && site.geocodeStatus === "not_found") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: null } });
const outcome: GeocodeOutcome = await geocodeSite(tenant.id, site.id);
totals[outcome] = (totals[outcome] ?? 0) + 1;
console.log(` ${outcome.padEnd(9)} ${site.name}`);
}
}
console.log(`\nFertig: ${done} Objekt(e) ${JSON.stringify(totals)}`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+178
View File
@@ -0,0 +1,178 @@
// L13 Planung — Geocoding: Normalisierung/Änderungserkennung, Nominatim-Anfrage (URL, User-Agent,
// Accept-Language, Auswertung, Fehler) mit injiziertem fetch, Drosselung 1/s mit Fake-Uhr, Processor mit
// Fake-Provider (Cache bei unveränderter Adresse, not_found, failed → Retry, manuelle Koordinaten,
// Provider none, Mandantentrennung), Hook beim Anlegen ohne Inline-Aufruf. Nominatim wird NIE aufgerufen.
// Lauf: npx tsx scripts/test-planung-geocode.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture";
import { geocodingConfig, mapConfig } from "../src/server/services/geo/config";
import { requestSiteGeocoding } from "../src/server/services/geo/dispatch";
import { geocodeSite } from "../src/server/services/geo/geocode-site";
import { buildNominatimUrl, createNominatimProvider } from "../src/server/services/geo/nominatim";
import { addressChanged, geocodeQueryFor } from "../src/server/services/geo/normalize";
import { GeocodingError, setGeocodingProvider, type GeocodeAddress, type GeocodingProvider } from "../src/server/services/geo/provider";
import { createIntervalLimiter } from "../src/server/services/geo/rate-limit";
import { process as geocodeProcessor } from "../src/server/jobs/processors/geocode-site";
import { PROCESSORS } from "../src/server/jobs/processors";
import { createSite } from "../src/server/services/sites/sites";
const SLUG_A = "zz-planung-geo-a";
const SLUG_B = "zz-planung-geo-b";
// Guard: any request to a Nominatim host fails the suite.
let nominatimHits = 0;
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
if (/nominatim|openstreetmap/i.test(url)) {
nominatimHits++;
throw new Error("Nominatim must never be called from tests");
}
return realFetch(input, init);
}) as typeof fetch;
function fakeProvider(answer: (a: GeocodeAddress) => { status: "ok"; latitude: number; longitude: number } | { status: "not_found" } | "throw") {
const calls: GeocodeAddress[] = [];
const provider: GeocodingProvider = {
name: "fake",
async geocode(a) {
calls.push(a);
const r = answer(a);
if (r === "throw") throw new GeocodingError("http 503");
return r;
},
};
return { provider, calls };
}
runSuite("L13 Planung – Geocoding", [SLUG_A, SLUG_B], async () => {
section("Normalisierung & Änderungserkennung");
const addr = { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" };
ok(geocodeQueryFor(addr) === "hafenstraße 12|20457|hamburg|de", `normierte Adresse (${geocodeQueryFor(addr)})`);
ok(geocodeQueryFor({ ...addr, street: " HAFENSTRASSE ".replace("SS", "ß"), city: "hamburg " }) === geocodeQueryFor(addr), "Groß-/Kleinschreibung und Leerzeichen egal");
ok(geocodeQueryFor({ street: "Hafenstraße", houseNumber: "12" }) === null, "ohne PLZ und Ort → nicht verortbar");
ok(!addressChanged(addr, { ...addr, city: "HAMBURG" }) && addressChanged(addr, { ...addr, houseNumber: "14" }), "nur relevante Adressänderungen zählen");
section("Nominatim-Anfrage (ohne Netz)");
const url = buildNominatimUrl("https://nominatim.example/", addr);
ok(
url.pathname === "/search" && url.searchParams.get("format") === "jsonv2" && url.searchParams.get("limit") === "1" && url.searchParams.get("street") === "12 Hafenstraße" && url.searchParams.get("postalcode") === "20457" && url.searchParams.get("city") === "Hamburg" && url.searchParams.get("countrycodes") === "de",
`strukturierte Suche (${url.search})`,
);
const cfg = geocodingConfig({ APP_BASE_URL: "https://app.craftvia.example" });
ok(/^Craftvia\/\d+\.\d+\.\d+ \(\+https:\/\/app\.craftvia\.example\)$/.test(cfg.userAgent) && cfg.url === "https://nominatim.openstreetmap.org" && cfg.provider === "nominatim", `Standard-User-Agent „${cfg.userAgent}“`);
ok(geocodingConfig({ GEOCODING_USER_AGENT: "Betrieb/1 (+mailto:it@example.org)", GEOCODING_PROVIDER: "none", GEOCODING_URL: "https://geo.example/" }).userAgent === "Betrieb/1 (+mailto:it@example.org)", "User-Agent per GEOCODING_USER_AGENT überschreibbar");
ok(geocodingConfig({ GEOCODING_PROVIDER: "none" }).provider === "none" && geocodingConfig({ GEOCODING_URL: "https://geo.example/" }).url === "https://geo.example", "Provider none / eigene GEOCODING_URL");
ok(mapConfig({}).tileUrl === "https://tile.openstreetmap.org/{z}/{x}/{y}.png" && mapConfig({}).attribution.includes("OpenStreetMap"), "Kartenkacheln: OSM-Standard mit Namensnennung");
const seen: { url: string; headers: Headers }[] = [];
let reply: () => Response = () => Response.json([{ lat: "53.5413", lon: "9.9849" }]);
const acquired: number[] = [];
const nominatim = createNominatimProvider(
{ url: "https://nominatim.example", userAgent: "Craftvia/test (+https://app.example)", timeoutMs: 1000 },
{
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
seen.push({ url: String(input), headers: new Headers(init?.headers) });
return reply();
}) as typeof fetch,
limiter: { acquire: async () => (acquired.push(Date.now()), Date.now()) },
},
);
const hit = await nominatim.geocode(addr);
ok(hit.status === "ok" && hit.latitude === 53.5413 && hit.longitude === 9.9849, "Treffer ausgewertet");
ok(seen[0]?.headers.get("user-agent") === "Craftvia/test (+https://app.example)" && seen[0].headers.get("accept-language") === "de", "User-Agent und Accept-Language de gesetzt");
ok(acquired.length === 1, "jede Anfrage läuft über den Drosselungs-Limiter");
reply = () => Response.json([]);
ok((await nominatim.geocode(addr)).status === "not_found", "leere Antwort → not_found");
reply = () => new Response("busy", { status: 503 });
let threw = false;
try {
await nominatim.geocode(addr);
} catch (err) {
threw = err instanceof GeocodingError;
}
ok(threw, "HTTP-Fehler → GeocodingError (wiederholbar)");
section("Drosselung 1 Anfrage/s (Fake-Uhr)");
let clock = 10_000;
const sleeps: number[] = [];
const limiter = createIntervalLimiter(1000, { now: () => clock, sleep: async (ms) => void sleeps.push(ms) });
const slots = [await limiter.acquire(), await limiter.acquire(), await limiter.acquire()];
ok(slots[0] === 10_000 && slots[1] === 11_000 && slots[2] === 12_000 && sleeps.join(",") === "1000,2000", `Slots im Sekundenabstand (${slots.join(",")}), Wartezeiten ${sleeps.join(",")}`);
clock = 20_000;
ok((await limiter.acquire()) === 20_000 && sleeps.length === 2, "nach Pause sofort wieder frei");
section("geocodeSite mit Fake-Provider");
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const fake = fakeProvider((a) => (a.city === "Nirgendwo" ? { status: "not_found" } : { status: "ok", latitude: 53.55, longitude: a.houseNumber === "14" ? 10.01 : 10.0 }));
setGeocodingProvider(fake.provider);
const site = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo 1", ...addr } });
ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 1, "neue Adresse → ok, 1 Provider-Aufruf");
const s1 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } });
ok(s1.latitude === 53.55 && s1.longitude === 10.0 && s1.geocodeStatus === "ok" && s1.geocodeQuery === geocodeQueryFor(addr) && !!s1.geocodedAt, "Koordinaten, Status, Adresse und Zeitpunkt am Objekt gespeichert");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "site", entityId: site.id, action: "update" } })) === 1, "Koordinatenänderung auditiert");
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "unveränderte Adresse → Cache, kein weiterer Aufruf");
await prisma.site.update({ where: { id: site.id }, data: { city: "hamburg " } });
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "nur Schreibweise geändert → Cache");
await prisma.site.update({ where: { id: site.id }, data: { houseNumber: "14" } });
ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 2 && (await prisma.site.findUniqueOrThrow({ where: { id: site.id } })).longitude === 10.01, "geänderte Adresse → neu verortet");
await prisma.site.update({ where: { id: site.id }, data: { city: "Nirgendwo", postalCode: "99999" } });
ok((await geocodeSite(A.tenantId, site.id)) === "not_found" && fake.calls.length === 3, "unbekannte Adresse → not_found");
const s2 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } });
ok(s2.geocodeStatus === "not_found" && s2.latitude === null && s2.longitude === null, "not_found entfernt veraltete automatische Koordinaten");
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 3, "not_found wird für dieselbe Adresse nicht wiederholt");
const failing = fakeProvider(() => "throw");
const siteFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Fehler", ...addr } });
ok((await geocodeSite(A.tenantId, siteFail.id, { provider: failing.provider })) === "failed" && (await prisma.site.findUniqueOrThrow({ where: { id: siteFail.id } })).geocodeStatus === "failed", "Providerfehler → Status failed");
ok((await geocodeSite(A.tenantId, siteFail.id)) === "ok", "failed wird beim nächsten Lauf erneut versucht");
const manual = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo manuell", ...addr, latitude: 50.1, longitude: 8.6 } });
const callsBefore = fake.calls.length;
ok((await geocodeSite(A.tenantId, manual.id)) === "manual" && fake.calls.length === callsBefore, "manuelle Koordinaten → kein Aufruf");
const m = await prisma.site.findUniqueOrThrow({ where: { id: manual.id } });
ok(m.latitude === 50.1 && m.longitude === 8.6 && m.geocodeStatus === "skipped", "manuelle Koordinaten bleiben unverändert (Status skipped)");
const none = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo none", ...addr } });
ok((await geocodeSite(A.tenantId, none.id, { provider: null })) === "skipped" && (await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).latitude === null, "Provider none → skipped, ohne Ortsangabe, kein Fehler");
const noAddr = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo ohne Adresse", street: "Irgendwo" } });
ok((await geocodeSite(A.tenantId, noAddr.id)) === "skipped" && fake.calls.length === callsBefore, "unvollständige Adresse → skipped ohne Aufruf");
const bCalls = fake.calls.length;
ok((await geocodeSite(B.tenantId, none.id)) === "missing" && fake.calls.length === bCalls, "Mandant B: Objekt von A nicht gefunden, kein Aufruf");
ok((await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).geocodeStatus === "skipped", "Objekt von A unverändert");
section("Processor & Hook");
ok(typeof PROCESSORS["geocode-site"] === "function", "Processor geocode-site registriert");
const viaJob = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job", ...addr } });
await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJob.id });
ok((await prisma.site.findUniqueOrThrow({ where: { id: viaJob.id } })).geocodeStatus === "ok", "Processor verortet das Objekt");
setGeocodingProvider(failing.provider);
const viaJobFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job Fehler", ...addr } });
let jobThrew = false;
try {
await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJobFail.id });
} catch {
jobThrew = true;
}
ok(jobThrew, "Processor wirft bei failed → BullMQ wiederholt");
setGeocodingProvider(fake.provider);
const prev = process.env.GEOCODING_PROVIDER;
process.env.GEOCODING_PROVIDER = "none";
ok((await requestSiteGeocoding(A.tenantId, viaJob.id)) === false, "GEOCODING_PROVIDER=none → kein Job");
process.env.GEOCODING_PROVIDER = "nominatim";
const hookCalls = fake.calls.length;
const created = await createSite(A.ctx.backoffice, { customerId: A.customerId, name: "Geo Hook", street: "Am Kaiserkai", houseNumber: "1", postalCode: "20457", city: "Hamburg" });
await new Promise((resolve) => setTimeout(resolve, 100));
ok(!!created.id && fake.calls.length === hookCalls, "Anlage eines Objekts: Verortung nur per Queue, nie inline im Request");
if (prev === undefined) delete process.env.GEOCODING_PROVIDER;
else process.env.GEOCODING_PROVIDER = prev;
setGeocodingProvider(undefined);
ok(nominatimHits === 0, "Nominatim wurde in keinem Test aufgerufen");
});
@@ -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)");
}
+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);
+2
View File
@@ -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
View File
@@ -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;
}