100896a564
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>
406 lines
13 KiB
TypeScript
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,
|
|
}),
|
|
},
|
|
};
|