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:
@@ -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 ("<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) {
|
||||
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<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,
|
||||
);
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}>;
|
||||
|
||||
@@ -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}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user