/** * Resolve the state/province for a destination address via public geo services. * Used by the commissioning address wizard (Magic Fix) to PROPOSE a state when * the address has none at all — e.g. US shipments, where DHL requires * consignee.state but the order data often carries no region. * * Query: country (ISO2, default US), postal, city (fallback when no postal). * Returns { status, stateCode, stateName, city, source } — status 404 when * nothing could be resolved. Always fail-soft: never throws to the client. * * 1) zippopotam.us — deterministic postal-code → state, no API key * 2) Nominatim (OpenStreetMap) — structured search fallback, also city-only */ // ZIP→state never changes: cache resolved lookups in-process. const lookupCache = new Map() const handleFunc = async (event: any) => { const query = getQuery(event) const country = String(query.country || 'US').toUpperCase().slice(0, 2) const postalRaw = String(query.postal || '').trim().slice(0, 20) const city = String(query.city || '').trim().slice(0, 60) if (!postalRaw && !city) return { status: 404 } const cacheKey = `${country}|${postalRaw.toUpperCase()}|${postalRaw ? '' : city.toLowerCase()}` if (lookupCache.has(cacheKey)) return lookupCache.get(cacheKey) if (lookupCache.size > 2000) lookupCache.clear() let result: any = { status: 404 } // 1) zippopotam.us — postal code lookup (US wants the plain 5-digit ZIP) if (postalRaw) { const zip = country === 'US' ? ((postalRaw.match(/\d{5}/) || [])[0] || '') : postalRaw if (zip) { try { const res: any = await $fetch(`https://api.zippopotam.us/${country.toLowerCase()}/${encodeURIComponent(zip)}`, { timeout: 6000 }) const place = res?.places?.[0] if (place && (place['state abbreviation'] || place['state'])) { result = { status: 200, stateCode: String(place['state abbreviation'] || '').toUpperCase(), stateName: String(place['state'] || ''), city: String(place['place name'] || ''), source: 'zippopotam.us' } } } catch { // fall through to Nominatim } } } // 2) Nominatim fallback — postal code the primary DB missed, or city-only if (result.status !== 200) { try { const params: any = { format: 'jsonv2', addressdetails: 1, limit: 1, countrycodes: country.toLowerCase() } if (postalRaw) params.postalcode = postalRaw else params.city = city const res: any = await $fetch('https://nominatim.openstreetmap.org/search', { params, timeout: 6000, headers: { 'User-Agent': 'LogShip-ERP/1.0 (+https://app.logship.de)' } }) const addr = res?.[0]?.address if (addr?.state || addr?.['ISO3166-2-lvl4']) { // "ISO3166-2-lvl4": "US-NY" → "NY" const iso = String(addr['ISO3166-2-lvl4'] || '') const code = iso.toUpperCase().startsWith(country + '-') ? iso.slice(country.length + 1) : '' result = { status: 200, stateCode: String(code || '').toUpperCase(), stateName: String(addr.state || ''), city: String(addr.city || addr.town || addr.village || ''), source: 'openstreetmap.org' } } } catch { // stay 404 } } if (result.status === 200) lookupCache.set(cacheKey, result) return result } export default defineEventHandler(async (event) => { try { return await handleFunc(event) } catch { return { status: 404 } } })