import { forward, GeocodeError, isConfigured, type GeocodeResult } from '@linkder/geocode'; import type { LatLng, LocationInput, LocationPrecision } from '@linkder/shared'; /** * The one place a stored coordinate is decided. * * `job.create`, `pro.upsertProfile` and `user.updateLocation` all route through * here, because a point that means one thing on a job and another on a pro * profile makes `ST_Distance(p.base_location, j.location)` meaningless — and * that expression is how this product ranks every deck. * * The important property: for a picked suggestion the SERVER resolves the * coordinates. The client sends an id and a label, never a lat/lng. Before this, * `job.create` wrote `input.location` straight through, so a crafted payload * could put a job anywhere and an ordinary form could — and routinely did — put * it at the city centre while the row claimed to be an address. */ export interface ResolvedLocation { location: LatLng; /** What the geocoder called this point. Written to the row's address column. */ addressText: string; precision: LocationPrecision; placeId: string | null; } export interface CityCentre { lat: number; lng: number; name: string; } /** The fallback point, read once from env. Throws rather than guessing a city. */ export function cityCentre(): CityCentre { const lat = Number(process.env.NEXT_PUBLIC_CITY_LAT); const lng = Number(process.env.NEXT_PUBLIC_CITY_LNG); if (!Number.isFinite(lat) || !Number.isFinite(lng)) { // Same reasoning as deck.showcase: an unset city silently makes every pro // "out of radius", which looks like having no supply rather than a config // mistake. throw new Error('NEXT_PUBLIC_CITY_LAT / NEXT_PUBLIC_CITY_LNG are not set.'); } return { lat, lng, name: process.env.NEXT_PUBLIC_CITY_NAME ?? 'the city centre' }; } function centreFallback(label?: string): ResolvedLocation { const centre = cityCentre(); return { location: { lat: centre.lat, lng: centre.lng }, // Keep whatever the person typed. It is not a location, but it is a note to // themselves and to the pro who eventually turns up. addressText: label?.trim() || centre.name, precision: 'city', placeId: null, }; } /** * Turn what the caller reported into a point we are willing to store. * * Never throws for a geocoding failure. A provider outage must not stop someone * posting a job — it downgrades them to `city` precision, which every read * surface already knows how to treat as "we do not really know where this is". */ export async function resolveLocation(input: LocationInput): Promise { if (input.source === 'none') return centreFallback(input.label); if (input.source === 'device') { return { location: { lat: input.lat, lng: input.lng }, addressText: input.label?.trim() || 'Current location', // Never `exact`. A handset fix is metres out on a good day and a street // away on a bad one. precision: 'approximate', placeId: null, }; } if (!isConfigured()) return centreFallback(input.label); try { /* * Re-resolve from the label and keep the candidate whose id the caller * picked. * * Mapbox Geocoding v6 has no retrieve-by-id, so a forward call on the same * text is how the id gets turned back into a point. Costs one extra request * per SAVE — not per keystroke — which is the right place to spend it: the * alternative is trusting coordinates from the browser, and the whole reason * this file exists is that we did that and the data was wrong. */ const candidates = await forward({ q: input.label, limit: 10 }); const match = candidates.find((c: GeocodeResult) => c.providerId === input.placeId); if (!match) return centreFallback(input.label); return { location: match.coordinates, addressText: match.label, precision: match.precision, placeId: match.providerId, }; } catch (error) { if (error instanceof GeocodeError) return centreFallback(input.label); throw error; } }