diff --git a/src/app/api/gis/parcela/find/route.ts b/src/app/api/gis/parcela/find/route.ts index 27dfb80..72929a8 100644 --- a/src/app/api/gis/parcela/find/route.ts +++ b/src/app/api/gis/parcela/find/route.ts @@ -5,17 +5,22 @@ import { gisApi, GisApiError } from "@/lib/gis-api-client"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -// Lookup the canonical GisFeature for a click that arrives without a uuid +// Resolve the canonical GisFeature for a click that arrives without a uuid // (PMTiles overview tiles only carry siruta / cadastral_ref / object_id). // -// gis-api `/api/v1/search` indexes by cadastralRef trigram but does not -// return siruta per feature → two parcels in different UATs with the same -// cadref are indistinguishable from the search response alone. So we -// search, filter by layerId, then resolve each candidate via parcela.get -// and pick the one whose stored siruta matches the click. First match -// wins; if no candidate matches siruta we surface the closest hit (same -// cadref + layerId) so the panel still has something to render rather -// than a hard miss. +// 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. +// +// 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; + export async function GET(request: Request) { const session = await getAuthSession(); if (!session) { @@ -35,88 +40,66 @@ export async function GET(request: Request) { } try { - const sr = await gisApi.search(cad, 20); - const candidates = (sr.features ?? []).filter( - (f) => f.cadastralRef === cad && f.layerId === layerId, - ); - console.log( - "[gis-parcela-find] siruta=%s cad=%s layerId=%s candidates=%d", - siruta, - cad, - layerId, - candidates.length, - ); - if (candidates.length === 0) { - return NextResponse.json({ error: "not_found" }, { status: 404 }); - } + const sr = await gisApi.search(cad, MAX_LIMIT); + const all = sr.features ?? []; - let fallback: unknown = null; - let forbiddenCount = 0; - for (const c of candidates) { + // 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(c.id)) as { - siruta?: string; - [k: string]: unknown; - } | null; - if (!detail) continue; - if (typeof fallback !== "object" || fallback === null) fallback = detail; - if (String(detail.siruta ?? "") === siruta) { - const enr = (detail as { enrichment?: Record }).enrichment ?? null; - console.log( - "[gis-parcela-find] hit id=%s has_enrich=%s keys=%d", - c.id.slice(0, 8), - !!enr, - enr ? Object.keys(enr).length : 0, - ); - return NextResponse.json(detail); - } + const detail = await gisApi.parcela.get(direct.id); + return NextResponse.json(detail); } catch (e) { - // Skip candidates that 403 (scope) or fail individually, - // but track 403s so we can distinguish "no data" from - // "all data was scope-blocked" at the route boundary. if (e instanceof GisApiError && e.status === 403) { - forbiddenCount++; - continue; + return NextResponse.json( + { error: "scope_insufficient", hint: "Token lacks enrichment_scope" }, + { status: 403 }, + ); } throw e; } } - // No exact siruta match — return the best-effort candidate (first one - // we could read). Better than an empty panel; the user can verify - // via the Citește din ANCPI button if they think it's wrong. - if (fallback) { - console.log( - "[gis-parcela-find] no_siruta_match cad=%s — returning fallback", - cad, - ); - return NextResponse.json(fallback); - } + // 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, + ); - // No readable candidate AND every read returned 403 → user is - // authenticated but lacks the enrichment scope. Surface this - // explicitly so the panel can prompt re-login instead of showing - // an empty/404 state (the older behaviour was silent 404 — caller - // couldn't tell auth-loss from missing-data). - if (forbiddenCount > 0 && forbiddenCount === candidates.length) { - console.warn( - "[gis-parcela-find] all_candidates_forbidden siruta=%s cad=%s candidates=%d", - siruta, - cad, - candidates.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) { return NextResponse.json( { - error: "scope_insufficient", - hint: - "All candidates returned 403 from gis-api — token likely lacks enrichment_scope. Sign out and back in.", - candidates: candidates.length, + 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, }, - { status: 403 }, + { status: 422 }, ); } - console.log("[gis-parcela-find] no_match siruta=%s cad=%s", siruta, cad); 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 0063fb2..89360fd 100644 --- a/src/lib/gis-api-client.ts +++ b/src/lib/gis-api-client.ts @@ -247,13 +247,14 @@ export const gisApi = { }), }, - search: (q: string, limit = 20, opts: GisApiCallOpts = {}) => + search: (q: string, limit = 50, opts: GisApiCallOpts = {}) => request<{ q: string; uats: Array<{ siruta: string; name: string; county: string }>; features: Array<{ id: string; layerId: string; + siruta: string; cadastralRef: string; areaValue?: number; }>; diff --git a/src/modules/geoportal/v2/feature-info-panel.tsx b/src/modules/geoportal/v2/feature-info-panel.tsx index 6712284..db48d78 100644 --- a/src/modules/geoportal/v2/feature-info-panel.tsx +++ b/src/modules/geoportal/v2/feature-info-panel.tsx @@ -868,7 +868,9 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa ? "Parcela nu există în baza centrală gis_core." : error === "eterra_fetch_failed" ? "eTerra ANCPI nu răspunde momentan. Reîncearcă în 1-2 minute." - : `Eroare: ${error}`} + : error === "search_limit_exceeded" + ? "Numărul cadastral e foarte comun (sute de parcele). gis-api are limit 50 la căutare — aceasta nu apare. Trebuie un endpoint /parcela/by-ref dedicat." + : `Eroare: ${error}`} )}