From 02a466ccaa00496d846bbc80429491db209702cf Mon Sep 17 00:00:00 2001 From: Claude VM Date: Tue, 19 May 2026 22:24:02 +0300 Subject: [PATCH] feat(geoportal-v2): swap refresh path to /parcel/enrich (deep-enrich) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gis-api session shipped PR3 (gis-api 09f1ab8 + gis-sync-orchestrator 0371d81): new POST /api/v1/parcel/enrich does the full eTerra round-trip (searchImmovableByIdentifier → fetchDocumentationData → fetchImmovableParcelDetails) and merges NR_CF / ADRESA / PROPRIETARI + 20-plus fields into gis_core.GisFeature.enrichment with a 30-day cache. Verified on 266888 + 328607 → 27 keys with full PII. Wired in three places: 1. src/lib/gis-api-client.ts — gisApi.parcel.enrich({siruta, cadastralRef, force?}) thin wrapper. 2. src/app/api/gis/parcel/enrich/route.ts — architots-side proxy, matches the parcel/tech pattern (auth check → forward → bubble up GisApiError status codes). 3. src/modules/geoportal/v2/feature-info-panel.tsx — refreshFromAncpi now POSTs to /api/gis/parcel/enrich instead of /api/gis/parcel/tech. After the orchestrator returns, the panel re-fetches the canonical record via parcela.get (when uuid known) or parcela.find (when not), so it sees exactly what gis_core stores rather than the orchestrator response shape. The existing auto-trigger (fires when detail has no NR_CF/ADRESA/ PROPRIETARI) now actually fills those fields. Subsequent clicks on the same parcel hit gis-api's 30-day cache (5ms vs 1-2s live fetch). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/gis/parcel/enrich/route.ts | 49 +++++++++++++++++++ src/lib/gis-api-client.ts | 11 +++++ .../geoportal/v2/feature-info-panel.tsx | 41 +++++++++------- 3 files changed, 83 insertions(+), 18 deletions(-) create mode 100644 src/app/api/gis/parcel/enrich/route.ts diff --git a/src/app/api/gis/parcel/enrich/route.ts b/src/app/api/gis/parcel/enrich/route.ts new file mode 100644 index 0000000..b2f26a4 --- /dev/null +++ b/src/app/api/gis/parcel/enrich/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server"; +import { getAuthSession } from "@/core/auth/require-auth"; +import { gisApi, GisApiError, type ParcelRefBody } from "@/lib/gis-api-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Proxy to gis-api POST /api/v1/parcel/enrich (PR3, 2026-05-19). +// Orchestrator does the full eTerra round-trip and populates NR_CF / +// ADRESA / PROPRIETARI + tech fields in gis_core. 30-day cache by +// default; force=true bypasses. After this returns, the panel does a +// fresh parcela.get / parcela.find to read the canonical record. +export async function POST(request: Request) { + const session = await getAuthSession(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let body: ParcelRefBody; + try { + body = (await request.json()) as ParcelRefBody; + } catch { + return NextResponse.json({ error: "invalid_body" }, { status: 400 }); + } + + if (!body?.siruta || !body?.cadastralRef) { + return NextResponse.json( + { error: "missing_fields", required: ["siruta", "cadastralRef"] }, + { status: 400 }, + ); + } + + try { + return NextResponse.json(await gisApi.parcel.enrich(body)); + } catch (err) { + if (err instanceof GisApiError) { + return NextResponse.json( + { error: err.code, status: err.status, body: err.body }, + { status: err.status }, + ); + } + const msg = err instanceof Error ? err.message : String(err); + console.error("[gis-parcel-enrich] internal:", msg); + return NextResponse.json( + { error: "internal_error", hint: msg.slice(0, 200) }, + { status: 500 }, + ); + } +} diff --git a/src/lib/gis-api-client.ts b/src/lib/gis-api-client.ts index 36674b8..0063fb2 100644 --- a/src/lib/gis-api-client.ts +++ b/src/lib/gis-api-client.ts @@ -270,6 +270,17 @@ export const gisApi = { body: JSON.stringify(body), accessToken: opts.accessToken, }), + // Deep-enrich (PR3 / gis-api 09f1ab8): orchestrator looks up eTerra + // immovable, fetches documentation + parcel details, parses NR_CF + + // ADRESA + PROPRIETARI + 20+ more fields and merges into gis_core. + // Cache TTL ~30d; pass force=true to bypass. + enrich: (body: ParcelRefBody, opts: GisApiCallOpts = {}) => + request("/api/v1/parcel/enrich", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + accessToken: opts.accessToken, + }), unitsFetch: (body: ParcelRefBody, opts: GisApiCallOpts = {}) => request("/api/v1/parcel/units/fetch", { method: "POST", diff --git a/src/modules/geoportal/v2/feature-info-panel.tsx b/src/modules/geoportal/v2/feature-info-panel.tsx index ef4ce0a..552d4b1 100644 --- a/src/modules/geoportal/v2/feature-info-panel.tsx +++ b/src/modules/geoportal/v2/feature-info-panel.tsx @@ -417,7 +417,12 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { setRefreshing(true); setError(null); try { - const res = await fetch("/api/gis/parcel/tech", { + // PR3 deep-enrich path: gis-api orchestrates the eTerra round-trip + // and persists NR_CF / ADRESA / PROPRIETARI + tech fields in gis_core + // (30-day cache; force=true bypasses). After this returns the + // central record is canonical — we re-fetch it via parcela.get or + // parcela.find so the panel sees what's actually in gis_core. + const enrichResp = await fetch("/api/gis/parcel/enrich", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -426,16 +431,17 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { force: true, }), }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - setError(body.error || "refresh_failed"); + if (!enrichResp.ok) { + const body = await enrichResp.json().catch(() => ({})); + setError(body.error || `enrich_failed_${enrichResp.status}`); return; } - const techData = await res.json().catch(() => null); - // After orchestrator updates the central DB, re-fetch via the - // server-side find/get path so we land on the canonical shape - // (and pick up rich enrichment that the tech response itself - // doesn't carry). + const enriched = (await enrichResp.json().catch(() => null)) as + | { siruta?: string; cadastralRef?: string; enrichment?: Record; enrichedAt?: string } + | null; + + // Re-fetch canonical record so the panel matches what other clients + // would see (and so we get isActive / layerId / etc.). const id = detail?.id ?? feature.id; let updated: Response | null = null; if (id) { @@ -447,19 +453,18 @@ export function FeatureInfoPanel({ feature, onClose, basic = false }: Props) { `&layerId=${encodeURIComponent(feature.layerId)}`, ); } - if (updated.ok) { + if (updated && updated.ok) { setDetail(await updated.json()); - } else if (techData) { - // Final fallback — project the orchestrator response if we - // can't re-fetch the canonical record. - const inner = - (techData?.data as Record | undefined) ?? techData; + } else if (enriched?.enrichment) { + // Fallback: project the enrich response directly when the + // canonical re-fetch can't run. setDetail({ - siruta: feature.siruta, - cadastralRef: feature.cadastralRef, + siruta: enriched.siruta ?? feature.siruta, + cadastralRef: enriched.cadastralRef ?? feature.cadastralRef, areaValue: feature.areaValue, layerId: feature.layerId, - enrichment: inner as Record, + enrichment: enriched.enrichment, + enrichedAt: enriched.enrichedAt, }); } } catch {