import { NextResponse } from "next/server"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; import { prisma } from "@/core/storage/prisma"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; type Body = { siruta?: string; search?: string; // cadastral number(s), comma or newline separated username?: string; password?: string; workspacePk?: number; // county workspace PK — if provided, skips resolution }; /* ------------------------------------------------------------------ */ /* Workspace (county) lookup cache */ /* ------------------------------------------------------------------ */ const globalRef = globalThis as { __eterraWorkspaceCache?: Map; }; const workspaceCache = globalRef.__eterraWorkspaceCache ?? new Map(); globalRef.__eterraWorkspaceCache = workspaceCache; /** * Resolve eTerra workspace ID for a given SIRUTA. * * Strategy: Query 1 feature from TERENURI_ACTIVE ArcGIS layer for this * SIRUTA, read the WORKSPACE_ID attribute. * * Uses `listLayer()` (not `listLayerByWhere`) so the admin field name * (ADMIN_UNIT_ID, SIRUTA, UAT_ID…) is auto-discovered from layer metadata. * * SIRUTA ≠ eTerra nomenPk, so nomenclature API lookups don't help. */ async function resolveWorkspace( client: EterraClient, siruta: string, ): Promise { const cached = workspaceCache.get(siruta); if (cached !== undefined) return cached; try { // listLayer auto-discovers the correct admin field via buildWhere const features = await client.listLayer( { id: "TERENURI_ACTIVE", name: "TERENURI_ACTIVE", endpoint: "aut", whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1", }, siruta, { limit: 1, outFields: "WORKSPACE_ID" }, ); const wsId = features?.[0]?.attributes?.WORKSPACE_ID; console.log( "[resolveWorkspace] ArcGIS WORKSPACE_ID for", siruta, "→", wsId, ); if (wsId != null) { const numWs = Number(wsId); if (Number.isFinite(numWs)) { workspaceCache.set(siruta, numWs); // Persist to DB for future fast lookups persistWorkspace(siruta, numWs); return numWs; } } } catch (e) { console.log( "[resolveWorkspace] ArcGIS query failed:", e instanceof Error ? e.message : e, ); } return null; } /** Fire-and-forget: save WORKSPACE_ID to GisUat row */ function persistWorkspace(siruta: string, workspacePk: number) { prisma.gisUat .upsert({ where: { siruta }, update: { workspacePk }, create: { siruta, name: siruta, workspacePk }, }) .catch(() => {}); } /* ------------------------------------------------------------------ */ /* Helper formatters (same logic as export-bundle magic mode) */ /* ------------------------------------------------------------------ */ // eslint-disable-next-line @typescript-eslint/no-explicit-any function formatAddress(item?: any) { const address = item?.immovableAddresses?.[0]?.address ?? null; if (!address) return ""; const parts: string[] = []; // addressDescription may contain the full text address already if (address.addressDescription) { const desc = String(address.addressDescription).trim(); // If it looks like a complete address (has comma or street), use it directly if (desc.length > 3) return desc; } if (address.street) parts.push(`Str. ${address.street}`); if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`); if (address.locality?.name) parts.push(address.locality.name); if (address.county?.name) parts.push(`Jud. ${address.county.name}`); return parts.length ? parts.join(", ") : ""; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function normalizeIntravilan(values: string[]) { const normalized = values .map((v) => String(v ?? "") .trim() .toLowerCase(), ) .filter(Boolean); const unique = new Set(normalized); if (!unique.size) return ""; if (unique.size === 1) return unique.has("da") ? "Da" : unique.has("nu") ? "Nu" : "Mixt"; return "Mixt"; } // eslint-disable-next-line @typescript-eslint/no-explicit-any function formatCategories(entries: any[]) { const map = new Map(); for (const entry of entries) { const key = String(entry?.categorieFolosinta ?? "").trim(); if (!key) continue; const area = Number(entry?.suprafata ?? 0); map.set(key, (map.get(key) ?? 0) + (Number.isFinite(area) ? area : 0)); } return Array.from(map.entries()) .map(([k, a]) => `${k}:${a.toFixed(2).replace(/\.00$/, "")}`) .join("; "); } /* ------------------------------------------------------------------ */ /* Route handler */ /* ------------------------------------------------------------------ */ export type ParcelDetail = { nrCad: string; nrCF: string; nrCFVechi: string; nrTopo: string; intravilan: string; categorieFolosinta: string; adresa: string; proprietari: string; proprietariActuali: string; proprietariVechi: string; suprafata: number | null; solicitant: string; immovablePk: string; }; /** * POST /api/eterra/search * * Search eTerra by cadastral number using the application API * (same as the eTerra web UI). Returns full parcel details: * nr. cadastral, CF, topo, intravilan, categorii folosință, * adresă, proprietari. * * Accepts one or more cadastral numbers (comma/newline separated). */ export async function POST(req: Request) { try { const body = (await req.json()) as Body; const siruta = String(body.siruta ?? "").trim(); const rawSearch = (body.search ?? "").trim(); if (!siruta || !/^\d+$/.test(siruta)) { return NextResponse.json( { error: "SIRUTA obligatoriu" }, { status: 400 }, ); } if (!rawSearch) { return NextResponse.json( { error: "Număr cadastral obligatoriu" }, { status: 400 }, ); } // Parse multiple cadastral numbers (comma, newline, space separated) const cadNumbers = rawSearch .split(/[\s,;\n]+/) .map((s) => s.trim()) .filter(Boolean); if (cadNumbers.length === 0) { return NextResponse.json( { error: "Număr cadastral obligatoriu" }, { status: 400 }, ); } // Credential chain: body > session > env const session = getSessionCredentials(); const username = String( body.username || session?.username || process.env.ETERRA_USERNAME || "", ).trim(); const password = String( body.password || 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); // Workspace resolution chain: body → DB → ArcGIS layer query let workspaceId = body.workspacePk ?? null; if (!workspaceId || !Number.isFinite(workspaceId)) { try { const dbUat = await prisma.gisUat.findUnique({ where: { siruta }, select: { workspacePk: true }, }); if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk; } catch { // DB lookup failed } } if (!workspaceId || !Number.isFinite(workspaceId)) { workspaceId = await resolveWorkspace(client, siruta); } if (!workspaceId) { return NextResponse.json( { error: "Nu s-a putut determina județul pentru UAT-ul selectat." }, { status: 400 }, ); } console.log("[search] siruta:", siruta, "workspaceId:", workspaceId); const results: ParcelDetail[] = []; for (const cadNr of cadNumbers) { try { // 1. Search immovable by identifier (exact match) const immResponse = await client.searchImmovableByIdentifier( workspaceId, siruta, cadNr, ); const items = immResponse?.content ?? []; if (items.length === 0) { // No result — add placeholder so user knows it wasn't found results.push({ nrCad: cadNr, nrCF: "", nrCFVechi: "", nrTopo: "", intravilan: "", categorieFolosinta: "", adresa: "", proprietari: "", proprietariActuali: "", proprietariVechi: "", suprafata: null, solicitant: "", immovablePk: "", }); continue; } for (const item of items) { const immPk = item?.immovablePk; const immPkStr = String(immPk ?? ""); // Basic data from immovable list let nrCF = String(item?.paperLbNo ?? item?.paperCadNo ?? ""); let nrCFVechi = ""; let nrTopo = String(item?.topNo ?? item?.paperCadNo ?? ""); let addressText = formatAddress(item); let proprietariActuali: string[] = []; let proprietariVechi: string[] = []; let solicitant = ""; let intravilan = ""; let categorie = ""; let suprafata: number | null = null; // Try multiple area fields const areaStr = item?.area ?? item?.areaValue ?? item?.areaMP ?? item?.suprafata; if (areaStr != null) { const parsed = Number(areaStr); if (Number.isFinite(parsed) && parsed > 0) suprafata = parsed; } // Log raw item keys once for debugging (first item only) if (results.length === 0) { console.log("[search] immovable item keys:", Object.keys(item ?? {})); console.log("[search] area fields:", { area: item?.area, areaValue: item?.areaValue, areaMP: item?.areaMP }); } // 2. Fetch documentation data (CF, proprietari) if (immPk) { try { const docResponse = await client.fetchDocumentationData( workspaceId, [immPk], ); // Extract doc details const docImm = (docResponse?.immovables ?? []).find( // eslint-disable-next-line @typescript-eslint/no-explicit-any (d: any) => String(d?.immovablePk) === immPkStr, ); if (docImm) { if (docImm.landbookIE) { const oldCF = nrCF; nrCF = String(docImm.landbookIE); if (oldCF && oldCF !== nrCF) nrCFVechi = oldCF; } if (docImm.topNo) nrTopo = String(docImm.topNo); if (docImm.area != null) { const docArea = Number(docImm.area); if (Number.isFinite(docArea)) suprafata = docArea; } } // Extract owners from partTwoRegs — separate active vs cancelled const activeOwners: string[] = []; const cancelledOwners: string[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const regs = docResponse?.partTwoRegs ?? []; if (regs.length > 0) { console.log("[search] partTwoRegs[0] keys:", Object.keys(regs[0])); console.log("[search] partTwoRegs sample:", JSON.stringify(regs[0]).slice(0, 500)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any regs.forEach((reg: any) => { if ( String(reg?.nodeType ?? "").toUpperCase() === "P" && reg?.nodeName ) { const name = String(reg.nodeName).trim(); if (!name) return; // Check if this entry is cancelled/radiated const isCancelled = reg?.cancelled === true || reg?.isActive === false || reg?.radiat === true || String(reg?.status ?? "").toUpperCase() === "RADIAT" || String(reg?.status ?? "").toUpperCase() === "CANCELLED" || reg?.radiationDate != null; if (isCancelled) { cancelledOwners.push(name); } else { activeOwners.push(name); } } }); proprietariActuali = Array.from(new Set(activeOwners)); proprietariVechi = Array.from(new Set(cancelledOwners)) .filter((n) => !proprietariActuali.includes(n)); } catch { // Documentation fetch failed — continue with basic data } } // 3. Fetch application data (solicitant, folosință, intravilan) if (immPk) { try { const apps = await client.fetchImmAppsByImmovable( immPk, workspaceId, ); // Pick most recent application const chosen = // eslint-disable-next-line @typescript-eslint/no-explicit-any (apps ?? []) .filter((a: any) => a?.dataCerere) // eslint-disable-next-line @typescript-eslint/no-explicit-any .sort( (a: any, b: any) => (b.dataCerere ?? 0) - (a.dataCerere ?? 0), )[0] ?? apps?.[0]; if (chosen) { solicitant = String(chosen.solicitant ?? chosen.deponent ?? ""); const appId = chosen.applicationId; if (appId) { try { const fol = await client.fetchParcelFolosinte( workspaceId, immPk, appId, ); intravilan = normalizeIntravilan( // eslint-disable-next-line @typescript-eslint/no-explicit-any (fol ?? []).map((f: any) => f?.intravilan ?? ""), ); categorie = formatCategories(fol ?? []); } catch { // folosinta fetch failed } } } } catch { // immApps fetch failed } } const allOwners = [...proprietariActuali, ...proprietariVechi]; results.push({ nrCad: String(item?.identifierDetails ?? cadNr), nrCF, nrCFVechi, nrTopo, intravilan, categorieFolosinta: categorie, adresa: addressText, proprietari: allOwners.join("; "), proprietariActuali: proprietariActuali.join("; "), proprietariVechi: proprietariVechi.join("; "), suprafata, solicitant, immovablePk: immPkStr, }); } } catch { // Error for this particular cadNr — add placeholder results.push({ nrCad: cadNr, nrCF: "", nrCFVechi: "", nrTopo: "", intravilan: "", categorieFolosinta: "", adresa: "", proprietari: "", proprietariActuali: "", proprietariVechi: "", suprafata: null, solicitant: "", immovablePk: "", }); } } return NextResponse.json({ results, total: results.length, source: "eterra-app", }); } catch (error) { const message = error instanceof Error ? error.message : "Eroare server"; return NextResponse.json({ error: message }, { status: 500 }); } }