diff --git a/src/app/api/gis/parcela/find/route.ts b/src/app/api/gis/parcela/find/route.ts index 72929a8..b7248bc 100644 --- a/src/app/api/gis/parcela/find/route.ts +++ b/src/app/api/gis/parcela/find/route.ts @@ -5,21 +5,99 @@ import { gisApi, GisApiError } from "@/lib/gis-api-client"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -// Resolve the canonical GisFeature for a click that arrives without a uuid -// (PMTiles overview tiles only carry siruta / cadastral_ref / object_id). +// Resolve the canonical GisFeature for a click without a uuid. // -// gis-api `/api/v1/search` returns up to `limit` features whose -// `cadastralRef` contains the query string AND each feature already -// carries its `siruta`. We use that to filter directly client-side — -// one network round-trip instead of N parcela.get calls. +// Fallback chain (tried in order): +// 1. uuid path — handled in panel directly (parcela.get(id)). Never reaches this route. +// 2. /api/v1/parcela/by-ref — single indexed query on (siruta, cadref, layerId). +// Fast and exact. 404 here means "endpoint not deployed yet" or "row absent" — +// either way we fall through. +// 3. /api/v1/search + filter by siruta from the search response. Capped at 50 +// features per gis-api; surfaces 422 search_limit_exceeded when the target +// siruta is past the cap (common cadrefs like "61745" = 232 features). // -// Caveat: gis-api's search hard-caps `limit` at 50. For "common" cadrefs -// (e.g. "61745" — 232 features across all UATs) the right siruta can -// drop out of the top-50. When that happens we fall back to a second -// search variant ("" prefix-anchored) and as a last resort we -// hit parcela.get on the closest layerId match. That still beats the -// previous N-roundtrip path that was timing out silently. -const MAX_LIMIT = 50; +// Forbidden (403) at any step is surfaced as-is so the panel's SessionErrorWatcher +// can trigger a silent re-grant. + +const MAX_SEARCH_LIMIT = 50; + +async function tryByRef( + siruta: string, + cad: string, + layerId: string, +): Promise { + try { + const data = await gisApi.parcela.byRef({ + siruta, + cadastralRef: cad, + layerId, + }); + console.log( + "[gis-parcela-find] by-ref hit cad=%s siruta=%s", + cad, + siruta, + ); + return data; + } catch (err) { + if (err instanceof GisApiError) { + if (err.status === 404) return null; // not deployed yet OR no match — fall back + throw err; // propagate 403 / 5xx + } + throw err; + } +} + +async function trySearch( + siruta: string, + cad: string, + layerId: string, +): Promise< + { hit: unknown } + | { miss: "not_found" } + | { miss: "search_limit_exceeded"; sameRefCount: number } +> { + const sr = await gisApi.search(cad, MAX_SEARCH_LIMIT); + const all = sr.features ?? []; + + // search returns siruta per feature → direct in-memory match + const direct = all.find( + (f) => + f.cadastralRef === cad && + f.layerId === layerId && + String(f.siruta ?? "") === siruta, + ); + if (direct) { + console.log( + "[gis-parcela-find] search hit cad=%s siruta=%s id=%s", + cad, + siruta, + direct.id.slice(0, 8), + ); + try { + return { hit: await gisApi.parcela.get(direct.id) }; + } catch (err) { + if (err instanceof GisApiError && err.status === 403) { + throw err; + } + throw err; + } + } + + const sameRef = all.filter( + (f) => f.cadastralRef === cad && f.layerId === layerId, + ); + console.log( + "[gis-parcela-find] search miss cad=%s siruta=%s same_ref=%d total=%d", + cad, + siruta, + sameRef.length, + all.length, + ); + if (sameRef.length >= MAX_SEARCH_LIMIT) { + return { miss: "search_limit_exceeded", sameRefCount: sameRef.length }; + } + return { miss: "not_found" }; +} export async function GET(request: Request) { const session = await getAuthSession(); @@ -40,66 +118,24 @@ export async function GET(request: Request) { } try { - const sr = await gisApi.search(cad, MAX_LIMIT); - const all = sr.features ?? []; + // Step 1: indexed by-ref lookup + const byRef = await tryByRef(siruta, cad, layerId); + if (byRef) return NextResponse.json(byRef); - // First pass: exact cadref + layerId + siruta match. Most common case - // — single round-trip resolution. - const direct = all.find( - (f) => - f.cadastralRef === cad && - f.layerId === layerId && - String(f.siruta ?? "") === siruta, - ); - if (direct) { - console.log( - "[gis-parcela-find] direct_hit cad=%s siruta=%s id=%s", - cad, - siruta, - direct.id.slice(0, 8), - ); - try { - const detail = await gisApi.parcela.get(direct.id); - return NextResponse.json(detail); - } catch (e) { - if (e instanceof GisApiError && e.status === 403) { - return NextResponse.json( - { error: "scope_insufficient", hint: "Token lacks enrichment_scope" }, - { status: 403 }, - ); - } - throw e; - } - } - - // Second pass: same cadref + layerId without siruta match. Surfaces - // the common "too many features with this cadref, our siruta wasn't - // in the top 50" failure mode explicitly. - const sameRef = all.filter( - (f) => f.cadastralRef === cad && f.layerId === layerId, - ); - console.log( - "[gis-parcela-find] cad=%s siruta=%s direct=miss same_ref_count=%d total=%d", - cad, - siruta, - sameRef.length, - all.length, - ); - - // If gis-api's limit is the bottleneck (top-50 didn't include our - // siruta), report it distinctively so the client can react. Otherwise - // 404 — the parcel really isn't in gis_core yet. - if (sameRef.length >= MAX_LIMIT) { + // Step 2: search fallback (works until gis-api search-limit becomes the + // bottleneck for very common cadrefs) + const result = await trySearch(siruta, cad, layerId); + if ("hit" in result) return NextResponse.json(result.hit); + if (result.miss === "search_limit_exceeded") { return NextResponse.json( { error: "search_limit_exceeded", - hint: `gis-api search returned ${MAX_LIMIT} features all with cadref=${cad} — the target siruta=${siruta} is past the cap. Needs a siruta-filtered search endpoint on gis-api.`, - sameRefCount: sameRef.length, + hint: `gis-api search returned ${MAX_SEARCH_LIMIT} features all with cadref=${cad} — target siruta=${siruta} past the cap. by-ref endpoint not yet deployed on gis-api.`, + sameRefCount: result.sameRefCount, }, { status: 422 }, ); } - return NextResponse.json({ error: "not_found" }, { status: 404 }); } catch (err) { if (err instanceof GisApiError) { diff --git a/src/lib/gis-api-client.ts b/src/lib/gis-api-client.ts index 89360fd..c630f6c 100644 --- a/src/lib/gis-api-client.ts +++ b/src/lib/gis-api-client.ts @@ -245,6 +245,22 @@ export const gisApi = { request(`/api/v1/parcela/${encodeURIComponent(id)}`, { accessToken: opts.accessToken, }), + // GET /api/v1/parcela/by-ref?siruta&cad&layerId — indexed lookup that + // skips the cadref-trigram-then-filter dance. Use when a click arrives + // without a uuid in the tile properties (PMTiles overview today). Same + // response shape as parcela.get; 404 when no match; 403 on scope=none. + byRef: ( + body: { siruta: string; cadastralRef: string; layerId: string }, + opts: GisApiCallOpts = {}, + ) => + request("/api/v1/parcela/by-ref", { + query: { + siruta: body.siruta, + cad: body.cadastralRef, + layerId: body.layerId, + }, + accessToken: opts.accessToken, + }), }, search: (q: string, limit = 50, opts: GisApiCallOpts = {}) =>