From 7afba6e1a9b989dc321ca1eb7aca37067d904e87 Mon Sep 17 00:00:00 2001 From: Claude VM Date: Tue, 19 May 2026 15:26:49 +0300 Subject: [PATCH] fix(geoportal-v2): siruta-aware parcela lookup (B1 round 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous fix searched by cadastralRef and picked the first layerId-matching result. But cadastral refs collide across UATs: "354686" exists in multiple counties. The Cluj-Napoca f9bf2ca4-... parcel with full enrichment got passed over for a same-cad parcel in another UAT that has no enrichment → panel rendered header + "Caracteristici" with empty Intravilan, no "Date eTerra" section. New server-side /api/gis/parcela/find?siruta&cad&layerId proxy: - gisApi.search(cad) → filter by layerId → up to ~20 candidates - For each candidate, parcela.get and check stored siruta - Return the siruta-matching detail - Fallback: first readable candidate (so the panel still has data even if siruta mismatch — better than empty) Panel useEffect simplified: fast path = parcela.get by uuid when the tile has one, slow path = parcela/find when not. 404 from find sets the "not in central DB yet" empty state (user can hit Citește din ANCPI to trigger orchestrator live-fetch). Diagnostic logs: [gis-parcela-find] siruta=… cad=… layerId=… candidates=N + per-hit "has_enrich=true keys=N" so we can tell from container logs whether the right parcel resolved. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/gis/parcela/find/route.ts | 105 ++++++++++++++++++ .../geoportal/v2/feature-info-panel.tsx | 41 +++---- 2 files changed, 121 insertions(+), 25 deletions(-) create mode 100644 src/app/api/gis/parcela/find/route.ts diff --git a/src/app/api/gis/parcela/find/route.ts b/src/app/api/gis/parcela/find/route.ts new file mode 100644 index 0000000..f33657e --- /dev/null +++ b/src/app/api/gis/parcela/find/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +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 +// (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. +export async function GET(request: Request) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const siruta = (searchParams.get("siruta") ?? "").trim(); + const cad = (searchParams.get("cad") ?? "").trim(); + const layerId = (searchParams.get("layerId") ?? "TERENURI_ACTIVE").trim(); + + if (!siruta || !cad) { + return NextResponse.json( + { error: "missing_fields", required: ["siruta", "cad"] }, + { status: 400 }, + ); + } + + 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 }); + } + + let fallback: unknown = null; + for (const c of candidates) { + 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); + } + } catch (e) { + // Skip candidates that 403 (scope) or fail individually. + if (e instanceof GisApiError && e.status === 403) continue; + 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); + } + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } catch (err) { + if (err instanceof GisApiError) { + console.log("[gis-parcela-find] gis-api %d %s", err.status, err.code); + return NextResponse.json( + { error: err.code, status: err.status }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[gis-parcela-find] internal:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} diff --git a/src/modules/geoportal/v2/feature-info-panel.tsx b/src/modules/geoportal/v2/feature-info-panel.tsx index 51f2547..8ff2967 100644 --- a/src/modules/geoportal/v2/feature-info-panel.tsx +++ b/src/modules/geoportal/v2/feature-info-panel.tsx @@ -165,34 +165,25 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { const run = async () => { try { - let id = feature.id; - if (!id) { - const sr = await fetch( - `/api/gis/search?q=${encodeURIComponent(feature.cadastralRef)}&limit=20`, + let r: Response; + if (feature.id) { + // Fast path: tile carried the uuid. + r = await fetch(`/api/gis/parcela/${encodeURIComponent(feature.id)}`); + } else { + // PMTiles overview: no uuid → use the server-side lookup that + // resolves siruta+cadref+layerId across candidate matches. + r = await fetch( + `/api/gis/parcela/find?siruta=${encodeURIComponent(feature.siruta)}` + + `&cad=${encodeURIComponent(feature.cadastralRef)}` + + `&layerId=${encodeURIComponent(feature.layerId)}`, ); - if (cancelled) return; - if (!sr.ok) { - setError("search_failed"); - setLoading(false); - return; - } - const sd = (await sr.json()) as { - features?: Array<{ id: string; layerId: string; cadastralRef: string }>; - }; - const match = (sd.features ?? []).find( - (f) => - f.cadastralRef === feature.cadastralRef && - f.layerId === feature.layerId, - ); - if (!match) { - // Parcel not in central DB — show header only; user can press "Citește din ANCPI" - setLoading(false); - return; - } - id = match.id; } - const r = await fetch(`/api/gis/parcela/${encodeURIComponent(id)}`); if (cancelled) return; + if (r.status === 404) { + // Parcel not in central DB yet — show header only; user can hit "Citește din ANCPI" + setLoading(false); + return; + } if (r.status === 403) { setError("forbidden"); setLoading(false);