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:
@@ -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,
|
||||
}),
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user