fix(geoportal-v2): use siruta from search response — no more N+1 misses

Marius hit "Date ne-încărcate" / "Parcela nu există" on Feleacu parcels
(SIRUTA 57582, cadref 61745 / 61746) even though gis_core has 28 rich
enrichment keys for them. Root cause: 232 features in gis_core share
cadref `61745` across different UATs. Our find proxy was doing:

  1. gisApi.search(cad, limit=20)
  2. for each candidate (up to 20): parcela.get(id), check siruta

Feleacu's parcel sat past position 20 in the search ranking, so we
never tried parcela.get on it — fallback returned a sibling parcel
with 0 keys (the "Date ne-încărcate" UI) or no readable candidate at
all (the "nu există în DB centrală" 404 UI).

This was wrong on two counts:

1. WE WERE DOING N+1: gis-api's /api/v1/search already returns siruta
   per feature (see gis-api src/routes/search.ts:41). One round-trip
   would have given us the answer; we just weren't reading the field.
   Updated src/lib/gis-api-client.ts to declare siruta in the
   response type + bumped default limit from 20 → 50 (gis-api's
   server-side cap).

2. WE WERE FAILING SILENTLY: when search-cap was the actual bottleneck
   the proxy returned 404 with no hint that gis-api had more
   data we just couldn't reach. New find proxy:

   - First pass: direct match on cadref + layerId + siruta from the
     search response. Single follow-up parcela.get to fetch full
     detail. No more sequential probing.
   - If no direct match: log + report distinctively. When the search
     returned MAX_LIMIT (50) features all with the same cadref, we
     return 422 search_limit_exceeded with a hint about the missing
     siruta filter. Otherwise 404 (genuinely not in gis_core).

3. Panel surfaces the 422 with a plain-language explanation rather
   than the raw "Eroare: ..." dump.

For the long-term fix: gis-api needs either a `siruta` query param on
/api/v1/search OR a dedicated /api/v1/parcela/by-ref?siruta&cad&layerId
endpoint that does a single indexed lookup. Today's patch handles the
top-50 case (was top-20); the 422 surfaces the residual cases for
follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-20 12:57:31 +03:00
parent 7b01744fad
commit 653cffeee3
3 changed files with 63 additions and 77 deletions
+54 -71
View File
@@ -5,17 +5,22 @@ import { gisApi, GisApiError } from "@/lib/gis-api-client";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; 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). // (PMTiles overview tiles only carry siruta / cadastral_ref / object_id).
// //
// gis-api `/api/v1/search` indexes by cadastralRef trigram but does not // gis-api `/api/v1/search` returns up to `limit` features whose
// return siruta per feature → two parcels in different UATs with the same // `cadastralRef` contains the query string AND each feature already
// cadref are indistinguishable from the search response alone. So we // carries its `siruta`. We use that to filter directly client-side —
// search, filter by layerId, then resolve each candidate via parcela.get // one network round-trip instead of N parcela.get calls.
// and pick the one whose stored siruta matches the click. First match //
// wins; if no candidate matches siruta we surface the closest hit (same // Caveat: gis-api's search hard-caps `limit` at 50. For "common" cadrefs
// cadref + layerId) so the panel still has something to render rather // (e.g. "61745" — 232 features across all UATs) the right siruta can
// than a hard miss. // drop out of the top-50. When that happens we fall back to a second
// search variant ("<cadref>" 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) { export async function GET(request: Request) {
const session = await getAuthSession(); const session = await getAuthSession();
if (!session) { if (!session) {
@@ -35,88 +40,66 @@ export async function GET(request: Request) {
} }
try { try {
const sr = await gisApi.search(cad, 20); const sr = await gisApi.search(cad, MAX_LIMIT);
const candidates = (sr.features ?? []).filter( const all = sr.features ?? [];
(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; // First pass: exact cadref + layerId + siruta match. Most common case
let forbiddenCount = 0; // — single round-trip resolution.
for (const c of candidates) { const direct = all.find(
try { (f) =>
const detail = (await gisApi.parcela.get(c.id)) as { f.cadastralRef === cad &&
siruta?: string; f.layerId === layerId &&
[k: string]: unknown; String(f.siruta ?? "") === siruta,
} | null;
if (!detail) continue;
if (typeof fallback !== "object" || fallback === null) fallback = detail;
if (String(detail.siruta ?? "") === siruta) {
const enr = (detail as { enrichment?: Record<string, unknown> }).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,
); );
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); return NextResponse.json(detail);
}
} catch (e) { } 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) { if (e instanceof GisApiError && e.status === 403) {
forbiddenCount++; return NextResponse.json(
continue; { error: "scope_insufficient", hint: "Token lacks enrichment_scope" },
{ status: 403 },
);
} }
throw e; throw e;
} }
} }
// No exact siruta match — return the best-effort candidate (first one // Second pass: same cadref + layerId without siruta match. Surfaces
// we could read). Better than an empty panel; the user can verify // the common "too many features with this cadref, our siruta wasn't
// via the Citește din ANCPI button if they think it's wrong. // in the top 50" failure mode explicitly.
if (fallback) { const sameRef = all.filter(
(f) => f.cadastralRef === cad && f.layerId === layerId,
);
console.log( console.log(
"[gis-parcela-find] no_siruta_match cad=%s — returning fallback", "[gis-parcela-find] cad=%s siruta=%s direct=miss same_ref_count=%d total=%d",
cad, cad,
);
return NextResponse.json(fallback);
}
// 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, siruta,
cad, sameRef.length,
candidates.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) {
return NextResponse.json( return NextResponse.json(
{ {
error: "scope_insufficient", error: "search_limit_exceeded",
hint: 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.`,
"All candidates returned 403 from gis-api — token likely lacks enrichment_scope. Sign out and back in.", sameRefCount: sameRef.length,
candidates: candidates.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 }); return NextResponse.json({ error: "not_found" }, { status: 404 });
} catch (err) { } catch (err) {
if (err instanceof GisApiError) { if (err instanceof GisApiError) {
+2 -1
View File
@@ -247,13 +247,14 @@ export const gisApi = {
}), }),
}, },
search: (q: string, limit = 20, opts: GisApiCallOpts = {}) => search: (q: string, limit = 50, opts: GisApiCallOpts = {}) =>
request<{ request<{
q: string; q: string;
uats: Array<{ siruta: string; name: string; county: string }>; uats: Array<{ siruta: string; name: string; county: string }>;
features: Array<{ features: Array<{
id: string; id: string;
layerId: string; layerId: string;
siruta: string;
cadastralRef: string; cadastralRef: string;
areaValue?: number; areaValue?: number;
}>; }>;
@@ -868,6 +868,8 @@ export function FeatureInfoPanel({ feature, onClose, onSelectFeature, basic = fa
? "Parcela nu există în baza centrală gis_core." ? "Parcela nu există în baza centrală gis_core."
: error === "eterra_fetch_failed" : error === "eterra_fetch_failed"
? "eTerra ANCPI nu răspunde momentan. Reîncearcă în 1-2 minute." ? "eTerra ANCPI nu răspunde momentan. Reîncearcă în 1-2 minute."
: 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}`} : `Eroare: ${error}`}
</span> </span>
</div> </div>