import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { forward, GeocodeError, isConfigured, MAX_SUGGESTIONS, reverse, type GeocodeResult, } from '@linkder/geocode'; import { latLngSchema } from '@linkder/shared'; import { protectedProcedure, router } from '../trpc'; /** * Turning what someone typed into a point we can match on. * * `protectedProcedure`, not public: every address surface in the product is * already behind sign-in, and unlike `pro.search` this one costs money per * keystroke. An anonymous caller with a loop would be spending our Mapbox * budget, so the session is the first cost bound and the throttle below is the * second. */ /** * A per-process throttle, same shape as the one in `message.ts` and with the * same caveat: it resets on deploy and does not span instances. It is a spend * ceiling on a runaway client, not a rate limiter — the real one arrives with * the shared Redis in M4. * * Sized for typing rather than for sending: the client debounces at 250ms, so a * person filling in one address costs a handful of calls and this only bites a * loop. */ const WINDOW_MS = 60_000; const LIMIT = 60; const recent = new Map(); function assertRate(userId: string): void { const now = Date.now(); const window = (recent.get(userId) ?? []).filter((at) => now - at < WINDOW_MS); if (window.length >= LIMIT) { throw new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'Too many lookups. Pause a moment.' }); } window.push(now); recent.set(userId, window); } /** * A geocoder that is down, or not configured, must not take a form down with it. * * Callers get an empty list and the UI says "we could not look that up" — the * user can still submit, and the point lands as `city` precision, which is * exactly what the flag is for. The alternative, a 500 out of an address field, * would block posting a job because a third party had a bad minute. */ async function tolerant(work: () => Promise): Promise { if (!isConfigured()) return []; try { return await work(); } catch (error) { if (error instanceof GeocodeError) return []; throw error; } } export const geocodeRouter = router({ /** Address text → candidates, for the address field's suggestion list. */ suggest: protectedProcedure .input( z.object({ q: z.string().trim().min(1).max(200), /** Bias toward here — the city centre, or a pin the user already has. */ proximity: latLngSchema.optional(), limit: z.number().int().min(1).max(MAX_SUGGESTIONS).optional(), }), ) .query(async ({ ctx, input }) => { assertRate(ctx.session.userId); const results = await tolerant(() => forward(input)); return { results, configured: isConfigured() }; }), /** * Coordinates → the nearest address. * * What makes "Use my current location" honest: the button used to set a point * with no label, so the form had silently decided where you live and shown you * nothing about it. */ reverse: protectedProcedure.input(latLngSchema).mutation(async ({ ctx, input }) => { assertRate(ctx.session.userId); if (!isConfigured()) return { result: null, configured: false }; try { return { result: await reverse(input), configured: true }; } catch (error) { if (error instanceof GeocodeError) return { result: null, configured: true }; throw error; } }), });