import { NextResponse } from "next/server"; import { prisma } from "@/core/storage/prisma"; import { readFile } from "fs/promises"; import { join } from "path"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /* ------------------------------------------------------------------ */ /* Feature count cache (expensive query, cached 5 min) */ /* ------------------------------------------------------------------ */ const gCache = globalThis as { __featureCountCache?: { map: Map; ts: number }; __featureCountInFlight?: Promise | null; }; // NEVER block the response on the counts query — the groupBy takes ~30s // on 9.7M GisFeature rows and the in-memory cache dies on every redeploy, // which used to freeze the UAT selector for the first ~30s after a deploy // (2026-06-04 incident). Counts only feed the decorative "N local" badge; // a cold start simply renders the badge as absent until the first refresh // lands. Single-flight so concurrent cold requests don't pile up 30s // queries on the DB. function getCachedFeatureCounts(): Map { const TTL = 5 * 60 * 1000; // 5 minutes const cached = gCache.__featureCountCache; if (!cached || Date.now() - cached.ts >= TTL) { void refreshFeatureCounts(); } return cached?.map ?? new Map(); } function refreshFeatureCounts(): Promise { if (gCache.__featureCountInFlight) return gCache.__featureCountInFlight; gCache.__featureCountInFlight = (async () => { try { const groups = await prisma.gisFeature.groupBy({ by: ["siruta"], _count: { id: true }, }); const map = new Map(); for (const g of groups) { map.set(g.siruta, g._count.id); } gCache.__featureCountCache = { map, ts: Date.now() }; } catch { // keep whatever cache we had; next request retries } finally { gCache.__featureCountInFlight = null; } })(); return gCache.__featureCountInFlight; } /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ type UatResponse = { siruta: string; name: string; county: string; workspacePk: number; /** Number of GIS features synced locally for this UAT */ localFeatures: number; }; /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ function populateWorkspaceCache( uats: Array<{ siruta: string; workspacePk: number }>, ) { const wsGlobal = globalThis as { __eterraWorkspaceCache?: Map; }; if (!wsGlobal.__eterraWorkspaceCache) { wsGlobal.__eterraWorkspaceCache = new Map(); } for (const u of uats) { if (u.workspacePk) { wsGlobal.__eterraWorkspaceCache.set(u.siruta, u.workspacePk); } } } /** Remove diacritics and uppercase for fuzzy name matching */ function normalizeName(s: string): string { return s .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toUpperCase() .trim(); } /** Title-case: "SATU MARE" → "Satu Mare" */ function titleCase(s: string): string { return s .toLowerCase() .replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase()); } /** * Extract a name from an eTerra nomenclature entry. * Tries multiple possible field names. */ // 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 ""; } /** * Extract a SIRUTA code from an eTerra nomenclature entry. * Tries multiple possible field names (nomenPk ≠ SIRUTA, but code might be). */ // 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", "CODE", ]) { const val = entry[key]; if (val != null) { const s = String(val).trim(); if (s && /^\d+$/.test(s)) return s; } } return ""; } /** * Unwrap a potentially nested response (Spring Boot Page format). * eTerra sometimes returns {content: [...]} instead of flat arrays. */ // 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; if (Array.isArray(data.results)) return data.results; } return []; } /* ------------------------------------------------------------------ */ /* GET /api/eterra/uats */ /* */ /* Always serves from local PostgreSQL (GisUat table). */ /* Includes local GIS feature counts per UAT for the UI indicator. */ /* No eTerra credentials needed — instant response. */ /* ------------------------------------------------------------------ */ export async function GET() { try { // CRITICAL: select only needed fields — geometry column has huge polygon data const rows = await prisma.gisUat.findMany({ orderBy: { name: "asc" }, select: { siruta: true, name: true, county: true, workspacePk: true }, }); // Feature counts: in-memory cache, refreshed in the background (never // awaited — see getCachedFeatureCounts). Cold cache → badge absent. const featureCounts = getCachedFeatureCounts(); const uats: UatResponse[] = rows.map((r) => ({ siruta: r.siruta, name: r.name, county: r.county ?? "", workspacePk: r.workspacePk ?? 0, localFeatures: featureCounts.get(r.siruta) ?? 0, })); // Populate in-memory workspace cache for search route if (uats.length > 0) { populateWorkspaceCache(uats); } return NextResponse.json({ uats, total: uats.length, source: "database", }); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; return NextResponse.json({ error: message, uats: [] }, { status: 500 }); } } /* ------------------------------------------------------------------ */ /* POST /api/eterra/uats */ /* */ /* Seed or resync DB from static uat.json. */ /* Uses upsert so it's safe to call repeatedly — new UATs are added, */ /* existing names are updated, county/workspacePk are preserved. */ /* eTerra nomenPk ≠ SIRUTA, so we cannot use the nomenclature API */ /* for populating UAT data. uat.json has correct SIRUTA codes. */ /* Workspace (county) PKs are resolved lazily via ArcGIS layer query */ /* in the search route and persisted to DB on first resolution. */ /* ------------------------------------------------------------------ */ export async function POST() { const gate = await gateLegacyGisWrite("/api/eterra/uats"); if (gate) return gate; try { // Read uat.json from public/ directory let rawUats: Array<{ siruta: string; name: string }>; try { const filePath = join(process.cwd(), "public", "uat.json"); const content = await readFile(filePath, "utf-8"); rawUats = JSON.parse(content); } catch { return NextResponse.json( { error: "Nu s-a putut citi uat.json", synced: false }, { status: 500 }, ); } if (!Array.isArray(rawUats) || rawUats.length === 0) { return NextResponse.json({ synced: false, reason: "empty-uat-json", total: 0, }); } // Batch insert in chunks of 500 (Prisma transaction limit) const CHUNK_SIZE = 500; let inserted = 0; for (let i = 0; i < rawUats.length; i += CHUNK_SIZE) { const chunk = rawUats.slice(i, i + CHUNK_SIZE); await prisma.$transaction( chunk .filter((u) => u.siruta && u.name) .map((u) => prisma.gisUat.upsert({ where: { siruta: String(u.siruta) }, update: { name: String(u.name).trim() }, create: { siruta: String(u.siruta), name: String(u.name).trim(), }, }), ), ); inserted += chunk.length; } console.log(`[uats] Seeded ${inserted} UATs from uat.json`); return NextResponse.json({ synced: true, total: inserted, source: "uat-json", }); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; return NextResponse.json( { error: message, synced: false }, { status: 500 }, ); } } /* ------------------------------------------------------------------ */ /* PATCH /api/eterra/uats */ /* */ /* Populate county names from eTerra nomenclature API. */ /* */ /* Strategy (two phases): */ /* Phase 1: For UATs that already have workspacePk resolved, */ /* use fetchCounties() → countyMap[workspacePk] → instant update. */ /* Phase 2: For remaining UATs, enumerate counties → */ /* fetchAdminUnitsByCounty() per county → match by code or name. */ /* */ /* Requires active eTerra session. */ /* ------------------------------------------------------------------ */ export async function PATCH() { const gate = await gateLegacyGisWrite("/api/eterra/uats"); if (gate) return gate; try { // 1. Get eTerra credentials from session const session = getSessionCredentials(); const username = String( session?.username || process.env.ETERRA_USERNAME || "", ).trim(); const password = String( session?.password || process.env.ETERRA_PASSWORD || "", ).trim(); if (!username || !password) { return NextResponse.json( { error: "Conectează-te la eTerra mai întâi." }, { status: 401 }, ); } const client = await EterraClient.create(username, password); // 2. Fetch all counties from eTerra nomenclature const rawCounties = await client.fetchCounties(); const counties = unwrapArray(rawCounties); const countyMap = new Map(); // nomenPk → county name 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) { // Log raw response for debugging console.error( "[uats-patch] fetchCounties returned 0 counties. Raw sample:", JSON.stringify(rawCounties).slice(0, 500), ); return NextResponse.json( { error: "Nu s-au putut obține județele din eTerra.", debug: { rawType: typeof rawCounties, isArray: Array.isArray(rawCounties), length: Array.isArray(rawCounties) ? rawCounties.length : null, sample: JSON.stringify(rawCounties).slice(0, 300), }, }, { status: 502 }, ); } console.log(`[uats-patch] Fetched ${countyMap.size} counties from eTerra`); // 3. Load all UATs from DB const allUats = await prisma.gisUat.findMany({ select: { siruta: true, name: true, county: true, workspacePk: true }, }); // Phase 1: instant fill for UATs that already have workspacePk const phase1Ops: Array> = []; const needsCounty: Array<{ siruta: string; name: string }> = []; for (const uat of allUats) { if (uat.county) continue; // already has county if (uat.workspacePk && uat.workspacePk > 0) { const county = countyMap.get(uat.workspacePk); if (county) { phase1Ops.push( prisma.gisUat.update({ where: { siruta: uat.siruta }, data: { county }, }), ); continue; } } needsCounty.push({ siruta: uat.siruta, name: uat.name }); } // Execute phase 1 in batches let phase1Updated = 0; for (let i = 0; i < phase1Ops.length; i += 100) { const batch = phase1Ops.slice(i, i + 100); await prisma.$transaction(batch); phase1Updated += batch.length; } console.log( `[uats-patch] Phase 1: ${phase1Updated} updated via workspacePk. ` + `${needsCounty.length} remaining.`, ); // Phase 2: enumerate UATs per county from nomenclature, match by code or name // Build lookups 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 phase2Updated = 0; let codeMatches = 0; let nameMatches = 0; const matchedSirutas = new Set(); let loggedSample = false; // eslint-disable-next-line @typescript-eslint/no-explicit-any let sampleCounty: any = null; // eslint-disable-next-line @typescript-eslint/no-explicit-any let sampleUat: any = null; let totalEterraUats = 0; for (const [countyPk, countyName] of countyMap) { if (matchedSirutas.size >= needsCounty.length) break; try { const rawUats = await client.fetchAdminUnitsByCounty(countyPk); const uats = unwrapArray(rawUats); totalEterraUats += uats.length; // Log first county's first UAT for debugging if (!loggedSample && uats.length > 0) { sampleUat = uats[0]; sampleCounty = { pk: countyPk, name: countyName, uatCount: uats.length }; console.log( `[uats-patch] Sample UAT from ${countyName} (${uats.length} UATs):`, JSON.stringify(uats[0]).slice(0, 500), ); console.log( `[uats-patch] Sample UAT keys:`, Object.keys(uats[0] ?? {}), ); loggedSample = true; } for (const uat of uats) { // Strategy A: match by code (might be SIRUTA) const code = extractCode(uat); if (code && sirutaSet.has(code) && !matchedSirutas.has(code)) { matchedSirutas.add(code); await prisma.gisUat.update({ where: { siruta: code }, data: { county: countyName, workspacePk: countyPk }, }); phase2Updated++; codeMatches++; continue; } // Strategy B: match by normalized 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) => !matchedSirutas.has(s)); if (!siruta) continue; matchedSirutas.add(siruta); await prisma.gisUat.update({ where: { siruta }, data: { county: countyName, workspacePk: countyPk }, }); phase2Updated++; nameMatches++; if (sirutas.every((s) => matchedSirutas.has(s))) { nameToSirutas.delete(key); } } } catch (err) { console.warn( `[uats-patch] Failed to fetch UATs for county ${countyName} (pk=${countyPk}):`, err instanceof Error ? err.message : err, ); } } const totalUpdated = phase1Updated + phase2Updated; const unmatched = needsCounty.length - phase2Updated; console.log( `[uats-patch] Phase 2: ${phase2Updated} (${codeMatches} by code, ${nameMatches} by name). ` + `Total: ${totalUpdated}. Unmatched: ${unmatched}.`, ); return NextResponse.json({ updated: totalUpdated, phase1: phase1Updated, phase2: phase2Updated, codeMatches, nameMatches, totalCounties: countyMap.size, totalEterraUats, unmatched, // Include debug samples so we can see what eTerra returns debug: { sampleCounty, sampleUatKeys: sampleUat ? Object.keys(sampleUat) : null, sampleUat: sampleUat ? JSON.parse(JSON.stringify(sampleUat).slice(0, 500)) : null, sampleCountyRaw: counties[0] ? { keys: Object.keys(counties[0]), nomenPk: counties[0].nomenPk, name: counties[0].name, } : null, }, }); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; console.error("[uats-patch] Error:", message); return NextResponse.json({ error: message }, { status: 500 }); } }