Files
ArchiTools/src/lib/gis-api-client.ts
T
Claude VM 100896a564 feat(geoportal-v2): find proxy fallback chain — by-ref → search
Per Marius's greenlight + gis-api shipping POST? GET /api/v1/parcela/by-ref
imminent.

src/lib/gis-api-client.ts:
  Added gisApi.parcela.byRef({siruta, cadastralRef, layerId}) thin
  wrapper. Same return shape as parcela.get; gis-api will 404 when no
  match and 403 on scope=none.

src/app/api/gis/parcela/find/route.ts:
  Chain rewrite. Three named helpers — tryByRef + trySearch — keep the
  main handler short and the fallback semantics obvious:

    1. tryByRef(siruta, cad, layerId)
         200 → return canonical record (instant — single indexed query
         on gis_core)
         404 → endpoint not deployed yet OR row genuinely absent. Fall
         through.
         403 / 5xx → propagate.

    2. trySearch(siruta, cad, layerId)
         The previous logic, moved verbatim. Uses search's response
         siruta field for in-memory filter (no N+1 parcela.get).
         Still capped at gis-api's max 50; returns
         search_limit_exceeded when the target siruta falls past it.

    3. 404 not_found — both layers exhausted.

When gis-api's by-ref is live, common-cadref cases (61745 / 232
features) resolve in one round-trip. Before then, by-ref returns 404
and we fall through to search — same behaviour as before for the
non-bottleneck cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:10:59 +03:00

406 lines
13 KiB
TypeScript

// 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,
};
}
// 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<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
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<Response> {
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<unknown>(`/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<unknown>("/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<unknown>("/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<unknown>("/api/v1/parcel/enrich", {
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,
}),
},
};