diff --git a/apps/mana/apps/web/src/lib/modules/places/ListView.svelte b/apps/mana/apps/web/src/lib/modules/places/ListView.svelte index 7cb50b126..a328f13b8 100644 --- a/apps/mana/apps/web/src/lib/modules/places/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/places/ListView.svelte @@ -7,7 +7,8 @@ import { useAllPlaces } from './queries'; import { placesStore } from './stores/places.svelte'; import { trackingStore } from './stores/tracking.svelte'; - import { Star, MapPin, Plus, PencilSimple, Trash } from '@mana/shared-icons'; + import { searchAddress, formatAddress, type GeocodingResult } from './geocoding'; + import { Star, MapPin, Plus, PencilSimple, Trash, MagnifyingGlass } from '@mana/shared-icons'; import type { ViewProps } from '$lib/app-registry'; import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui'; import { dropTarget, dragSource } from '@mana/shared-ui/dnd'; @@ -54,7 +55,58 @@ other: 'Sonstiges', }; - // Quick create + // --- Address autocomplete --- + let addressQuery = $state(''); + let suggestions = $state([]); + let showSuggestions = $state(false); + let debounceTimer: ReturnType | undefined; + + function onAddressInput() { + clearTimeout(debounceTimer); + if (addressQuery.trim().length < 2) { + suggestions = []; + showSuggestions = false; + return; + } + debounceTimer = setTimeout(async () => { + const focusLat = trackingStore.currentPosition?.coords.latitude; + const focusLon = trackingStore.currentPosition?.coords.longitude; + suggestions = await searchAddress(addressQuery, { + limit: 6, + focusLat, + focusLon, + }); + showSuggestions = suggestions.length > 0; + }, 300); + } + + async function selectSuggestion(result: GeocodingResult) { + showSuggestions = false; + addressQuery = ''; + const place = await placesStore.createPlace({ + name: result.name || result.label, + latitude: result.latitude, + longitude: result.longitude, + address: formatAddress(result.address), + category: result.category, + }); + navigate('detail', { placeId: place.id }); + } + + function onAddressKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + showSuggestions = false; + } + } + + function onAddressBlur() { + // Delay to allow click on suggestion + setTimeout(() => { + showSuggestions = false; + }, 200); + } + + // Quick create (manual name) let newName = $state(''); async function createPlace() { @@ -157,12 +209,51 @@ - + + + +
@@ -344,6 +435,120 @@ color: hsl(var(--color-muted-foreground)); } + /* ── Address Search ───────────────────────── */ + .address-search { + position: relative; + } + + .address-input-row { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.625rem; + border-radius: 0.5rem; + border: 1px solid hsl(var(--color-border)); + background: transparent; + transition: border-color 0.15s; + } + + .address-input-row:focus-within { + border-color: #0ea5e9; + } + + .address-input-row :global(.address-icon) { + color: hsl(var(--color-muted-foreground)); + flex-shrink: 0; + } + + .address-input { + flex: 1; + border: none; + background: transparent; + color: hsl(var(--color-foreground)); + font-size: 0.8125rem; + outline: none; + } + + .address-input::placeholder { + color: hsl(var(--color-muted-foreground)); + } + + .suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 0.25rem; + background: hsl(var(--color-background)); + border: 1px solid hsl(var(--color-border)); + border-radius: 0.5rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 50; + overflow: hidden; + } + + .suggestion-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.625rem; + width: 100%; + border: none; + background: transparent; + color: hsl(var(--color-foreground)); + cursor: pointer; + text-align: left; + transition: background 0.1s; + } + + .suggestion-item:hover { + background: hsl(var(--color-muted)); + } + + .suggestion-item + .suggestion-item { + border-top: 1px solid hsl(var(--color-border) / 0.5); + } + + .suggestion-icon { + color: #0ea5e9; + flex-shrink: 0; + } + + .suggestion-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.0625rem; + min-width: 0; + } + + .suggestion-name { + font-size: 0.8125rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .suggestion-address { + font-size: 0.6875rem; + color: hsl(var(--color-muted-foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .suggestion-category { + padding: 0.0625rem 0.375rem; + border-radius: 9999px; + background: rgba(14, 165, 233, 0.1); + color: #0ea5e9; + font-size: 0.5625rem; + font-weight: 500; + flex-shrink: 0; + white-space: nowrap; + } + /* ── Quick Create ─────────────────────────── */ .create-row { display: flex; diff --git a/apps/mana/apps/web/src/lib/modules/places/geocoding.ts b/apps/mana/apps/web/src/lib/modules/places/geocoding.ts new file mode 100644 index 000000000..3b748719c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/places/geocoding.ts @@ -0,0 +1,117 @@ +/** + * Geocoding client for the Places module. + * + * Talks to our self-hosted mana-geocoding service (Pelias-backed). + * All queries stay within our infrastructure — no location data + * leaves the network. + */ + +import type { PlaceCategory } from './types'; + +const GEOCODING_URL = () => { + if (typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_GEOCODING_URL__?: string }) + .__PUBLIC_MANA_GEOCODING_URL__; + if (injected) return injected; + } + return import.meta.env.PUBLIC_MANA_GEOCODING_URL ?? 'http://localhost:3018'; +}; + +export interface GeocodingResult { + label: string; + name: string; + latitude: number; + longitude: number; + address: { + street?: string; + houseNumber?: string; + postalCode?: string; + city?: string; + state?: string; + country?: string; + }; + category: PlaceCategory; + osmCategory?: string; + osmType?: string; + confidence: number; +} + +interface GeocodingResponse { + results: GeocodingResult[]; + cached?: boolean; + error?: string; +} + +/** + * Forward geocoding / autocomplete. + * Returns places matching the search query, biased towards the focus point. + */ +export async function searchAddress( + query: string, + options?: { + limit?: number; + focusLat?: number; + focusLon?: number; + lang?: string; + } +): Promise { + if (!query || query.trim().length < 2) return []; + + const params = new URLSearchParams({ q: query.trim() }); + if (options?.limit) params.set('limit', String(options.limit)); + if (options?.lang) params.set('lang', options.lang); + if (options?.focusLat != null) params.set('focus.lat', String(options.focusLat)); + if (options?.focusLon != null) params.set('focus.lon', String(options.focusLon)); + + try { + const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/search?${params}`); + if (!res.ok) return []; + const data: GeocodingResponse = await res.json(); + return data.results; + } catch { + console.warn('Geocoding search failed — service may be offline'); + return []; + } +} + +/** + * Reverse geocoding — resolve coordinates to an address and place type. + */ +export async function reverseGeocode( + lat: number, + lon: number, + lang = 'de' +): Promise { + try { + const params = new URLSearchParams({ + lat: String(lat), + lon: String(lon), + lang, + }); + const res = await fetch(`${GEOCODING_URL()}/api/v1/geocode/reverse?${params}`); + if (!res.ok) return null; + const data: GeocodingResponse = await res.json(); + return data.results[0] ?? null; + } catch { + console.warn('Reverse geocoding failed — service may be offline'); + return null; + } +} + +/** + * Format a structured address into a single-line string. + */ +export function formatAddress(address: GeocodingResult['address']): string { + const parts: string[] = []; + + if (address.street) { + parts.push(address.houseNumber ? `${address.street} ${address.houseNumber}` : address.street); + } + if (address.postalCode && address.city) { + parts.push(`${address.postalCode} ${address.city}`); + } else if (address.city) { + parts.push(address.city); + } + + return parts.join(', '); +} diff --git a/apps/mana/apps/web/src/lib/modules/places/index.ts b/apps/mana/apps/web/src/lib/modules/places/index.ts index 698457e9f..880fd7883 100644 --- a/apps/mana/apps/web/src/lib/modules/places/index.ts +++ b/apps/mana/apps/web/src/lib/modules/places/index.ts @@ -16,4 +16,6 @@ export { findNearestPlace, } from './queries'; export { placeTable, locationLogTable, PLACES_GUEST_SEED } from './collections'; +export { searchAddress, reverseGeocode, formatAddress } from './geocoding'; +export type { GeocodingResult } from './geocoding'; export type { LocalPlace, LocalLocationLog, Place, LocationLog, PlaceCategory } from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts index 763af8d10..ad386e79b 100644 --- a/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/places/stores/tracking.svelte.ts @@ -9,6 +9,7 @@ import { decryptRecords, encryptRecord } from '$lib/data/crypto'; import { createBlock } from '$lib/data/time-blocks/service'; import { locationLogTable, placeTable } from '../collections'; import { getDistanceKm, findNearestPlace, toPlace } from '../queries'; +import { reverseGeocode, formatAddress } from '../geocoding'; import type { LocalLocationLog, LocalPlace } from '../types'; // ─── State ────────────────────────────────────────────── @@ -139,11 +140,30 @@ async function logPosition(pos: GeolocationPosition) { if (nearest) { const local = await placeTable.get(nearest.id); if (local) { - await placeTable.update(nearest.id, { + const updates: Partial = { visitCount: (local.visitCount ?? 0) + 1, lastVisitedAt: log.timestamp, updatedAt: new Date().toISOString(), - }); + }; + + // Auto-fill address via reverse geocoding if the place has none + if (!local.address) { + reverseGeocode(lat, lng).then(async (result) => { + if (result) { + const addr = formatAddress(result.address); + if (addr) { + const rec: Partial = { address: addr }; + await encryptRecord('places', rec); + await placeTable.update(nearest.id, { + address: rec.address, + updatedAt: new Date().toISOString(), + }); + } + } + }); + } + + await placeTable.update(nearest.id, updates); await createBlock({ startDate: log.timestamp, diff --git a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte index f605750a8..f7c36cf20 100644 --- a/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/places/views/DetailView.svelte @@ -8,7 +8,8 @@ import { useDetailEntity } from '$lib/data/detail-entity.svelte'; import DetailViewShell from '$lib/components/DetailViewShell.svelte'; import { placesStore } from '../stores/places.svelte'; - import { Star, MapPin, X } from '@mana/shared-icons'; + import { reverseGeocode, formatAddress, searchAddress, type GeocodingResult } from '../geocoding'; + import { Star, MapPin, X, MagnifyingGlass, ArrowsClockwise } from '@mana/shared-icons'; import type { ViewProps } from '$lib/app-registry'; import type { LocalPlace, PlaceCategory, LocalLocationLog } from '../types'; import { useAllTags, getTagsByIds } from '@mana/shared-stores'; @@ -64,6 +65,64 @@ { value: 'other', label: 'Sonstiges' }, ]; + // --- Reverse geocoding (coords → address) --- + let isResolving = $state(false); + + async function resolveAddress() { + const lat = parseFloat(editLatitude); + const lng = parseFloat(editLongitude); + if (isNaN(lat) || isNaN(lng) || (lat === 0 && lng === 0)) return; + + isResolving = true; + const result = await reverseGeocode(lat, lng); + isResolving = false; + + if (result) { + editAddress = formatAddress(result.address); + if (editCategory === 'other' && result.category !== 'other') { + editCategory = result.category; + } + await saveField(); + } + } + + // --- Address search in detail view --- + let addressSearch = $state(''); + let addressSuggestions = $state([]); + let showAddressSuggestions = $state(false); + let addressDebounce: ReturnType | undefined; + + function onAddressSearchInput() { + clearTimeout(addressDebounce); + if (addressSearch.trim().length < 2) { + addressSuggestions = []; + showAddressSuggestions = false; + return; + } + addressDebounce = setTimeout(async () => { + addressSuggestions = await searchAddress(addressSearch, { limit: 5 }); + showAddressSuggestions = addressSuggestions.length > 0; + }, 300); + } + + async function applyAddressResult(result: GeocodingResult) { + showAddressSuggestions = false; + addressSearch = ''; + editAddress = formatAddress(result.address); + editLatitude = String(result.latitude); + editLongitude = String(result.longitude); + if (result.category !== 'other') { + editCategory = result.category; + } + await saveField(); + } + + function onAddressSearchBlur() { + setTimeout(() => { + showAddressSuggestions = false; + }, 200); + } + async function removeTag(tagId: string) { await removeTagIdWithUndo(detail.entity?.tagIds ?? [], tagId, (next) => placesStore.updateTagIds(placeId, next) @@ -181,6 +240,37 @@ />
+ +
+ +
+
+ + { + if (addressSuggestions.length > 0) showAddressSuggestions = true; + }} + /> +
+ {#if showAddressSuggestions} +
+ {#each addressSuggestions as result} + + {/each} +
+ {/if} +
+
+
Koordinaten
@@ -202,6 +292,14 @@ type="number" step="any" /> +
@@ -359,6 +457,134 @@ gap: 0.25rem; flex: 1; justify-content: flex-end; + align-items: center; + } + + .resolve-btn { + padding: 0.25rem; + border-radius: 0.25rem; + border: 1px solid transparent; + background: transparent; + color: hsl(var(--color-muted-foreground)); + cursor: pointer; + display: flex; + align-items: center; + transition: all 0.15s; + flex-shrink: 0; + } + + .resolve-btn:hover:not(:disabled) { + color: #0ea5e9; + border-color: hsl(var(--color-border)); + } + + .resolve-btn:disabled { + opacity: 0.4; + cursor: not-allowed; + } + + .resolve-btn :global(.spinning) { + animation: spin 0.8s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + /* ── Address Search (Detail) ─────────────── */ + .address-search-row { + align-items: flex-start; + } + + .address-search-wrapper { + flex: 1; + position: relative; + min-width: 0; + } + + .address-search-input-row { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.1875rem 0.375rem; + border-radius: 0.25rem; + border: 1px solid transparent; + color: hsl(var(--color-muted-foreground)); + transition: border-color 0.15s; + } + + .address-search-input-row:focus-within { + border-color: hsl(var(--color-border)); + } + + .address-search-input { + flex: 1; + border: none; + background: transparent; + color: hsl(var(--color-foreground)); + font-size: 0.75rem; + outline: none; + text-align: right; + } + + .address-search-input::placeholder { + color: hsl(var(--color-muted-foreground)); + } + + .address-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 0.125rem; + background: hsl(var(--color-background)); + border: 1px solid hsl(var(--color-border)); + border-radius: 0.375rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 50; + overflow: hidden; + } + + .address-suggestion { + display: flex; + flex-direction: column; + gap: 0.0625rem; + padding: 0.375rem 0.5rem; + width: 100%; + border: none; + background: transparent; + color: hsl(var(--color-foreground)); + cursor: pointer; + text-align: left; + font-size: 0.75rem; + } + + .address-suggestion:hover { + background: hsl(var(--color-muted)); + } + + .address-suggestion + .address-suggestion { + border-top: 1px solid hsl(var(--color-border) / 0.5); + } + + .address-suggestion-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .address-suggestion-detail { + font-size: 0.6875rem; + color: hsl(var(--color-muted-foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .tags-list { diff --git a/docs/PORT_SCHEMA.md b/docs/PORT_SCHEMA.md index ffa1d2856..0121459bb 100644 --- a/docs/PORT_SCHEMA.md +++ b/docs/PORT_SCHEMA.md @@ -74,7 +74,8 @@ | 3014 | mana-crawler | Go | Web crawler, content extraction | | 3015 | mana-landing-builder | NestJS | Org landing page builder | | 3016 | mana-api-gateway | Go | API keys, rate limiting, usage tracking | -| 3017-3019 | *(reserved)* | | | +| 3018 | mana-geocoding | Hono/Bun | Self-hosted geocoding (Pelias proxy) | +| 3017, 3019 | *(reserved)* | | | ## 3020-3029: AI/ML Services diff --git a/package.json b/package.json index d6499b687..6c18b8940 100644 --- a/package.json +++ b/package.json @@ -251,6 +251,7 @@ "dev:calc:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:calc:web\"", "dev:manavoxel:local": "concurrently -n sync,web -c magenta,cyan \"pnpm dev:sync\" \"pnpm dev:manavoxel:web\"", "dev:media": "cd services/mana-media/apps/api && bun run --hot src/index.ts", + "dev:geocoding": "cd services/mana-geocoding && bun run --watch src/index.ts", "dev:mana:servers": "concurrently -n auth,sync,api,media -c blue,magenta,yellow,green \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\" \"pnpm dev:media\"" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a324b923e..6f1d8ee16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,7 +86,7 @@ importers: version: 6.0.154(zod@3.25.76) drizzle-orm: specifier: ^0.38.0 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -105,7 +105,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.11 + version: 1.3.12 '@types/jsdom': specifier: ^21.1.0 version: 21.1.7 @@ -2406,7 +2406,7 @@ importers: version: link:../../../../packages/shared-hono drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) + version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) hono: specifier: ^4.7.0 version: 4.12.12 @@ -2428,7 +2428,7 @@ importers: devDependencies: drizzle-orm: specifier: ^0.44.7 - version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) + version: 0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) postgres: specifier: ^3.4.7 version: 3.4.9 @@ -2467,7 +2467,7 @@ importers: devDependencies: '@types/bun': specifier: latest - version: 1.3.11 + version: 1.3.12 typescript: specifier: ^5.7.2 version: 5.9.3 @@ -2854,7 +2854,7 @@ importers: version: link:../shared-logger drizzle-orm: specifier: ^0.45.1 - version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) + version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9) hono: specifier: ^4.7.0 version: 4.12.12 @@ -3269,7 +3269,7 @@ importers: version: link:../../packages/shared-hono drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -3302,10 +3302,10 @@ importers: version: 3.0.3 better-auth: specifier: ^1.4.3 - version: 1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3) + version: 1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3) drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -3344,7 +3344,7 @@ importers: version: 3.0.3 drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -3375,7 +3375,7 @@ importers: dependencies: drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -3396,6 +3396,16 @@ importers: specifier: ^5.9.3 version: 5.9.3 + services/mana-geocoding: + dependencies: + hono: + specifier: ^4.7.0 + version: 4.12.12 + devDependencies: + typescript: + specifier: ^5.9.3 + version: 5.9.3 + services/mana-landing-builder: dependencies: '@mana/shared-types': @@ -3460,7 +3470,7 @@ importers: version: 5.73.0 drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) exifr: specifier: ^7.1.3 version: 7.1.3 @@ -3519,7 +3529,7 @@ importers: version: link:../../packages/shared-hono drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -3552,7 +3562,7 @@ importers: version: link:../../packages/shared-hono drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -3580,7 +3590,7 @@ importers: version: 0.5.0 drizzle-orm: specifier: ^0.38.3 - version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + version: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) hono: specifier: ^4.7.0 version: 4.12.12 @@ -8490,6 +8500,9 @@ packages: '@types/bun@1.3.11': resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==} + '@types/bun@1.3.12': + resolution: {integrity: sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -9790,6 +9803,9 @@ packages: bun-types@1.3.11: resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==} + bun-types@1.3.12: + resolution: {integrity: sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -19506,12 +19522,12 @@ snapshots: nanostores: 1.2.0 zod: 4.3.6 - '@better-auth/drizzle-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))': + '@better-auth/drizzle-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))': dependencies: '@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/utils': 0.4.0 optionalDependencies: - drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) '@better-auth/kysely-adapter@1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15)': dependencies: @@ -24746,6 +24762,10 @@ snapshots: dependencies: bun-types: 1.3.11 + '@types/bun@1.3.12': + dependencies: + bun-types: 1.3.12 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -26790,10 +26810,10 @@ snapshots: bcryptjs@3.0.3: {} - better-auth@1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3): + better-auth@1.6.0(@opentelemetry/api@1.9.1)(@sveltejs/kit@2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(drizzle-kit@0.30.6)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(svelte@5.55.1)(vitest@4.1.3): dependencies: '@better-auth/core': 1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0) - '@better-auth/drizzle-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)) + '@better-auth/drizzle-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0)) '@better-auth/kysely-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0)(kysely@0.28.15) '@better-auth/memory-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0) '@better-auth/mongo-adapter': 1.6.0(@better-auth/core@1.6.0(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.5(zod@3.25.76))(jose@6.2.2)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.4.0) @@ -26812,7 +26832,7 @@ snapshots: optionalDependencies: '@sveltejs/kit': 2.56.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.55.1)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.55.1)(typescript@5.9.3)(vite@6.4.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) drizzle-kit: 0.30.6 - drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) + drizzle-orm: 0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) svelte: 5.55.1 @@ -26971,6 +26991,10 @@ snapshots: dependencies: '@types/node': 22.19.17 + bun-types@1.3.12: + dependencies: + '@types/node': 22.19.17 + bundle-require@5.1.0(esbuild@0.27.7): dependencies: esbuild: 0.27.7 @@ -27723,30 +27747,30 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.11)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0): + drizzle-orm@0.38.4(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(@types/react@19.2.14)(bun-types@1.3.12)(kysely@0.28.15)(postgres@3.4.9)(react@19.2.0): optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/pg': 8.6.1 '@types/react': 19.2.14 - bun-types: 1.3.11 + bun-types: 1.3.12 kysely: 0.28.15 postgres: 3.4.9 react: 19.2.0 - drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): + drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/pg': 8.6.1 - bun-types: 1.3.11 + bun-types: 1.3.12 gel: 2.2.0 kysely: 0.28.15 postgres: 3.4.9 - drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.11)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): + drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.6.1)(bun-types@1.3.12)(gel@2.2.0)(kysely@0.28.15)(postgres@3.4.9): optionalDependencies: '@opentelemetry/api': 1.9.1 '@types/pg': 8.6.1 - bun-types: 1.3.11 + bun-types: 1.3.12 gel: 2.2.0 kysely: 0.28.15 postgres: 3.4.9 diff --git a/services/mana-geocoding/CLAUDE.md b/services/mana-geocoding/CLAUDE.md new file mode 100644 index 000000000..b5582f676 --- /dev/null +++ b/services/mana-geocoding/CLAUDE.md @@ -0,0 +1,148 @@ +# mana-geocoding + +Self-hosted geocoding service. Wraps a local Pelias instance (DACH region) with caching and automatic OSM → PlaceCategory mapping. All geocoding queries stay within our infrastructure — no user location data leaves the network. + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| **Runtime** | Bun | +| **Framework** | Hono | +| **Geocoding** | Pelias (self-hosted, Elasticsearch-backed) | +| **Data** | OpenStreetMap DACH extract (DE/AT/CH) | +| **Caching** | In-memory LRU (5000 entries, 24h TTL) | + +## Port: 3018 + +## Quick Start + +```bash +# 1. Start Pelias stack (first time: run setup.sh for data import) +cd services/mana-geocoding/pelias +docker compose up -d +# First time only: +chmod +x setup.sh && ./setup.sh + +# 2. Start the Hono wrapper +cd services/mana-geocoding +bun run dev +``` + +## API Endpoints + +All endpoints are public (no auth required) — the service is internal-only, not exposed to the internet. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/geocode/search?q=...` | Forward geocoding / autocomplete | +| GET | `/api/v1/geocode/reverse?lat=...&lon=...` | Reverse geocoding | +| GET | `/api/v1/geocode/stats` | Cache statistics | +| GET | `/health` | Health check | + +### Search params + +| Param | Required | Description | +|-------|----------|-------------| +| `q` | yes | Search query (min 2 chars) | +| `limit` | no | Max results (default 5, max 20) | +| `lang` | no | Language (default `de`) | +| `focus.lat` | no | Bias results towards this latitude | +| `focus.lon` | no | Bias results towards this longitude | + +### Reverse params + +| Param | Required | Description | +|-------|----------|-------------| +| `lat` | yes | Latitude | +| `lon` | yes | Longitude | +| `lang` | no | Language (default `de`) | + +### Response format + +```json +{ + "results": [ + { + "label": "Münster Café, Münsterplatz 3, 78462 Konstanz", + "name": "Münster Café", + "latitude": 47.663, + "longitude": 9.175, + "address": { + "street": "Münsterplatz", + "houseNumber": "3", + "postalCode": "78462", + "city": "Konstanz", + "country": "Germany" + }, + "category": "food", + "osmCategory": "amenity", + "osmType": "cafe", + "confidence": 0.95 + } + ] +} +``` + +## Category Mapping + +The service maps OSM tags to our 7 PlaceCategories: + +| PlaceCategory | OSM examples | +|---------------|-------------| +| `home` | building:residential, building:house, building:apartments | +| `work` | amenity:school, amenity:university, office:*, building:commercial | +| `food` | amenity:restaurant, amenity:cafe, shop:bakery, shop:supermarket | +| `shopping` | shop:*, amenity:marketplace | +| `transit` | railway:station, highway:bus_stop, amenity:parking, aeroway:* | +| `leisure` | tourism:*, leisure:park, amenity:cinema, sport:* | +| `other` | Everything else | + +## Architecture + +``` +Client (Places module) + → mana-geocoding (Hono, port 3018) + → LRU cache check + → Pelias API (port 4000) + → Elasticsearch (port 9200) +``` + +## Configuration + +```env +PORT=3018 +PELIAS_API_URL=http://localhost:4000/v1 +CORS_ORIGINS=http://localhost:5173,https://mana.how +CACHE_MAX_ENTRIES=5000 +CACHE_TTL_MS=86400000 +``` + +## Pelias Infrastructure + +The Pelias stack runs as a separate docker-compose in `pelias/`: + +- **elasticsearch** — Index storage (~500MB for DACH) +- **api** — HTTP API (port 4000) +- **libpostal** — Address parsing (port 4400) +- **Import containers** — Run once for initial data load, then stop + +RAM usage (running): ~1.5GB (elasticsearch 512MB + api + libpostal) + +## Code Layout + +``` +src/ +├── index.ts # Bootstrap +├── app.ts # Hono app factory +├── config.ts # Environment config +├── routes/ +│ ├── geocode.ts # Forward + reverse endpoints with caching +│ └── health.ts +└── lib/ + ├── cache.ts # LRU cache with TTL + └── category-map.ts # OSM → PlaceCategory mapping +pelias/ +├── docker-compose.yml # Pelias stack +├── pelias.json # Pelias config (DACH region) +└── setup.sh # Initial data import script +``` diff --git a/services/mana-geocoding/package.json b/services/mana-geocoding/package.json new file mode 100644 index 000000000..5bc1b05c5 --- /dev/null +++ b/services/mana-geocoding/package.json @@ -0,0 +1,17 @@ +{ + "name": "@mana/geocoding", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun run --watch src/index.ts", + "start": "bun run src/index.ts", + "test": "bun test" + }, + "dependencies": { + "hono": "^4.7.0" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/services/mana-geocoding/pelias/docker-compose.yml b/services/mana-geocoding/pelias/docker-compose.yml new file mode 100644 index 000000000..62554033e --- /dev/null +++ b/services/mana-geocoding/pelias/docker-compose.yml @@ -0,0 +1,107 @@ +# Pelias geocoding stack for mana-geocoding. +# +# Data pipeline: download → prepare → import → serve. +# See pelias/README.md for initial setup instructions. +# +# After import, only `api` and `libpostal` need to stay running. +# The import containers (placeholder, interpolation, pip, elasticsearch) +# run during import and can be stopped afterward if RAM is tight, +# but elasticsearch must stay up for queries. + +services: + # --- Always running --- + + api: + image: pelias/api:latest + container_name: pelias-api + restart: unless-stopped + ports: + - "4000:4000" + environment: + PORT: 4000 + volumes: + - ./pelias.json:/code/pelias.json:ro + depends_on: + elasticsearch: + condition: service_healthy + networks: + - pelias + + libpostal: + image: pelias/libpostal-service + container_name: pelias-libpostal + restart: unless-stopped + ports: + - "4400:4400" + networks: + - pelias + + elasticsearch: + image: pelias/elasticsearch:7.17.1 + container_name: pelias-elasticsearch + restart: unless-stopped + ports: + - "9200:9200" + volumes: + - pelias-elasticsearch:/usr/share/elasticsearch/data + environment: + ES_JAVA_OPTS: "-Xms512m -Xmx512m" + ulimits: + memlock: + soft: -1 + hard: -1 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9200/_cluster/health"] + interval: 10s + timeout: 5s + retries: 30 + networks: + - pelias + + # --- Import pipeline (run once, then stop) --- + + schema: + image: pelias/schema:latest + container_name: pelias-schema + volumes: + - ./pelias.json:/code/pelias.json:ro + depends_on: + elasticsearch: + condition: service_healthy + networks: + - pelias + profiles: ["import"] + + openstreetmap: + image: pelias/openstreetmap:latest + container_name: pelias-openstreetmap + volumes: + - ./pelias.json:/code/pelias.json:ro + - pelias-data:/data + depends_on: + elasticsearch: + condition: service_healthy + networks: + - pelias + profiles: ["import"] + + polylines: + image: pelias/polylines:latest + container_name: pelias-polylines + volumes: + - ./pelias.json:/code/pelias.json:ro + - pelias-data:/data + depends_on: + elasticsearch: + condition: service_healthy + networks: + - pelias + profiles: ["import"] + +volumes: + pelias-elasticsearch: + pelias-data: + +networks: + pelias: + driver: bridge diff --git a/services/mana-geocoding/pelias/pelias.json b/services/mana-geocoding/pelias/pelias.json new file mode 100644 index 000000000..e760fcf40 --- /dev/null +++ b/services/mana-geocoding/pelias/pelias.json @@ -0,0 +1,39 @@ +{ + "esclient": { + "apiVersion": "7.x", + "hosts": [ + { + "host": "elasticsearch", + "port": 9200 + } + ] + }, + "api": { + "services": { + "libpostal": "http://libpostal:4400" + }, + "defaultParameters": { + "boundary.country": ["DEU", "AUT", "CHE"] + } + }, + "imports": { + "openstreetmap": { + "download": [ + { + "sourceURL": "https://download.geofabrik.de/europe/dach-latest.osm.pbf" + } + ], + "datapath": "/data/openstreetmap", + "leveldbpath": "/tmp/leveldb", + "importVenues": true, + "importAddresses": true + }, + "polylines": { + "datapath": "/data/polylines", + "files": ["extract.0sv"] + } + }, + "logger": { + "level": "info" + } +} diff --git a/services/mana-geocoding/pelias/setup.sh b/services/mana-geocoding/pelias/setup.sh new file mode 100755 index 000000000..68128f09f --- /dev/null +++ b/services/mana-geocoding/pelias/setup.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Initial Pelias data import for DACH region. +# +# Run this ONCE after first docker compose up. +# Takes 30-60 minutes depending on hardware. +# +# After import, the "import" profile containers can be stopped. + +set -euo pipefail +cd "$(dirname "$0")" + +echo "=== Step 1: Create Elasticsearch schema ===" +docker compose --profile import run --rm schema ./bin/create_index + +echo "=== Step 2: Download DACH OSM data ===" +mkdir -p data/openstreetmap +docker compose --profile import run --rm openstreetmap ./bin/download + +echo "=== Step 3: Import OpenStreetMap data ===" +docker compose --profile import run --rm openstreetmap ./bin/start + +echo "=== Step 4: Import polylines (street data) ===" +docker compose --profile import run --rm polylines ./bin/download +docker compose --profile import run --rm polylines ./bin/start + +echo "" +echo "=== Import complete! ===" +echo "Pelias API is available at http://localhost:4000/v1" +echo "" +echo "Test it:" +echo " curl 'http://localhost:4000/v1/search?text=Münsterplatz+Konstanz'" +echo " curl 'http://localhost:4000/v1/reverse?point.lat=47.663&point.lon=9.175'" +echo "" +echo "You can now stop the import containers:" +echo " docker compose --profile import stop" diff --git a/services/mana-geocoding/src/app.ts b/services/mana-geocoding/src/app.ts new file mode 100644 index 000000000..27e69ea7b --- /dev/null +++ b/services/mana-geocoding/src/app.ts @@ -0,0 +1,32 @@ +/** + * App factory — separated from index.ts so tests can import without + * triggering the production bootstrap. + */ + +import { Hono } from 'hono'; +import { cors } from 'hono/cors'; +import type { Config } from './config'; +import { healthRoutes } from './routes/health'; +import { createGeocodeRoutes } from './routes/geocode'; + +export function createApp(config: Config): Hono { + const app = new Hono(); + + app.onError((err, c) => { + console.error('Unhandled error:', err); + return c.json({ error: 'internal_error' }, 500); + }); + + app.use( + '*', + cors({ + origin: config.cors.origins, + credentials: true, + }) + ); + + app.route('/health', healthRoutes); + app.route('/api/v1/geocode', createGeocodeRoutes(config)); + + return app; +} diff --git a/services/mana-geocoding/src/config.ts b/services/mana-geocoding/src/config.ts new file mode 100644 index 000000000..bbfec13b1 --- /dev/null +++ b/services/mana-geocoding/src/config.ts @@ -0,0 +1,36 @@ +/** + * Application configuration loaded from environment variables. + */ + +export interface Config { + port: number; + pelias: { + /** Pelias API base URL (the API container, not the placeholder service) */ + apiUrl: string; + }; + cors: { + origins: string[]; + }; + cache: { + /** Max entries in the in-memory LRU cache */ + maxEntries: number; + /** TTL in milliseconds (default: 24h — geocoding results rarely change) */ + ttlMs: number; + }; +} + +export function loadConfig(): Config { + return { + port: parseInt(process.env.PORT || '3018', 10), + pelias: { + apiUrl: process.env.PELIAS_API_URL || 'http://localhost:4000/v1', + }, + cors: { + origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','), + }, + cache: { + maxEntries: parseInt(process.env.CACHE_MAX_ENTRIES || '5000', 10), + ttlMs: parseInt(process.env.CACHE_TTL_MS || String(24 * 60 * 60 * 1000), 10), + }, + }; +} diff --git a/services/mana-geocoding/src/index.ts b/services/mana-geocoding/src/index.ts new file mode 100644 index 000000000..905ce3285 --- /dev/null +++ b/services/mana-geocoding/src/index.ts @@ -0,0 +1,20 @@ +/** + * mana-geocoding — Self-hosted geocoding proxy. + * + * Wraps a local Pelias instance with caching and OSM → PlaceCategory + * mapping. All geocoding queries stay within our infrastructure — + * no user location data leaves the network. + */ + +import { createApp } from './app'; +import { loadConfig } from './config'; + +const config = loadConfig(); + +console.log(`mana-geocoding starting on port ${config.port}...`); +console.log(`Pelias API: ${config.pelias.apiUrl}`); + +export default { + port: config.port, + fetch: createApp(config).fetch, +}; diff --git a/services/mana-geocoding/src/lib/cache.ts b/services/mana-geocoding/src/lib/cache.ts new file mode 100644 index 000000000..c8f9b9a46 --- /dev/null +++ b/services/mana-geocoding/src/lib/cache.ts @@ -0,0 +1,56 @@ +/** + * Simple in-memory LRU cache with TTL for geocoding results. + * Geocoding results rarely change, so we cache aggressively to + * reduce load on the Pelias instance. + */ + +interface CacheEntry { + value: T; + expiresAt: number; +} + +export class LRUCache { + private map = new Map>(); + private maxEntries: number; + private ttlMs: number; + + constructor(maxEntries: number, ttlMs: number) { + this.maxEntries = maxEntries; + this.ttlMs = ttlMs; + } + + get(key: string): T | undefined { + const entry = this.map.get(key); + if (!entry) return undefined; + + if (Date.now() > entry.expiresAt) { + this.map.delete(key); + return undefined; + } + + // Move to end (most recently used) + this.map.delete(key); + this.map.set(key, entry); + return entry.value; + } + + set(key: string, value: T): void { + // Delete first so re-insert goes to end + this.map.delete(key); + + // Evict oldest if at capacity + if (this.map.size >= this.maxEntries) { + const oldest = this.map.keys().next().value; + if (oldest !== undefined) this.map.delete(oldest); + } + + this.map.set(key, { + value, + expiresAt: Date.now() + this.ttlMs, + }); + } + + get size(): number { + return this.map.size; + } +} diff --git a/services/mana-geocoding/src/lib/category-map.ts b/services/mana-geocoding/src/lib/category-map.ts new file mode 100644 index 000000000..156d81aaa --- /dev/null +++ b/services/mana-geocoding/src/lib/category-map.ts @@ -0,0 +1,170 @@ +/** + * Maps Pelias/OSM categories to our 7 Places categories. + * + * Pelias returns results with `addendum.osm.category` and `addendum.osm.type` + * fields that correspond to OSM key/value pairs. We map these to our simple + * category enum: home, work, food, shopping, transit, leisure, other. + */ + +export type PlaceCategory = 'home' | 'work' | 'food' | 'shopping' | 'transit' | 'leisure' | 'other'; + +/** + * OSM key → PlaceCategory mapping. + * The key is the OSM tag key (e.g. "amenity", "shop"), the value maps + * specific OSM values to our categories. A `_default` entry covers + * any value not explicitly listed. + */ +const OSM_CATEGORY_MAP: Record< + string, + Record & { _default?: PlaceCategory } +> = { + amenity: { + _default: 'other', + restaurant: 'food', + cafe: 'food', + fast_food: 'food', + bar: 'food', + pub: 'food', + biergarten: 'food', + food_court: 'food', + ice_cream: 'food', + bakery: 'food', + school: 'work', + university: 'work', + college: 'work', + library: 'work', + coworking_space: 'work', + office: 'work', + bus_station: 'transit', + ferry_terminal: 'transit', + taxi: 'transit', + parking: 'transit', + fuel: 'transit', + bicycle_parking: 'transit', + charging_station: 'transit', + cinema: 'leisure', + theatre: 'leisure', + nightclub: 'leisure', + community_centre: 'leisure', + swimming_pool: 'leisure', + marketplace: 'shopping', + }, + shop: { + _default: 'shopping', + supermarket: 'shopping', + bakery: 'food', + butcher: 'food', + deli: 'food', + greengrocer: 'food', + seafood: 'food', + pastry: 'food', + cheese: 'food', + coffee: 'food', + }, + tourism: { + _default: 'leisure', + hotel: 'other', + hostel: 'other', + guest_house: 'other', + motel: 'other', + apartment: 'home', + }, + leisure: { + _default: 'leisure', + park: 'leisure', + playground: 'leisure', + sports_centre: 'leisure', + fitness_centre: 'leisure', + stadium: 'leisure', + swimming_pool: 'leisure', + garden: 'leisure', + nature_reserve: 'leisure', + beach_resort: 'leisure', + marina: 'leisure', + }, + railway: { + _default: 'transit', + station: 'transit', + halt: 'transit', + tram_stop: 'transit', + }, + aeroway: { + _default: 'transit', + aerodrome: 'transit', + terminal: 'transit', + }, + highway: { + _default: 'transit', + bus_stop: 'transit', + }, + building: { + _default: 'other', + residential: 'home', + house: 'home', + apartments: 'home', + detached: 'home', + commercial: 'work', + office: 'work', + industrial: 'work', + retail: 'shopping', + supermarket: 'shopping', + church: 'leisure', + cathedral: 'leisure', + stadium: 'leisure', + school: 'work', + university: 'work', + hospital: 'other', + }, + office: { + _default: 'work', + }, + sport: { + _default: 'leisure', + }, +}; + +/** + * Derive a PlaceCategory from a Pelias result's OSM metadata. + * + * Pelias provides category info in several fields depending on the data source. + * We check them in order of specificity. + */ +export function mapOsmToPlaceCategory( + osmCategory?: string, + osmType?: string, + peliasLayer?: string +): PlaceCategory { + // Try direct OSM key/value mapping first + if (osmCategory && osmType) { + const categoryMap = OSM_CATEGORY_MAP[osmCategory]; + if (categoryMap) { + return categoryMap[osmType] ?? categoryMap._default ?? 'other'; + } + } + + // Try just the OSM key as a category + if (osmCategory) { + const categoryMap = OSM_CATEGORY_MAP[osmCategory]; + if (categoryMap?._default) { + return categoryMap._default; + } + } + + // Fallback: use Pelias layer as a hint + if (peliasLayer) { + switch (peliasLayer) { + case 'venue': + return 'other'; + case 'address': + case 'street': + return 'other'; + case 'neighbourhood': + case 'locality': + case 'region': + case 'country': + return 'other'; + } + } + + return 'other'; +} diff --git a/services/mana-geocoding/src/routes/geocode.ts b/services/mana-geocoding/src/routes/geocode.ts new file mode 100644 index 000000000..56dd13d17 --- /dev/null +++ b/services/mana-geocoding/src/routes/geocode.ts @@ -0,0 +1,210 @@ +/** + * Geocoding routes — thin proxy to Pelias with caching and + * OSM category mapping. + * + * Endpoints: + * GET /api/v1/geocode/search?q=...&limit=5 — forward (autocomplete) + * GET /api/v1/geocode/reverse?lat=...&lon=... — reverse + */ + +import { Hono } from 'hono'; +import type { Config } from '../config'; +import { LRUCache } from '../lib/cache'; +import { mapOsmToPlaceCategory, type PlaceCategory } from '../lib/category-map'; + +/** Normalized result returned to the client */ +export interface GeocodingResult { + /** Display name (e.g. "Münster Café, Münsterplatz 3, Konstanz") */ + label: string; + /** Short name (e.g. "Münster Café") */ + name: string; + latitude: number; + longitude: number; + /** Structured address components */ + address: { + street?: string; + houseNumber?: string; + postalCode?: string; + city?: string; + state?: string; + country?: string; + }; + /** Our Places category, derived from OSM tags */ + category: PlaceCategory; + /** Raw OSM category key (e.g. "amenity") */ + osmCategory?: string; + /** Raw OSM type value (e.g. "restaurant") */ + osmType?: string; + /** Pelias confidence score 0-1 */ + confidence: number; +} + +export function createGeocodeRoutes(config: Config) { + const app = new Hono(); + const searchCache = new LRUCache(config.cache.maxEntries, config.cache.ttlMs); + const reverseCache = new LRUCache(config.cache.maxEntries, config.cache.ttlMs); + + /** + * Forward geocoding / autocomplete + * GET /search?q=Münsterplatz+Konstanz&limit=5&lang=de + */ + app.get('/search', async (c) => { + const q = c.req.query('q'); + if (!q || q.trim().length < 2) { + return c.json({ results: [] }); + } + + const limit = Math.min(parseInt(c.req.query('limit') || '5', 10), 20); + const lang = c.req.query('lang') || 'de'; + const focusLat = c.req.query('focus.lat'); + const focusLon = c.req.query('focus.lon'); + + const cacheKey = `${q}|${limit}|${lang}|${focusLat}|${focusLon}`; + const cached = searchCache.get(cacheKey); + if (cached) { + return c.json({ results: cached, cached: true }); + } + + const params = new URLSearchParams({ + text: q.trim(), + size: String(limit), + lang, + 'boundary.country': 'DEU,AUT,CHE', + }); + + // Bias results towards a focus point (user's current location) + if (focusLat && focusLon) { + params.set('focus.point.lat', focusLat); + params.set('focus.point.lon', focusLon); + } + + const response = await fetch(`${config.pelias.apiUrl}/autocomplete?${params}`); + if (!response.ok) { + console.error(`Pelias autocomplete error: ${response.status} ${response.statusText}`); + return c.json({ results: [], error: 'geocoding_unavailable' }, 502); + } + + const data = (await response.json()) as PeliasResponse; + const results = data.features.map(normalizePeliasFeature); + + searchCache.set(cacheKey, results); + return c.json({ results }); + }); + + /** + * Reverse geocoding + * GET /reverse?lat=47.663&lon=9.175&lang=de + */ + app.get('/reverse', async (c) => { + const lat = c.req.query('lat'); + const lon = c.req.query('lon'); + if (!lat || !lon) { + return c.json({ error: 'lat and lon are required' }, 400); + } + + const lang = c.req.query('lang') || 'de'; + + // Round to 5 decimal places (~1m precision) for cache hits + const roundedLat = parseFloat(lat).toFixed(5); + const roundedLon = parseFloat(lon).toFixed(5); + const cacheKey = `${roundedLat}|${roundedLon}|${lang}`; + + const cached = reverseCache.get(cacheKey); + if (cached) { + return c.json({ results: cached, cached: true }); + } + + const params = new URLSearchParams({ + 'point.lat': roundedLat, + 'point.lon': roundedLon, + size: '3', + lang, + }); + + const response = await fetch(`${config.pelias.apiUrl}/reverse?${params}`); + if (!response.ok) { + console.error(`Pelias reverse error: ${response.status} ${response.statusText}`); + return c.json({ results: [], error: 'geocoding_unavailable' }, 502); + } + + const data = (await response.json()) as PeliasResponse; + const results = data.features.map(normalizePeliasFeature); + + reverseCache.set(cacheKey, results); + return c.json({ results }); + }); + + /** + * Cache stats (for monitoring) + * GET /stats + */ + app.get('/stats', (c) => { + return c.json({ + searchCacheSize: searchCache.size, + reverseCacheSize: reverseCache.size, + }); + }); + + return app; +} + +// --- Pelias response types --- + +interface PeliasResponse { + type: 'FeatureCollection'; + features: PeliasFeature[]; +} + +interface PeliasFeature { + type: 'Feature'; + geometry: { + type: 'Point'; + coordinates: [number, number]; // [lon, lat] + }; + properties: { + id?: string; + name?: string; + label?: string; + confidence?: number; + layer?: string; + street?: string; + housenumber?: string; + postalcode?: string; + locality?: string; + region?: string; + country?: string; + addendum?: { + osm?: { + category?: string; + type?: string; + }; + }; + }; +} + +function normalizePeliasFeature(feature: PeliasFeature): GeocodingResult { + const props = feature.properties; + const [lon, lat] = feature.geometry.coordinates; + + const osmCategory = props.addendum?.osm?.category; + const osmType = props.addendum?.osm?.type; + + return { + label: props.label || props.name || '', + name: props.name || '', + latitude: lat, + longitude: lon, + address: { + street: props.street, + houseNumber: props.housenumber, + postalCode: props.postalcode, + city: props.locality, + state: props.region, + country: props.country, + }, + category: mapOsmToPlaceCategory(osmCategory, osmType, props.layer), + osmCategory, + osmType, + confidence: props.confidence ?? 0, + }; +} diff --git a/services/mana-geocoding/src/routes/health.ts b/services/mana-geocoding/src/routes/health.ts new file mode 100644 index 000000000..c43276116 --- /dev/null +++ b/services/mana-geocoding/src/routes/health.ts @@ -0,0 +1,5 @@ +import { Hono } from 'hono'; + +export const healthRoutes = new Hono(); + +healthRoutes.get('/', (c) => c.json({ status: 'ok', service: 'mana-geocoding' })); diff --git a/services/mana-geocoding/tsconfig.json b/services/mana-geocoding/tsconfig.json new file mode 100644 index 000000000..6bec8c769 --- /dev/null +++ b/services/mana-geocoding/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "types": ["bun-types"] + }, + "include": ["src"] +}