From fc2bdfb2b41f427f0eb3504cdb58f1263a135f2d Mon Sep 17 00:00:00 2001 From: Claude VM Date: Mon, 18 May 2026 08:22:05 +0300 Subject: [PATCH] feat(gis-api): Faza D thin client lib (src/lib/gis-api-client.ts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side wrapper around api.gis.ac. Auto-extracts Authentik access_token from NextAuth session via getServerSession. Full surface covering 15 endpoints + RateLimit / GisApiError types: - me, parcela.get, search - parcel.{tech, unitsFetch, immApps} - building.{tech, condoOwners} - enrichment.cf.{list, get, create, patch, uploadPdf, getPdf} - enrichment.catalog Streaming PDF endpoint (getPdf) returns raw Response so caller can forward to browser without buffering. uploadPdf accepts ArrayBuffer/ Uint8Array/Blob with optional X-Document-Name header. Rate-limit headers (X-RateLimit-{Limit,Remaining,Reset}) parsed and attached to GisApiError on 429. No correlationId in requests — gis-api overwrites server-side (per project_audit_correlation_echo memory). GIS_API_URL env (Infisical /architools) defaults to https://api.gis.ac. Dormant until Faza E geoportal + Faza F ePay backend swap import it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/gis-api-client.ts | 349 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 349 insertions(+) create mode 100644 src/lib/gis-api-client.ts diff --git a/src/lib/gis-api-client.ts b/src/lib/gis-api-client.ts new file mode 100644 index 0000000..b526b08 --- /dev/null +++ b/src/lib/gis-api-client.ts @@ -0,0 +1,349 @@ +// 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, + }; +} + +async function request(path: string, opts: RequestOpts = {}): Promise { + const token = opts.accessToken || (await bearerFromSession()); + const 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", + }); + + 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()); + const res = await fetch(buildUrl(path, opts.query), { + method: opts.method || "GET", + body: opts.body ?? null, + headers: { + Authorization: `Bearer ${token}`, + ...opts.headers, + }, + cache: "no-store", + }); + 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, + }), + }, + + search: (q: string, limit = 20, opts: GisApiCallOpts = {}) => + request<{ + q: string; + uats: Array<{ siruta: string; name: string; county: string }>; + features: Array<{ + id: string; + layerId: 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, + }), + 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, + }), + }, +};