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