From 899b5c4cf747c44aa39e1425f41e2c5e2b3050d8 Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sun, 22 Mar 2026 22:23:46 +0200 Subject: [PATCH] fix(parcel-sync): populate county data during login, not via PATCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: PATCH endpoint created a new EterraClient which tried to re-login with expired session → 401. Now county refresh runs immediately after successful login in the session route, using the same authenticated client (fire-and-forget). Component reloads UAT data 5s after connection to pick up fresh county info. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/eterra/session/county-refresh.ts | 218 ++++++++++++++++++ src/app/api/eterra/session/route.ts | 8 +- .../components/parcel-sync-module.tsx | 59 ++--- 3 files changed, 243 insertions(+), 42 deletions(-) create mode 100644 src/app/api/eterra/session/county-refresh.ts diff --git a/src/app/api/eterra/session/county-refresh.ts b/src/app/api/eterra/session/county-refresh.ts new file mode 100644 index 0000000..cd489d9 --- /dev/null +++ b/src/app/api/eterra/session/county-refresh.ts @@ -0,0 +1,218 @@ +/** + * County refresh — populates GisUat.county from eTerra nomenclature. + * + * Called with an already-authenticated EterraClient (fire-and-forget + * after login), so there's no session expiry risk. + * + * Strategy: + * 1. fetchCounties() → build countyMap (nomenPk → county name) + * 2. Phase 1: UATs with workspacePk → instant lookup + * 3. Phase 2: enumerate counties → fetchAdminUnitsByCounty() → match by name + */ + +import { prisma } from "@/core/storage/prisma"; +import type { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; + +function normalizeName(s: string): string { + return s + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toUpperCase() + .trim(); +} + +function titleCase(s: string): string { + return s + .toLowerCase() + .replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase()); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractName(entry: any): string { + if (!entry || typeof entry !== "object") return ""; + for (const key of ["name", "nomenName", "label", "denumire", "NAME"]) { + const val = entry[key]; + if (typeof val === "string" && val.trim()) return val.trim(); + } + return ""; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function extractCode(entry: any): string { + if (!entry || typeof entry !== "object") return ""; + for (const key of [ + "code", + "sirutaCode", + "siruta", + "externalCode", + "cod", + ]) { + const val = entry[key]; + if (val != null) { + const s = String(val).trim(); + if (s && /^\d+$/.test(s)) return s; + } + } + return ""; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function unwrapArray(data: any): any[] { + if (Array.isArray(data)) return data; + if (data && typeof data === "object") { + if (Array.isArray(data.content)) return data.content; + if (Array.isArray(data.data)) return data.data; + if (Array.isArray(data.items)) return data.items; + } + return []; +} + +export async function refreshCountyData(client: EterraClient): Promise { + // Check if refresh is needed + const [total, withCounty] = await Promise.all([ + prisma.gisUat.count(), + prisma.gisUat.count({ where: { county: { not: null } } }), + ]); + + if (total === 0) return; + if (withCounty > total * 0.5) { + console.log( + `[county-refresh] ${withCounty}/${total} already have county, skipping.`, + ); + return; + } + + console.log( + `[county-refresh] Starting: ${withCounty}/${total} have county.`, + ); + + // 1. Fetch counties + const rawCounties = await client.fetchCounties(); + const counties = unwrapArray(rawCounties); + const countyMap = new Map(); + for (const c of counties) { + const pk = Number(c?.nomenPk ?? 0); + const name = extractName(c); + if (pk > 0 && name) countyMap.set(pk, titleCase(name)); + } + + if (countyMap.size === 0) { + console.error( + "[county-refresh] fetchCounties returned 0 counties. Raw sample:", + JSON.stringify(rawCounties).slice(0, 500), + ); + return; + } + + console.log(`[county-refresh] Got ${countyMap.size} counties.`); + + // 2. Load all UATs needing county + const allUats = await prisma.gisUat.findMany({ + where: { county: null }, + select: { siruta: true, name: true, workspacePk: true }, + }); + + // Phase 1: UATs with workspacePk + let phase1 = 0; + const needsCounty: Array<{ siruta: string; name: string }> = []; + + for (const uat of allUats) { + if (uat.workspacePk && uat.workspacePk > 0) { + const county = countyMap.get(uat.workspacePk); + if (county) { + await prisma.gisUat.update({ + where: { siruta: uat.siruta }, + data: { county }, + }); + phase1++; + continue; + } + } + needsCounty.push({ siruta: uat.siruta, name: uat.name }); + } + + console.log( + `[county-refresh] Phase 1: ${phase1} via workspacePk. ${needsCounty.length} remaining.`, + ); + + // Phase 2: enumerate counties → match by code or name + const nameToSirutas = new Map(); + const sirutaSet = new Set(); + for (const u of needsCounty) { + sirutaSet.add(u.siruta); + const key = normalizeName(u.name); + const arr = nameToSirutas.get(key); + if (arr) arr.push(u.siruta); + else nameToSirutas.set(key, [u.siruta]); + } + + let phase2 = 0; + let codeMatches = 0; + let nameMatches = 0; + const matched = new Set(); + + for (const [countyPk, countyName] of countyMap) { + if (matched.size >= needsCounty.length) break; + + try { + const rawUats = await client.fetchAdminUnitsByCounty(countyPk); + const uats = unwrapArray(rawUats); + + // Log first response for debugging + if (phase2 === 0 && codeMatches === 0 && nameMatches === 0 && uats.length > 0) { + console.log( + `[county-refresh] Sample from ${countyName} (${uats.length} UATs):`, + JSON.stringify(uats[0]).slice(0, 500), + ); + } + + for (const uat of uats) { + // Strategy A: match by code + const code = extractCode(uat); + if (code && sirutaSet.has(code) && !matched.has(code)) { + matched.add(code); + await prisma.gisUat.update({ + where: { siruta: code }, + data: { county: countyName, workspacePk: countyPk }, + }); + phase2++; + codeMatches++; + continue; + } + + // Strategy B: match by name + const eterraName = extractName(uat); + if (!eterraName) continue; + const key = normalizeName(eterraName); + const sirutas = nameToSirutas.get(key); + if (!sirutas || sirutas.length === 0) continue; + + const siruta = sirutas.find((s) => !matched.has(s)); + if (!siruta) continue; + + matched.add(siruta); + await prisma.gisUat.update({ + where: { siruta }, + data: { county: countyName, workspacePk: countyPk }, + }); + phase2++; + nameMatches++; + + if (sirutas.every((s) => matched.has(s))) { + nameToSirutas.delete(key); + } + } + } catch (err) { + console.warn( + `[county-refresh] County ${countyName} failed:`, + err instanceof Error ? err.message : err, + ); + } + } + + console.log( + `[county-refresh] Done: ${phase1 + phase2} updated ` + + `(phase1=${phase1}, phase2=${phase2}: ${codeMatches} code, ${nameMatches} name). ` + + `Unmatched: ${needsCounty.length - phase2}.`, + ); +} diff --git a/src/app/api/eterra/session/route.ts b/src/app/api/eterra/session/route.ts index 37872d1..bf87492 100644 --- a/src/app/api/eterra/session/route.ts +++ b/src/app/api/eterra/session/route.ts @@ -8,6 +8,7 @@ import { getSessionStatus, } from "@/modules/parcel-sync/services/session-store"; import { getEterraHealth } from "@/modules/parcel-sync/services/eterra-health"; +import { refreshCountyData } from "./county-refresh"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -104,9 +105,14 @@ export async function POST(req: Request) { } // Attempt login - await EterraClient.create(username, password); + const client = await EterraClient.create(username, password); createSession(username, password); + // Fire-and-forget: populate county data using fresh client + refreshCountyData(client).catch((err) => + console.error("[session] County refresh failed:", err), + ); + return NextResponse.json({ success: true }); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 1c5f021..850df9a 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -554,51 +554,28 @@ export function ParcelSyncModule() { }, [fetchDbSummary]); /* ════════════════════════════════════════════════════════════ */ - /* Auto-refresh county data when eTerra is connected */ + /* Reload UAT data when session connects (county data may */ + /* have been populated by the login flow) */ /* ════════════════════════════════════════════════════════════ */ - const countyRefreshAttempted = useRef(false); + const prevConnected = useRef(false); useEffect(() => { - if (!session.connected || countyRefreshAttempted.current) return; - if (uatData.length === 0) return; - - // Check if most UATs are missing county data - const withCounty = uatData.filter((u) => u.county).length; - if (withCounty > uatData.length * 0.5) return; // >50% already have county - - countyRefreshAttempted.current = true; - console.log("[uats] Auto-refreshing county data from eTerra…"); - - fetch("/api/eterra/uats", { method: "PATCH" }) - .then((res) => res.json()) - .then( - (result: { - updated?: number; - error?: string; - phase1?: number; - phase2?: number; - codeMatches?: number; - nameMatches?: number; - unmatched?: number; - debug?: unknown; - }) => { - console.log("[uats] County refresh result:", result); - if (result.updated && result.updated > 0) { - // Reload UAT data with fresh county info - fetch("/api/eterra/uats") - .then((res) => res.json()) - .then((data: { uats?: UatEntry[] }) => { - if (data.uats && data.uats.length > 0) setUatData(data.uats); - }) - .catch(() => {}); - } - }, - ) - .catch((err) => { - console.error("[uats] County refresh failed:", err); - }); - }, [session.connected, uatData]); + if (session.connected && !prevConnected.current) { + // Just connected — reload UATs after a short delay to let + // the server-side county refresh finish + const timer = setTimeout(() => { + fetch("/api/eterra/uats") + .then((res) => res.json()) + .then((data: { uats?: UatEntry[] }) => { + if (data.uats && data.uats.length > 0) setUatData(data.uats); + }) + .catch(() => {}); + }, 5000); + return () => clearTimeout(timer); + } + prevConnected.current = session.connected; + }, [session.connected]); /* ════════════════════════════════════════════════════════════ */ /* UAT autocomplete filter */