// Thin server-side client for api.gis.ac (Plan 003, Faza D). // // SERVER-SIDE ONLY. Never import from a "use client" component — token // extraction depends on NextAuth getServerSession + Authentik access_token // exposed by auth-options.ts. Browser code must call internal ArchiTools // API routes that wrap gisApi.* on the server. // // All methods auto-extract the Authentik access_token from the current // request's NextAuth session. Pass { accessToken } in opts to override // (only useful for service-account contexts, e.g. background jobs). // // Endpoint table + scope semantics: docs/plans/003-architools-cutover- // execution-2026-05-17.md §Faza D. import { getServerSession } from "next-auth"; import { authOptions } from "@/core/auth/auth-options"; const GIS_API_URL = process.env.GIS_API_URL || "https://api.gis.ac"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type EnrichmentScope = "none" | "basic" | "full"; export interface AuthClaims { sub: string; tenant: string; org_ids: string[]; is_beletage_group: boolean; enrichment_scope: EnrichmentScope; email?: string; } export interface ParcelRefBody { siruta: string; cadastralRef: string; force?: boolean; } export interface ImmAppsBody extends ParcelRefBody { layerId?: "TERENURI_ACTIVE" | "CLADIRI_ACTIVE"; } export interface CfExtractRow { id: string; userId: string; nrCadastral: string; nrCF?: string; status: | "pending" | "queued" | "cart" | "ordering" | "polling" | "downloading" | "completed" | "failed" | "cancelled"; epayStatus?: string; orderId?: string; basketRowId?: string; documentName?: string; documentDate?: string; minioPath?: string; completedAt?: string; expiresAt?: string; createdAt: string; updatedAt: string; } export interface CfListResponse { total: number; limit: number; offset: number; rows: CfExtractRow[]; } export interface RateLimit { limit: number; remaining: number; resetSec: number; } // --------------------------------------------------------------------------- // Error type // --------------------------------------------------------------------------- export class GisApiError extends Error { constructor( public status: number, public code: string, public body?: unknown, public rateLimit?: RateLimit, ) { super(`${code} (HTTP ${status})`); this.name = "GisApiError"; } } // --------------------------------------------------------------------------- // Internal request helper // --------------------------------------------------------------------------- interface RequestOpts { accessToken?: string; method?: string; body?: BodyInit | null; headers?: Record; query?: Record; } async function bearerFromSession(): Promise { const session = await getServerSession(authOptions); const tok = (session as { accessToken?: string } | null)?.accessToken; if (!tok) { throw new GisApiError(401, "no_session_access_token", { hint: "User not signed in or NextAuth session has no Authentik access_token", }); } return tok; } function buildUrl(path: string, query?: RequestOpts["query"]): string { const url = new URL(`${GIS_API_URL}${path}`); if (query) { for (const [k, v] of Object.entries(query)) { if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); } } return url.toString(); } function parseRateLimit(res: Response): RateLimit | undefined { const limit = res.headers.get("X-RateLimit-Limit"); const remaining = res.headers.get("X-RateLimit-Remaining"); const reset = res.headers.get("X-RateLimit-Reset"); if (!limit) return undefined; return { limit: Number(limit), remaining: Number(remaining) || 0, resetSec: Number(reset) || 0, }; } // Default per-request timeout. Light queries (search, parcela.get) usually // finish in <1s; proxy calls (parcel/tech) hit ANCPI live and can take 5-15s. const DEFAULT_TIMEOUT_MS = 30_000; async function request(path: string, opts: RequestOpts = {}): Promise { const token = opts.accessToken || (await bearerFromSession()); let res: Response; try { res = await fetch(buildUrl(path, opts.query), { method: opts.method || "GET", body: opts.body ?? null, headers: { Authorization: `Bearer ${token}`, Accept: "application/json", ...opts.headers, }, cache: "no-store", signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), }); } catch (err) { const isAbort = (err as { name?: string })?.name === "TimeoutError" || (err as { name?: string })?.name === "AbortError"; throw new GisApiError( 504, isAbort ? "upstream_timeout" : "upstream_unreachable", { hint: (err as Error)?.message }, ); } const rateLimit = parseRateLimit(res); if (!res.ok) { let body: unknown = null; try { body = await res.json(); } catch { body = await res.text(); } const code = (body as { error?: string })?.error || `gis_api_${res.status}`; throw new GisApiError(res.status, code, body, rateLimit); } // Streaming endpoints (PDF) need raw Response — caller decides if (res.headers.get("Content-Type")?.startsWith("application/pdf")) { return res as unknown as T; } return (await res.json()) as T; } async function rawResponse(path: string, opts: RequestOpts = {}): Promise { const token = opts.accessToken || (await bearerFromSession()); let res: Response; try { res = await fetch(buildUrl(path, opts.query), { method: opts.method || "GET", body: opts.body ?? null, headers: { Authorization: `Bearer ${token}`, ...opts.headers, }, cache: "no-store", signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS), }); } catch (err) { const isAbort = (err as { name?: string })?.name === "TimeoutError" || (err as { name?: string })?.name === "AbortError"; throw new GisApiError( 504, isAbort ? "upstream_timeout" : "upstream_unreachable", { hint: (err as Error)?.message }, ); } if (!res.ok) { let body: unknown = null; try { body = await res.clone().json(); } catch { body = await res.clone().text(); } const code = (body as { error?: string })?.error || `gis_api_${res.status}`; throw new GisApiError(res.status, code, body, parseRateLimit(res)); } return res; } // --------------------------------------------------------------------------- // Public surface // --------------------------------------------------------------------------- export interface GisApiCallOpts { accessToken?: string; } export const gisApi = { me: (opts: GisApiCallOpts = {}) => request<{ claims: AuthClaims }>("/api/v1/me", { accessToken: opts.accessToken }), parcela: { get: (id: string, opts: GisApiCallOpts = {}) => request(`/api/v1/parcela/${encodeURIComponent(id)}`, { accessToken: opts.accessToken, }), // GET /api/v1/parcela/by-ref?siruta&cad&layerId — indexed lookup that // skips the cadref-trigram-then-filter dance. Use when a click arrives // without a uuid in the tile properties (PMTiles overview today). Same // response shape as parcela.get; 404 when no match; 403 on scope=none. byRef: ( body: { siruta: string; cadastralRef: string; layerId: string }, opts: GisApiCallOpts = {}, ) => request("/api/v1/parcela/by-ref", { query: { siruta: body.siruta, cad: body.cadastralRef, layerId: body.layerId, }, accessToken: opts.accessToken, }), }, search: (q: string, limit = 50, opts: GisApiCallOpts = {}) => request<{ q: string; uats: Array<{ siruta: string; name: string; county: string }>; features: Array<{ id: string; layerId: string; siruta: string; cadastralRef: string; areaValue?: number; }>; }>("/api/v1/search", { query: { q, limit }, accessToken: opts.accessToken, }), parcel: { tech: (body: ParcelRefBody, opts: GisApiCallOpts = {}) => request("/api/v1/parcel/tech", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), accessToken: opts.accessToken, }), // Deep-enrich (PR3 / gis-api 09f1ab8): orchestrator looks up eTerra // immovable, fetches documentation + parcel details, parses NR_CF + // ADRESA + PROPRIETARI + 20+ more fields and merges into gis_core. // Cache TTL ~30d; pass force=true to bypass. enrich: (body: ParcelRefBody, opts: GisApiCallOpts = {}) => request("/api/v1/parcel/enrich", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), accessToken: opts.accessToken, }), unitsFetch: (body: ParcelRefBody, opts: GisApiCallOpts = {}) => request("/api/v1/parcel/units/fetch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), accessToken: opts.accessToken, }), immApps: (body: ImmAppsBody, opts: GisApiCallOpts = {}) => request("/api/v1/parcel/imm-apps", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), accessToken: opts.accessToken, }), }, building: { tech: (body: ParcelRefBody, opts: GisApiCallOpts = {}) => request("/api/v1/building/tech", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), accessToken: opts.accessToken, }), condoOwners: (body: ParcelRefBody, opts: GisApiCallOpts = {}) => request("/api/v1/building/condo-owners", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), accessToken: opts.accessToken, }), }, enrichment: { cf: { list: ( params: { limit?: number; offset?: number; status?: string } = {}, opts: GisApiCallOpts = {}, ) => request("/api/v1/enrichment/cf", { query: params, accessToken: opts.accessToken, }), get: (id: string, opts: GisApiCallOpts = {}) => request(`/api/v1/enrichment/cf/${encodeURIComponent(id)}`, { accessToken: opts.accessToken, }), create: ( body: { nrCadastral: string; siruta?: string; gisFeatureId?: string }, opts: GisApiCallOpts = {}, ) => request( "/api/v1/enrichment/cf", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), accessToken: opts.accessToken, }, ), patch: (id: string, body: Partial, opts: GisApiCallOpts = {}) => request(`/api/v1/enrichment/cf/${encodeURIComponent(id)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), accessToken: opts.accessToken, }), uploadPdf: ( id: string, pdf: ArrayBuffer | Uint8Array | Blob, opts: GisApiCallOpts & { documentName?: string } = {}, ) => request<{ status: "ok"; size: number }>( `/api/v1/enrichment/cf/${encodeURIComponent(id)}/pdf`, { method: "POST", headers: { "Content-Type": "application/pdf", ...(opts.documentName ? { "X-Document-Name": opts.documentName } : {}), }, body: pdf as BodyInit, accessToken: opts.accessToken, }, ), // Returns raw Response — caller streams to client (PDF body) getPdf: (id: string, opts: GisApiCallOpts = {}) => rawResponse(`/api/v1/enrichment/cf/${encodeURIComponent(id)}/pdf`, { accessToken: opts.accessToken, }), }, catalog: (nrCadastral: string, opts: GisApiCallOpts = {}) => request<{ nrCadastral: string; siruta?: string; uatName?: string; completedAt?: string; expiresAt?: string; isFresh: boolean; }>(`/api/v1/enrichment/catalog/${encodeURIComponent(nrCadastral)}`, { accessToken: opts.accessToken, }), }, };