From 100896a564d1ad9543174ef7934359f8fc0f2bbe Mon Sep 17 00:00:00 2001 From: Claude VM Date: Wed, 20 May 2026 15:10:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(geoportal-v2):=20find=20proxy=20fallback?= =?UTF-8?q?=20chain=20=E2=80=94=20by-ref=20=E2=86=92=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Marius's greenlight + gis-api shipping POST? GET /api/v1/parcela/by-ref imminent. src/lib/gis-api-client.ts: Added gisApi.parcela.byRef({siruta, cadastralRef, layerId}) thin wrapper. Same return shape as parcela.get; gis-api will 404 when no match and 403 on scope=none. src/app/api/gis/parcela/find/route.ts: Chain rewrite. Three named helpers — tryByRef + trySearch — keep the main handler short and the fallback semantics obvious: 1. tryByRef(siruta, cad, layerId) 200 → return canonical record (instant — single indexed query on gis_core) 404 → endpoint not deployed yet OR row genuinely absent. Fall through. 403 / 5xx → propagate. 2. trySearch(siruta, cad, layerId) The previous logic, moved verbatim. Uses search's response siruta field for in-memory filter (no N+1 parcela.get). Still capped at gis-api's max 50; returns search_limit_exceeded when the target siruta falls past it. 3. 404 not_found — both layers exhausted. When gis-api's by-ref is live, common-cadref cases (61745 / 232 features) resolve in one round-trip. Before then, by-ref returns 404 and we fall through to search — same behaviour as before for the non-bottleneck cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/gis/parcela/find/route.ts | 166 ++++++++++++++++---------- src/lib/gis-api-client.ts | 16 +++ 2 files changed, 117 insertions(+), 65 deletions(-) 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 = {}) =>