fix(parcel-sync): populate county data during login, not via PATCH

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) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-22 22:23:46 +02:00
parent 379e7e4d3f
commit 899b5c4cf7
3 changed files with 243 additions and 42 deletions
@@ -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<void> {
// 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<number, string>();
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<string, string[]>();
const sirutaSet = new Set<string>();
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<string>();
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}.`,
);
}
+7 -1
View File
@@ -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";
@@ -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
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);
}
},
)
.catch((err) => {
console.error("[uats] County refresh failed:", err);
});
}, [session.connected, uatData]);
prevConnected.current = session.connected;
}, [session.connected]);
/* ════════════════════════════════════════════════════════════ */
/* UAT autocomplete filter */