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 runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
// Resolve the canonical GisFeature for a click that arrives without a uuid
|
// Resolve the canonical GisFeature for a click without a uuid.
|
||||||
// (PMTiles overview tiles only carry siruta / cadastral_ref / object_id).
|
|
||||||
//
|
//
|
||||||
// gis-api `/api/v1/search` returns up to `limit` features whose
|
// Fallback chain (tried in order):
|
||||||
// `cadastralRef` contains the query string AND each feature already
|
// 1. uuid path — handled in panel directly (parcela.get(id)). Never reaches this route.
|
||||||
// carries its `siruta`. We use that to filter directly client-side —
|
// 2. /api/v1/parcela/by-ref — single indexed query on (siruta, cadref, layerId).
|
||||||
// one network round-trip instead of N parcela.get calls.
|
// 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
|
// Forbidden (403) at any step is surfaced as-is so the panel's SessionErrorWatcher
|
||||||
// (e.g. "61745" — 232 features across all UATs) the right siruta can
|
// can trigger a silent re-grant.
|
||||||
// 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
|
const MAX_SEARCH_LIMIT = 50;
|
||||||
// hit parcela.get on the closest layerId match. That still beats the
|
|
||||||
// previous N-roundtrip path that was timing out silently.
|
async function tryByRef(
|
||||||
const MAX_LIMIT = 50;
|
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) {
|
export async function GET(request: Request) {
|
||||||
const session = await getAuthSession();
|
const session = await getAuthSession();
|
||||||
@@ -40,66 +118,24 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sr = await gisApi.search(cad, MAX_LIMIT);
|
// Step 1: indexed by-ref lookup
|
||||||
const all = sr.features ?? [];
|
const byRef = await tryByRef(siruta, cad, layerId);
|
||||||
|
if (byRef) return NextResponse.json(byRef);
|
||||||
|
|
||||||
// First pass: exact cadref + layerId + siruta match. Most common case
|
// Step 2: search fallback (works until gis-api search-limit becomes the
|
||||||
// — single round-trip resolution.
|
// bottleneck for very common cadrefs)
|
||||||
const direct = all.find(
|
const result = await trySearch(siruta, cad, layerId);
|
||||||
(f) =>
|
if ("hit" in result) return NextResponse.json(result.hit);
|
||||||
f.cadastralRef === cad &&
|
if (result.miss === "search_limit_exceeded") {
|
||||||
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) {
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: "search_limit_exceeded",
|
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.`,
|
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: sameRef.length,
|
sameRefCount: result.sameRefCount,
|
||||||
},
|
},
|
||||||
{ status: 422 },
|
{ status: 422 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -245,6 +245,22 @@ export const gisApi = {
|
|||||||
request<unknown>(`/api/v1/parcela/${encodeURIComponent(id)}`, {
|
request<unknown>(`/api/v1/parcela/${encodeURIComponent(id)}`, {
|
||||||
accessToken: opts.accessToken,
|
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 = {}) =>
|
search: (q: string, limit = 50, opts: GisApiCallOpts = {}) =>
|
||||||
|
|||||||
Reference in New Issue
Block a user