/** * eTerra ANCPI client — server-side only. * * Authenticates via form-post, keeps a JSESSIONID cookie jar, * and exposes typed wrappers for count / list / fetchAll / parcel-detail * queries against the eTerra ArcGIS-REST API. * * Ported from the standalone ParcelSync project and adapted for * ArchiTools module architecture. */ import axios, { type AxiosError, type AxiosInstance } from "axios"; import crypto from "crypto"; import { wrapper } from "axios-cookiejar-support"; import { CookieJar } from "tough-cookie"; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ export type EsriGeometry = { rings: number[][][] }; export type EsriFeature = { attributes: Record; geometry?: EsriGeometry; }; export type LayerEndpoint = "aut" | "all"; export type LayerConfig = { id: string; name: string; endpoint: LayerEndpoint; subLayerId?: number; whereTemplate?: string; spatialFilter?: boolean; }; type EsriQueryResponse = { error?: { message?: string; details?: string[] }; count?: number; objectIds?: number[]; objectIdFieldName?: string; features?: EsriFeature[]; exceededTransferLimit?: boolean; }; type EsriLayerInfo = { fields?: { name: string }[]; error?: { message?: string; details?: string[] }; }; type ProgressCallback = (downloaded: number, total?: number) => void; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ const BASE_URL = "https://eterra.ancpi.ro/eterra"; const LOGIN_URL = `${BASE_URL}/api/authentication`; const DEFAULT_TIMEOUT_MS = 120_000; const DEFAULT_PAGE_SIZE = 1000; const PAGE_SIZE_FALLBACKS = [500, 200]; const MAX_RETRIES = 2; const SESSION_TTL_MS = 9 * 60 * 1000; const MAX_URL_LENGTH = 1500; /* ------------------------------------------------------------------ */ /* Session cache (global singleton) */ /* ------------------------------------------------------------------ */ type SessionEntry = { jar: CookieJar; client: AxiosInstance; createdAt: number; lastUsed: number; }; const globalStore = globalThis as { __eterraSessionStore?: Map; __eterraCleanupTimer?: ReturnType; }; const sessionStore = globalStore.__eterraSessionStore ?? new Map(); globalStore.__eterraSessionStore = sessionStore; // Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL) if (!globalStore.__eterraCleanupTimer) { globalStore.__eterraCleanupTimer = setInterval(() => { const now = Date.now(); for (const [key, entry] of sessionStore.entries()) { if (now - entry.lastUsed > 9 * 60_000) { sessionStore.delete(key); } } }, 5 * 60_000); } const makeCacheKey = (u: string, p: string) => crypto.createHash("sha256").update(`${u}:${p}`).digest("hex"); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ const isTransient = (error: unknown) => { const err = error as AxiosError; if (!err) return false; if ( err.code === "ECONNRESET" || err.code === "ETIMEDOUT" || err.code === "ECONNABORTED" ) return true; const status = err.response?.status ?? 0; return status >= 500; }; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /* ------------------------------------------------------------------ */ /* Client */ /* ------------------------------------------------------------------ */ export class EterraClient { private client: AxiosInstance; private jar: CookieJar; private timeoutMs: number; private maxRetries: number; private username: string; private password: string; private cacheKey: string; private layerFieldsCache = new Map(); private constructor( client: AxiosInstance, jar: CookieJar, timeoutMs: number, username: string, password: string, maxRetries: number, ) { this.client = client; this.jar = jar; this.timeoutMs = timeoutMs; this.username = username; this.password = password; this.maxRetries = maxRetries; this.cacheKey = makeCacheKey(username, password); } /* ---- Factory --------------------------------------------------- */ static async create( username: string, password: string, options?: { timeoutMs?: number; maxRetries?: number; useCache?: boolean }, ) { const useCache = options?.useCache !== false; const now = Date.now(); const cacheKey = makeCacheKey(username, password); const cached = sessionStore.get(cacheKey); if (useCache && cached && now - cached.lastUsed < SESSION_TTL_MS) { cached.lastUsed = now; return new EterraClient( cached.client, cached.jar, options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, username, password, options?.maxRetries ?? MAX_RETRIES, ); } const jar = new CookieJar(); const client = wrapper( axios.create({ jar, withCredentials: true, maxContentLength: Infinity, maxBodyLength: Infinity, headers: { Accept: "application/json, text/plain, */*", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", Referer: `${BASE_URL}/`, }, }), ); const instance = new EterraClient( client, jar, options?.timeoutMs ?? DEFAULT_TIMEOUT_MS, username, password, options?.maxRetries ?? MAX_RETRIES, ); await instance.login(username, password); sessionStore.set(cacheKey, { jar, client, createdAt: now, lastUsed: now }); return instance; } /* ---- Auth ------------------------------------------------------ */ private async login(u: string, p: string) { const body = new URLSearchParams(); body.set("j_username", u); body.set("j_password", p); body.set("j_uuid", "undefined"); body.set("j_isRevoked", "undefined"); body.set("_spring_security_remember_me", "true"); body.set("submit", "Login"); try { await this.requestWithRetry(() => this.client.post(LOGIN_URL, body.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: this.timeoutMs, }), ); } catch (error) { const err = error as AxiosError; if (err?.response?.status === 401) throw new Error("Login failed (invalid credentials)"); throw error; } const cookies = await this.jar.getCookies(LOGIN_URL); if (!cookies.some((c) => c.key === "JSESSIONID")) throw new Error("Login failed / session not set"); // Activate RGI module context by loading the RGI page // This may set additional session attributes needed for document downloads try { await this.client.get(`${BASE_URL}/`, { timeout: 10_000 }); } catch { // Non-critical } } /* ---- High-level parcels --------------------------------------- */ async countParcels(siruta: string) { return this.countLayer( { id: "TERENURI_ACTIVE", name: "TERENURI_ACTIVE", endpoint: "aut", whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1", }, siruta, ); } async fetchAllParcels( siruta: string, options?: { pageSize?: number; total?: number; onProgress?: ProgressCallback; delayMs?: number; }, ) { return this.fetchAllLayer( { id: "TERENURI_ACTIVE", name: "TERENURI_ACTIVE", endpoint: "aut", whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1", }, siruta, options, ); } /* ---- Generic layer methods ------------------------------------ */ async countLayer(layer: LayerConfig, siruta: string) { const where = await this.buildWhere(layer, siruta); return this.countLayerByWhere(layer, where); } async countLayerByWhere(layer: LayerConfig, where: string) { const params = new URLSearchParams(); params.set("f", "json"); params.set("where", where); return this.countLayerWithParams(layer, params, false); } async countLayerByGeometry(layer: LayerConfig, geometry: EsriGeometry) { const params = new URLSearchParams(); params.set("f", "json"); params.set("where", "1=1"); this.applyGeometryParams(params, geometry); return this.countLayerWithParams(layer, params, true); } /* ---- Incremental sync: fetch only OBJECTIDs -------------------- */ async fetchObjectIds(layer: LayerConfig, siruta: string): Promise { const where = await this.buildWhere(layer, siruta); return this.fetchObjectIdsByWhere(layer, where); } async fetchObjectIdsByWhere( layer: LayerConfig, where: string, ): Promise { const params = new URLSearchParams(); params.set("f", "json"); params.set("where", where); params.set("returnIdsOnly", "true"); const data = await this.queryLayer(layer, params, false); return data.objectIds ?? []; } async fetchObjectIdsByGeometry( layer: LayerConfig, geometry: EsriGeometry, ): Promise { const params = new URLSearchParams(); params.set("f", "json"); params.set("where", "1=1"); params.set("returnIdsOnly", "true"); this.applyGeometryParams(params, geometry); const data = await this.queryLayer(layer, params, true); return data.objectIds ?? []; } /* ---- Fetch specific features by OBJECTID list ------------------- */ async fetchFeaturesByObjectIds( layer: LayerConfig, objectIds: number[], options?: { baseWhere?: string; outFields?: string; returnGeometry?: boolean; onProgress?: ProgressCallback; delayMs?: number; }, ): Promise { if (objectIds.length === 0) return []; const chunkSize = 500; const all: EsriFeature[] = []; const total = objectIds.length; for (let i = 0; i < objectIds.length; i += chunkSize) { const chunk = objectIds.slice(i, i + chunkSize); const idList = chunk.join(","); const idWhere = `OBJECTID IN (${idList})`; const where = options?.baseWhere ? `(${options.baseWhere}) AND ${idWhere}` : idWhere; try { const features = await this.fetchAllLayerByWhere(layer, where, { outFields: options?.outFields ?? "*", returnGeometry: options?.returnGeometry ?? true, delayMs: options?.delayMs ?? 200, }); all.push(...features); } catch (err) { // Log but continue with remaining chunks — partial results better than none const msg = err instanceof Error ? err.message : String(err); console.warn( `[fetchFeaturesByObjectIds] Chunk ${Math.floor(i / chunkSize) + 1} failed (${chunk.length} IDs): ${msg}`, ); } options?.onProgress?.(all.length, total); } return all; } async listLayer( layer: LayerConfig, siruta: string, options?: { limit?: number; outFields?: string }, ) { const where = await this.buildWhere(layer, siruta); return this.listLayerByWhere(layer, where, options); } async listLayerByWhere( layer: LayerConfig, where: string, options?: { limit?: number; outFields?: string }, ) { const params = new URLSearchParams(); params.set("f", "json"); params.set("where", where); params.set("outFields", options?.outFields ?? "*"); params.set("returnGeometry", "false"); params.set("resultRecordCount", String(options?.limit ?? 200)); params.set("resultOffset", "0"); const data = await this.queryLayer(layer, params, false); return data.features ?? []; } async fetchAllLayer( layer: LayerConfig, siruta: string, options?: { pageSize?: number; total?: number; onProgress?: ProgressCallback; delayMs?: number; outFields?: string; returnGeometry?: boolean; }, ) { const where = await this.buildWhere(layer, siruta); return this.fetchAllLayerByWhere(layer, where, options); } async fetchAllLayerByWhere( layer: LayerConfig, where: string, options?: { pageSize?: number; total?: number; onProgress?: ProgressCallback; delayMs?: number; outFields?: string; returnGeometry?: boolean; geometry?: EsriGeometry; }, ) { let pageSize = options?.pageSize ?? DEFAULT_PAGE_SIZE; const total = options?.total; const onProgress = options?.onProgress; const delayMs = options?.delayMs ?? 0; const outFields = options?.outFields ?? "*"; const returnGeometry = options?.returnGeometry ?? true; let offset = 0; const all: EsriFeature[] = []; // ArcGIS servers have a maxRecordCount (typically 1000). // If we request 2000 but get exactly 1000, we hit the server cap. // Track this so we continue paginating instead of stopping. let serverMaxRecordCount: number | null = null; let retried = false; // one retry per page for transient ArcGIS errors while (true) { const params = new URLSearchParams(); params.set("f", "json"); params.set("where", where); if (options?.geometry) this.applyGeometryParams(params, options.geometry); params.set("outFields", outFields); params.set("returnGeometry", returnGeometry ? "true" : "false"); if (returnGeometry) params.set("outSR", "3844"); params.set("resultRecordCount", String(pageSize)); params.set("resultOffset", String(offset)); let data: EsriQueryResponse; try { data = await this.queryLayer(layer, params, Boolean(options?.geometry)); } catch (err) { const cause = err instanceof Error ? err.message : String(err); const isQueryError = cause.includes("Error performing query"); // ArcGIS "Error performing query" — retry same page size first // (often a transient server-side timeout), then try smaller. if (isQueryError && !retried) { retried = true; await sleep(2000); continue; } retried = false; // Try next smaller page size const nextSize = PAGE_SIZE_FALLBACKS.find((s) => s < pageSize); if (nextSize) { pageSize = nextSize; await sleep(1000); continue; } throw new Error(`Failed to fetch layer ${layer.name}: ${cause}`); } retried = false; // reset on success const features = data.features ?? []; if (features.length === 0) { const nextSize = PAGE_SIZE_FALLBACKS.find((s) => s < pageSize); if (total && all.length < total && nextSize) { pageSize = nextSize; continue; } break; } // Detect server maxRecordCount cap: // If we asked for more than we got AND the result is a round number // (1000, 2000), the server likely capped us. Adjust pageSize to match. if ( serverMaxRecordCount === null && features.length < pageSize && features.length > 0 && features.length % 500 === 0 // round cap: 500, 1000, 1500, 2000... ) { serverMaxRecordCount = features.length; pageSize = serverMaxRecordCount; // Don't break — this is a full page at server's cap, continue } all.push(...features); offset += features.length; if (onProgress) onProgress(all.length, total); if (total && all.length >= total) break; // End of data: fewer features than the effective page size const effectivePageSize = serverMaxRecordCount ?? pageSize; if (features.length < effectivePageSize) { const nextSize = PAGE_SIZE_FALLBACKS.find((s) => s < pageSize); if (total && all.length < total && nextSize) { pageSize = nextSize; continue; } break; } if (delayMs > 0) await sleep(delayMs); } return all; } async fetchAllLayerByGeometry( layer: LayerConfig, geometry: EsriGeometry, options?: { pageSize?: number; total?: number; onProgress?: ProgressCallback; delayMs?: number; outFields?: string; returnGeometry?: boolean; }, ) { return this.fetchAllLayerByWhere(layer, "1=1", { ...options, geometry }); } async getLayerFieldNames(layer: LayerConfig) { return this.getLayerFields(layer); } /* ---- Magic-mode methods (eTerra application APIs) ------------- */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async fetchImmAppsByImmovable( immovableId: string | number, workspaceId: string | number, ): Promise { const url = `${BASE_URL}/api/immApps/byImm/list/${immovableId}/${workspaceId}`; return this.getRawJson(url); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async fetchParcelFolosinte( workspaceId: string | number, immovableId: string | number, applicationId: string | number, page = 1, ): Promise { const url = `${BASE_URL}/api/immApps/parcels/list/${workspaceId}/${immovableId}/${applicationId}/${page}`; return this.getRawJson(url); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async fetchImmovableListByAdminUnit( workspaceId: string | number, adminUnitId: string | number, page = 0, size = 200, includeInscrisCF = true, ): Promise { const url = `${BASE_URL}/api/immovable/list`; const filters: Array<{ value: string | number; type: "NUMBER" | "STRING"; key: string; op: string; }> = [ { value: Number(workspaceId), type: "NUMBER", key: "workspace.nomenPk", op: "=", }, { value: Number(adminUnitId), type: "NUMBER", key: "adminUnit.nomenPk", op: "=", }, { value: "C", type: "STRING", key: "immovableType", op: "<>C" }, ]; if (includeInscrisCF) { filters.push({ value: -1, type: "NUMBER", key: "inscrisCF", op: "=" }); } const payload = { filters, nrElements: size, page, sorters: [] }; return this.requestRaw(() => this.client.post(url, payload, { headers: { "Content-Type": "application/json;charset=UTF-8" }, timeout: this.timeoutMs, }), ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async fetchDocumentationData( workspaceId: string | number, immovableIds: Array, ): Promise { const url = `${BASE_URL}/api/documentation/data/`; const payload = { workflowCode: "EXPLORE_DATABASE", activityCode: "EXPLORE", applicationId: 0, workspaceId: Number(workspaceId), immovables: immovableIds.map((id) => Number(id)).filter(Number.isFinite), }; return this.requestRaw(() => this.client.post(url, payload, { headers: { "Content-Type": "application/json;charset=UTF-8" }, timeout: this.timeoutMs, }), ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any async fetchImmovableParcelDetails( workspaceId: string | number, immovableId: string | number, page = 1, size = 20, ): Promise { const url = `${BASE_URL}/api/immovable/details/parcels/list/${workspaceId}/${immovableId}/${page}/${size}`; return this.getRawJson(url); } /** * Check if an immovable is an "Imobil Electronic" (IE) in eTerra. * Uses the same endpoint the eTerra UI calls when searching by * topNo + paperCfNo. * * @returns true if the immovable is registered as IE, false otherwise */ async checkIfIsIE( adminUnitId: string | number, paperCadNo: number | null, topNo: string | number, paperCfNo: string | number, ): Promise { const url = `${BASE_URL}/api/immovable/checkIfIsIE`; const payload = { adminUnitId: Number(adminUnitId), paperCadNo: Number(paperCadNo ?? 0), topNo: typeof topNo === "string" ? Number(topNo.split(",")[0]) || 0 : Number(topNo), paperCfNo: Number(paperCfNo), }; try { const result = await this.requestRaw(() => this.client.post(url, payload, { headers: { "Content-Type": "application/json;charset=UTF-8" }, timeout: this.timeoutMs, }), ); // API may return boolean, number (1/0), or string return result === true || result === 1 || String(result) === "true"; } catch { return false; } } /** * Build the URL for downloading a CF (carte funciară) extract PDF. * The eTerra UI calls this to get the landbook/CF PDF blob. * * @returns URL string (caller needs an authenticated session to fetch) */ getCfExtractUrl( immovablePk: string | number, workspaceId: string | number, ): string { return `${BASE_URL}/api/cf/landbook/copycf/get/${immovablePk}/${workspaceId}/0/true`; } /** * Search immovable list by exact cadastral number (identifierDetails). * This is the eTerra application API that the web UI uses when you type * a cadastral number in the search box. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async searchImmovableByIdentifier( workspaceId: string | number, adminUnitId: string | number, identifierDetails: string, page = 0, size = 10, ): Promise { const url = `${BASE_URL}/api/immovable/list`; const filters: Array<{ value: string | number; type: "NUMBER" | "STRING"; key: string; op: string; }> = [ { value: Number(workspaceId), type: "NUMBER", key: "workspace.nomenPk", op: "=", }, { value: Number(adminUnitId), type: "NUMBER", key: "adminUnit.nomenPk", op: "=", }, { value: identifierDetails, type: "STRING", key: "identifierDetails", op: "=", }, { value: -1, type: "NUMBER", key: "inscrisCF", op: "=" }, { value: "P", type: "STRING", key: "immovableType", op: "<>C" }, ]; const payload = { filters, nrElements: size, page, sorters: [] }; return this.requestRaw(() => this.client.post(url, payload, { headers: { "Content-Type": "application/json;charset=UTF-8" }, timeout: this.timeoutMs, }), ); } /** * Search immovable list by owner/titular name. * * Uses the same `/api/immovable/list` endpoint, with a `personName` * filter key. eTerra supports partial matching on this field. * * Falls back to `titularName` key if personName returns empty. * * @returns Paginated response with `content[]`, `totalPages`, `totalElements` */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async searchImmovableByOwnerName( workspaceId: string | number, adminUnitId: string | number, ownerName: string, page = 0, size = 20, ): Promise { const url = `${BASE_URL}/api/immovable/list`; const baseFilters: Array<{ value: string | number; type: "NUMBER" | "STRING"; key: string; op: string; }> = [ { value: Number(workspaceId), type: "NUMBER", key: "workspace.nomenPk", op: "=", }, { value: Number(adminUnitId), type: "NUMBER", key: "adminUnit.nomenPk", op: "=", }, { value: -1, type: "NUMBER", key: "inscrisCF", op: "=" }, ]; // Try primary key: personName (used in some eTerra versions) const keysToTry = ["personName", "titularName", "ownerName"]; for (const filterKey of keysToTry) { try { const filters = [ ...baseFilters, { value: ownerName, type: "STRING" as const, key: filterKey, op: "=", }, ]; const payload = { filters, nrElements: size, page, sorters: [] }; const result = await this.requestRaw(() => this.client.post(url, payload, { headers: { "Content-Type": "application/json;charset=UTF-8" }, timeout: this.timeoutMs, }), ); // If we got content back (even empty), it means the key is valid if (result && typeof result === "object" && "content" in result) { console.log( `[searchByOwner] Key "${filterKey}" worked — ${(result as any).totalElements ?? 0} results`, ); return result; } } catch (e) { console.log( `[searchByOwner] Key "${filterKey}" failed:`, e instanceof Error ? e.message : e, ); // Try next key } } // All keys failed — return empty result return { content: [], totalElements: 0, totalPages: 0 }; } /** * Fetch all counties (workspaces) from eTerra nomenclature. * Returns array of { nomenPk, name, parentNomenPk, ... } */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async fetchCounties(): Promise { const url = `${BASE_URL}/api/adm/nomen/COUNTY/list`; return this.getRawJson(url); } /** * Fetch a single nomenclature entry by nomenPk. * Returns { nomenPk, name, parentNomenPk, nomenType, ... } or null */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async fetchNomenByPk(nomenPk: string | number): Promise { const url = `${BASE_URL}/api/adm/nomen/${nomenPk}`; try { return await this.getRawJson(url); } catch { return null; } } /** * Fetch administrative units (UATs) under a county workspace. * Returns array of { nomenPk, name, parentNomenPk, ... } */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async fetchAdminUnitsByCounty( countyNomenPk: string | number, ): Promise { const url = `${BASE_URL}/api/adm/nomen/ADMINISTRATIVEUNIT/filterByParent/${countyNomenPk}`; return this.getRawJson(url); } /* ---- Internals ------------------------------------------------ */ private layerQueryUrl(layer: LayerConfig) { const suffix = typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : ""; return `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}/query`; } private applyGeometryParams(params: URLSearchParams, geometry: EsriGeometry) { params.set("geometry", JSON.stringify(geometry)); params.set("geometryType", "esriGeometryPolygon"); params.set("spatialRel", "esriSpatialRelIntersects"); params.set("inSR", "3844"); } private async buildWhere(layer: LayerConfig, siruta: string) { const fields = await this.getLayerFields(layer); const adminField = this.findAdminField(fields); if (!adminField) throw new Error(`Layer ${layer.name} has no SIRUTA/ADMIN_UNIT field`); if (!layer.whereTemplate) return `${adminField}=${siruta}`; const hasIsActive = fields.some((f) => f.toUpperCase() === "IS_ACTIVE"); if (layer.whereTemplate.includes("IS_ACTIVE") && !hasIsActive) return `${adminField}=${siruta}`; return layer.whereTemplate .replace(/\{\{adminField\}\}/g, adminField) .replace(/\{\{siruta\}\}/g, siruta); } private findAdminField(fields: string[]) { const preferred = [ "ADMIN_UNIT_ID", "SIRUTA", "UAT_ID", "SIRUTA_UAT", "UAT_SIRUTA", ]; const upper = fields.map((f) => f.toUpperCase()); for (const key of preferred) { const idx = upper.indexOf(key); if (idx >= 0) return fields[idx]; } return null; } private async getLayerFields(layer: LayerConfig) { const cacheKey = `${layer.endpoint}:${layer.name}`; const cached = this.layerFieldsCache.get(cacheKey); if (cached) return cached; const suffix = typeof layer.subLayerId === "number" ? `/${layer.subLayerId}` : ""; const url = `${BASE_URL}/api/map/rest/${layer.endpoint}/layer/${layer.name}${suffix}?f=json`; let response; try { response = await this.requestWithRetry(() => this.client.get(url, { timeout: this.timeoutMs }), ); } catch (error) { const err = error as AxiosError; if (err?.response?.status === 401) { await this.login(this.username, this.password); response = await this.requestWithRetry(() => this.client.get(url, { timeout: this.timeoutMs }), ); } else throw error; } const data = response.data as EsriLayerInfo | string; if (typeof data === "string") throw new Error(`Layer info invalid for ${layer.name}`); if (data.error) { const details = data.error.details?.join(" ") ?? ""; throw new Error( `${data.error.message ?? "Layer error"}${details ? ` (${details})` : ""}`, ); } let fields = data.fields?.map((f) => f.name) ?? []; if (!fields.length) { const params = new URLSearchParams(); params.set("f", "json"); params.set("where", "1=1"); params.set("outFields", "*"); params.set("returnGeometry", "false"); params.set("resultRecordCount", "1"); params.set("resultOffset", "0"); const sample = await this.getJson( `${this.layerQueryUrl(layer)}?${params}`, ); fields = Object.keys(sample.features?.[0]?.attributes ?? {}); } if (!fields.length) throw new Error(`Layer ${layer.name} returned no fields`); this.layerFieldsCache.set(cacheKey, fields); return fields; } private async getJson(url: string): Promise { return this.requestJson(() => this.client.get(url, { timeout: this.timeoutMs }), ); } // eslint-disable-next-line @typescript-eslint/no-explicit-any private async getRawJson(url: string): Promise { return this.requestRaw(() => this.client.get(url, { timeout: this.timeoutMs }), ); } private async postJson( url: string, body: URLSearchParams, ): Promise { return this.requestJson(() => this.client.post(url, body.toString(), { headers: { "Content-Type": "application/x-www-form-urlencoded" }, timeout: this.timeoutMs, }), ); } /** Touch session TTL in global store (prevents expiry during long pagination) */ private touchSession(): void { const cached = sessionStore.get(this.cacheKey); if (cached) cached.lastUsed = Date.now(); } private async requestJson( request: () => Promise<{ data: EsriQueryResponse | string; status: number; }>, ): Promise { this.touchSession(); let response; try { response = await this.requestWithRetry(request); } catch (error) { const err = error as AxiosError; if (err?.response?.status === 401) { // Always attempt relogin on 401 (session may expire multiple times during long syncs) await this.login(this.username, this.password); response = await this.requestWithRetry(request); } else throw error; } const data = response.data as EsriQueryResponse | string; if (typeof data === "string") throw new Error("Session expired or invalid response from eTerra"); if (data.error) { const details = data.error.details?.join(" ") ?? ""; throw new Error( `${data.error.message ?? "eTerra query error"}${details ? ` (${details})` : ""}`, ); } return data; } // eslint-disable-next-line @typescript-eslint/no-explicit-any private async requestRaw( request: () => Promise<{ data: T | string; status: number }>, ): Promise { this.touchSession(); let response; try { response = await this.requestWithRetry(request); } catch (error) { const err = error as AxiosError; if (err?.response?.status === 401) { await this.login(this.username, this.password); response = await this.requestWithRetry(request); } else throw error; } const data = response.data as T | string; if (typeof data === "string") { try { return JSON.parse(data) as T; } catch { throw new Error("Session expired or invalid response from eTerra"); } } return data; } private async queryLayer( layer: LayerConfig, params: URLSearchParams, usePost: boolean, ): Promise { const url = this.layerQueryUrl(layer); const qs = params.toString(); const shouldPost = usePost || qs.length > MAX_URL_LENGTH; if (shouldPost) return this.postJson(url, params); try { return await this.getJson(`${url}?${qs}`); } catch (error) { const err = error as AxiosError; if (err?.response?.status === 414) return this.postJson(url, params); throw error; } } private async countLayerWithParams( layer: LayerConfig, params: URLSearchParams, usePost: boolean, ) { const cp = new URLSearchParams(params); cp.set("returnCountOnly", "true"); const data = await this.queryLayer(layer, cp, usePost); if (typeof data.count === "number") return data.count; const message = data.error?.message; throw new Error( message ? `Count unavailable (${layer.name}): ${message}` : `Count unavailable (${layer.name})`, ); } /* ── RGI (Registrul General de Intrare) API ───────────────── */ /** * Generic RGI POST request with JSON body. * Uses the same JSESSIONID cookie jar as GIS queries. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async rgiPost(path: string, body?: unknown): Promise { const url = `${BASE_URL}/api/${path}`; return this.requestRaw(() => this.client.post(url, body ?? {}, { headers: { "Content-Type": "application/json;charset=UTF-8" }, timeout: this.timeoutMs, }), ); } /** * Generic RGI GET request. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any async rgiGet(path: string): Promise { const url = `${BASE_URL}/api/${path}`; return this.requestRaw(() => this.client.get(url, { timeout: this.timeoutMs }), ); } /** * Download a file from RGI (returns Buffer). */ async rgiDownload(path: string): Promise<{ data: Buffer; contentType: string; filename: string }> { const url = `${BASE_URL}/api/${path}`; const doRequest = () => this.client.get(url, { timeout: this.timeoutMs, responseType: "arraybuffer", // Override Accept header for binary download headers: { Accept: "*/*" }, // Don't throw on any status — we'll check manually validateStatus: () => true, }); let response = await doRequest(); // If 401/302/404 (session expired → redirect to login), re-login and retry if (response.status === 401 || response.status === 302 || response.status === 404) { await this.login(this.username, this.password); response = await doRequest(); } if (response.status !== 200) { throw new Error(`Download failed: HTTP ${response.status}`); } const headers = response.headers as Record | undefined; const cd = headers?.["content-disposition"] ?? ""; const match = /filename="?([^"]+)"?/.exec(cd); return { data: Buffer.from(response.data as ArrayBuffer), contentType: headers?.["content-type"] ?? "application/octet-stream", filename: match?.[1] ?? "document.pdf", }; } private async requestWithRetry(request: () => Promise) { let attempt = 0; while (true) { try { return await request(); } catch (error) { if (attempt >= this.maxRetries || !isTransient(error)) throw error; attempt += 1; await sleep(300 * attempt); } } } }