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:
Claude VM
2026-05-20 15:10:59 +03:00
parent 653cffeee3
commit 100896a564
2 changed files with 117 additions and 65 deletions
+101 -65
View File
@@ -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) {
+16
View File
@@ -245,6 +245,22 @@ export const gisApi = {
request<unknown>(`/api/v1/parcela/${encodeURIComponent(id)}`, {
accessToken: opts.accessToken,
}),
// GET /api/v1/parcela/by-ref?siruta&cad&layerId — indexed lookup that
// skips the cadref-trigram-then-filter dance. Use when a click arrives
// without a uuid in the tile properties (PMTiles overview today). Same
// response shape as parcela.get; 404 when no match; 403 on scope=none.
byRef: (
body: { siruta: string; cadastralRef: string; layerId: string },
opts: GisApiCallOpts = {},
) =>
request<unknown>("/api/v1/parcela/by-ref", {
query: {
siruta: body.siruta,
cad: body.cadastralRef,
layerId: body.layerId,
},
accessToken: opts.accessToken,
}),
},
search: (q: string, limit = 50, opts: GisApiCallOpts = {}) =>