Files
ArchiTools/docs/plans/005-gis-api-export-endpoints.md
T
Claude VM 71cfc29f9a feat(geoportal-v2): export toolbar + Semnez ca picker + CF intern/Extras split
V2 panel toolbar replaces the single "Comandă CF" button with two rows:

  [Încadrare] [Pl. situație] [Coord.] [DXF]       ← 4 exports
  [CF intern] [Extras CF]                          ← 2 CF flows

Each export button pops an inline modal:
  - PIZ / PAD: SignAsPicker (PFA / PJA radio list, manual-add inline,
    co-signer slot on PIZ) + basemap toggle (google / orto for PIZ).
  - Coord / DXF: no picker — single-click download via JWT proxy.

"CF intern" is the free copycf flow from eTerra (proxied via gis-api);
"Extras CF" keeps the existing CfOrderModal (1 credit ePay). The two
modes are now visually balanced as a 2-button row.

Sign-as picker rows merge user-owned Signatory table entries with the
SIGN_AS_DEFAULT_OPTIONS env-driven fallback (org-wide hardcoded options;
defaults seed two Studii de teren entries — Tiurbe PFA + SRL PJA). New
rows added via the picker's "Adaugă autorizație" inline form write to
the Signatory table; ENV rows are read-only.

Architots side ships fully:
  - prisma Signatory model + ALTER TABLE applied (per the schema-drift
    feedback memory).
  - /api/sign-as-options (GET, POST) + /api/sign-as-options/[id]
    (PATCH, DELETE).
  - /api/cf-intern/order and /api/gis/parcel/[id]/{piz,pad,coords,dxf}
    proxy routes — auth check + JWT forward, stream binary back.
  - gis-api thin client extended with the matching exports.* namespace.

Until the gis-api endpoints ship (next session — full spec in
docs/plans/005-gis-api-export-endpoints.md), each export proxy returns
501 "…urmează" with a Romanian message so the modal shows what's
coming instead of a hard error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:57:55 +03:00

12 KiB

Plan 005 — gis-api export endpoints (PIZ / PAD / Coord / DXF / CF intern)

Date: 2026-05-21 Author: ArchiTools session (V2 panel toolbar) Owner of execution: gis-api session (/home/orchestrator/Code/gis-api) Status: Architots side complete; gis-api endpoints not yet built.


Why this exists

The V2 panel on ArchiTools now exposes a 4-button export toolbar (PIZ / Plan situație / Coord. / DXF) plus a 2-button CF row (CF intern / Extras CF). Every button except "Extras CF" routes through gis-api so a future eterra.live migration or new app (planhub) consumes the same endpoints — same code path, same auth, same audit log.

Architots ships the UI + a thin proxy under /api/gis/parcel/[id]/{piz, pad,coords,dxf} and /api/cf-intern/order. Each route forwards to gis-api with the user's Authentik JWT, then streams the response back. Until these gis-api endpoints exist, those routes surface a 404 from gis-api as a friendly Romanian message in the modal ("…urmează — endpoint-ul gis-api se livrează în sesiunea următoare").

Source for the heavy lifting (PDF renderers, basemap fetchers, DXF writer, ExcelJS layout) is ~/Code/eterra-live/src/. The relevant files are listed below per endpoint. Port them into gis-api; keep them provider-agnostic (no eterra.live user table, no usage tracking).


Shared contract

All endpoints below take the GisFeature uuid in the path. Architots already resolves the uuid via the find chain (uuid → by-ref → search) and passes it in. The endpoints look up geometry + enrichment via gis_core."GisFeature" (or the orchestrator's enrichment path when a fresh fetch is needed).

Auth

  • Bearer JWT (Authentik) — same as every other v1 endpoint.
  • enrichment_scope claim must be at least basic for CF intern; none is allowed for the geometry-only exports (PIZ/PAD/Coord/DXF).
  • Per-tenant rate limit applies (existing GIS_API_TENANT_RATE_LIMITS).

Errors

Status Code Architots renders…
400 signer_required "Selectează un semnatar."
400 invalid_geometry "Geometria parcelei e invalidă."
401 unauthorized "Autentificare expirată. Reîncarcă pagina."
403 enrichment_scope_* "Nu ai permisiunea pentru acest export."
404 parcel_not_found "Parcela nu există în baza gis_core."
502 eterra_fetch_failed "ANCPI nu răspunde momentan."
503 no_available_account "Pool-ul ANCPI e temporar epuizat."
504 upstream_timeout "Timeout la generare. Reîncearcă."

Architots already handles each of these (see feature-info-panel.tsx error mapping for the deep-enrich endpoint — same shape).

Signer payload shape

type Signer = {
  kind: "user" | "org";   // PFA vs PJA
  displayName: string;    // "Dan-Gheorghe Tiurbe" or "Studii de teren SRL"
  authClass: string | null; // "Cat. D" / "Clasa III"
  authNumber: string;     // "RO-B-F/3183"
};

gis-api never resolves signers — architots picks them client-side (merged DB rows + ENV defaults) and sends the final string-only struct in the request body. gis-api renders verbatim into the PDF footer / PIZ cartouche. Co-signer rendering follows the same shape.


Endpoint 1: POST /api/v1/parcel/:id/piz

PDF — Plan de încadrare în zonă, A4 portrait, scara 1:5000 (1:10000 peste 400 m), parcela conturată cu roșu peste fundal satelit / orto.

Request body

{
  signer: Signer,                    // required
  coSigner?: Signer | null,          // optional second name in footer
  basemap?: "google" | "orto",       // default "google"
  layerId?: "TERENURI_ACTIVE" | "CLADIRI_ACTIVE",   // disambiguator
}

Response

200 application/pdf with Content-Disposition: attachment; filename="PZ_<cad>_<UAT>_<YYYY-MM-DD>.pdf".

Port from eterra.live

  • src/app/api/geoportal/piz/route.ts (logic + filename + content-disposition)
  • src/lib/piz/renderer.ts (PDF layout)
  • src/lib/piz/google-tiles.ts (Google satellite tile composite, EPSG:3857)
  • src/lib/piz/overlay-tiles.ts (Harta 1970 overlay — optional, can ship later)
  • src/lib/eterra-basemap.ts (ANCPI ortofoto WMS)
  • src/lib/pad/geometry.ts (extractVertices, computeBBox)
  • src/lib/auth-display.ts (formatSignerOneLine)
  • src/lib/categorie-folosinta.ts

Replace in port

  • Drop getEffectiveSession → use gis-api's bearer-token + claims context.
  • Drop prisma.eterraUser.findUnique → use the signer struct from body.
  • Drop resolveSignerIdentity → trust body.signer (architots-side validated).
  • Drop trackUsage → optional; can keep a thin audit row in gis_meta if desired, but skip credit accounting.
  • enrichFeatureIfNeeded → call the existing orchestrator path (or auto-trigger /api/v1/parcel/enrich internally). Best-effort: render with whatever's in DB on failure (filename + footer okay even without NR_CF).

Special considerations

  • Whichever PDF lib eterra.live uses (skia-canvas, pdf-lib, etc.) needs to be added to gis-api's package.json. Memory says eterra.live runs Next 16 with skia-canvas — should port cleanly.
  • Google tiles require an HTTP fetch grid. Cache per-bbox (Redis or filesystem) to avoid hammering Google when users repeat exports.
  • Ortofoto requires ANCPI WMS auth — needs a service account in Infisical (/gis-api/ANCPI_ORTOFOTO_USER + _PASS?).

Endpoint 2: POST /api/v1/parcel/:id/pad

PDF — Plan de amplasament și delimitare. Auto-picks scale + paper + orientation based on geometry; rasters neighbors (parcels + buildings) within the drawing bbox; renders a cartouche with CF / topo / categorie / adresă / coords table.

Request body

{
  signer: Signer,                    // required (single signer)
  layerId?: "TERENURI_ACTIVE" | "CLADIRI_ACTIVE",
  scale?: number,                    // override autoSelectLayout
  paper?: string,                    // "A4" | "A3" | …
}

Response

200 application/pdf, PS_<cad>_<UAT>_<YYYY-MM-DD>.pdf.

Port from eterra.live

  • src/app/api/geoportal/pad/route.ts
  • src/lib/pad/renderer.ts
  • src/lib/pad/geometry.ts (computeSegments, computeArea, computePerimeter, autoSelectLayout, fillBboxToDrawable)
  • Plus same shared libs as PIZ.

Special considerations

  • Neighbor SQL stays in raw $queryRaw (already PostGIS-aware, intersection with the draw bbox, isSubject flag for buildings). Just point at gis-api's gis_core connection.
  • No basemap fetch — PAD is line-drawing only.

Endpoint 3: GET /api/v1/parcel/:id/coords-xlsx

XLSX — coordonate Stereo70 per vârf, lungimi segmente, suprafață calculată Shoelace. 3 sheets (Sumar / Coordonate / Segmente).

Request

No body. The parcel id in the path is sufficient.

Response

200 application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, filename coordonate_<cad>.xlsx.

Port from eterra.live

  • src/app/api/geoportal/coords-xlsx/route.ts (~280 LOC, mostly ExcelJS scaffolding). One file, minimal dependencies (exceljs + Prisma) — easiest port; recommend starting here to validate the contract end-to-end.

Replace in port

  • prisma.gisFeature.findMany already targets gis_core in eterra.live — same shape on gis-api side, just swap the client import.
  • Drop enrichment and attributes reads if scope doesn't permit (Coord works fine with geometry alone).

Endpoint 4: POST /api/v1/parcel/:id/dxf

DXF — parcela ca polilinie închisă pe layer-ul PARCELA_<cad>, plus vecini opționali pe layer VECINI și clădiri pe CLADIRI. Coordonate Stereo70.

Request body

{
  layerId?: "TERENURI_ACTIVE" | "CLADIRI_ACTIVE",
  includeNeighbors?: boolean,        // default true for TERENURI, false for CLADIRI
}

Response

200 application/dxf, filename <cad>.dxf.

Port from eterra.live

  • src/app/api/geoportal/export/route.ts (single-parcel branch when format=dxf)
  • src/lib/dxf-writer.ts (the pure-JS DXF emitter — no native deps)

Replace in port

  • Strip the geojson + gpkg branches — gis-api endpoint is DXF-only (architots GeoJSON export already exists via the gis-api search path, and GPKG is rarely useful for single-parcel workflows).
  • The neighbor query is similar to PAD's — share the helper.

Endpoint 5: POST /api/v1/enrichment/cf-intern

PDF — extras CF din circuitul intern eTerra (copycf). Gratuit (nu consumă credit ePay). Streams the eTerra response directly.

Request body

{
  nrCadastral: string,    // "354686"
  siruta: string,         // "54975"
}

Response

200 application/pdf with the eTerra-generated filename in Content-Disposition.

Port from eterra.live

  • src/app/api/eterra/cf-intern/route.ts
  • src/lib/eterra-session.ts (account pool — likely already partially shared with /parcel/enrich orchestrator path)
  • Rate limit: keep eterra.live's RATE_LIMITS.docDownload window (default 10/h per user — tune in gis-api env).

Replace in port

  • Drop usage tracking + per-user eterra session caching tied to eterraUser — use the account pool that already serves enrichment.
  • Persist the CF row via gis-api's existing prisma.cfExtract writer (CfExtract table on gis_core or architools_postgres depending on architecture decision — likely gis_core for consistency).

Special considerations

  • Already-cached CF (catalog hit) should short-circuit and return the cached PDF — saves an eTerra round-trip. The orchestrator's enrichment/catalog/:nrCadastral endpoint can drive this.

Implementation order recommendation

  1. Coord-XLSX (Endpoint 3) — simplest, validates auth + contract.
  2. DXF (Endpoint 4) — also simple, no PDF lib needed.
  3. PAD (Endpoint 2) — first PDF endpoint, no basemap; isolates the skia-canvas / pdf lib selection question.
  4. CF intern (Endpoint 5) — depends on eTerra account pool; orth- ogonal to the others.
  5. PIZ (Endpoint 1) — hardest, needs basemap fetcher + tile cache.

Architots already speaks all five via the thin client (gisApi.exports.{piz,pad,coordsXlsx,dxf,cfIntern} in src/lib/gis-api-client.ts). No architots change required as each gis-api endpoint ships.


Verification per endpoint

After each ships:

# From architots cwd, on the live deploy (assumes test cad 354686 in UAT Cluj-Napoca, siruta 54975):
# (Replace <PARCEL_UUID> with the GisFeature uuid for that cadref.)

# Coord
curl -fsS https://tools.beletage.ro/api/gis/parcel/<PARCEL_UUID>/coords \
  -H "Cookie: $(...your session cookie...)" -o coord.xlsx

# DXF
curl -fsS https://tools.beletage.ro/api/gis/parcel/<PARCEL_UUID>/dxf \
  -X POST -H "Content-Type: application/json" -d '{}' \
  -H "Cookie: $(...)" -o test.dxf

# PIZ (substitute signer)
curl -fsS https://tools.beletage.ro/api/gis/parcel/<PARCEL_UUID>/piz \
  -X POST -H "Content-Type: application/json" \
  -d '{"signer":{"kind":"user","displayName":"Test","authClass":"Cat. D","authNumber":"RO-B-F/3183"},"basemap":"google"}' \
  -H "Cookie: $(...)" -o test_piz.pdf

Open each in its native viewer (Excel, AutoCAD/QCAD, PDF reader) to confirm rendering parity with eterra.live's output.


Out of scope for this plan

  • Bulk-export multi-parcel (UAT-level) — separate flow, will live at /api/v1/uat/:siruta/export once Coord + DXF singles are stable.
  • Mobile-first signer UI — current picker is desktop layout only.
  • PIZ overlay raster (Harta 1970) — port last, low priority.
  • Cache eviction policy for the Google tile composites — initial impl can be filesystem with daily rotation.