feat(gis-api): Faza D thin client lib (src/lib/gis-api-client.ts)

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) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-05-18 08:22:05 +03:00
parent 977db6d63a
commit fc2bdfb2b4
+349
View File
@@ -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<string, string>;
query?: Record<string, string | number | undefined | null>;
}
async function bearerFromSession(): Promise<string> {
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<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
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<Response> {
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<unknown>(`/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<unknown>("/api/v1/parcel/tech", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
accessToken: opts.accessToken,
}),
unitsFetch: (body: ParcelRefBody, opts: GisApiCallOpts = {}) =>
request<unknown>("/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<unknown>("/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<unknown>("/api/v1/building/tech", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
accessToken: opts.accessToken,
}),
condoOwners: (body: ParcelRefBody, opts: GisApiCallOpts = {}) =>
request<unknown>("/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<CfListResponse>("/api/v1/enrichment/cf", {
query: params,
accessToken: opts.accessToken,
}),
get: (id: string, opts: GisApiCallOpts = {}) =>
request<CfExtractRow>(`/api/v1/enrichment/cf/${encodeURIComponent(id)}`, {
accessToken: opts.accessToken,
}),
create: (
body: { nrCadastral: string; siruta?: string; gisFeatureId?: string },
opts: GisApiCallOpts = {},
) =>
request<CfExtractRow | { error: "catalog_hit"; existing: CfExtractRow }>(
"/api/v1/enrichment/cf",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
accessToken: opts.accessToken,
},
),
patch: (id: string, body: Partial<CfExtractRow>, opts: GisApiCallOpts = {}) =>
request<CfExtractRow>(`/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,
}),
},
};