feat(geoportal-v2): find proxy fallback chain — by-ref → search
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 ("<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;
|
||||
// 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<unknown | null> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user