# 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 ```ts 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 — architools 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 ```ts { 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___.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 (architools-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 ```ts { signer: Signer, // required (single signer) layerId?: "TERENURI_ACTIVE" | "CLADIRI_ACTIVE", scale?: number, // override autoSelectLayout paper?: string, // "A4" | "A3" | … } ``` ### Response `200 application/pdf`, `PS___.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_.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_`, plus vecini opționali pe layer `VECINI` și clădiri pe `CLADIRI`. Coordonate Stereo70. ### Request body ```ts { layerId?: "TERENURI_ACTIVE" | "CLADIRI_ACTIVE", includeNeighbors?: boolean, // default true for TERENURI, false for CLADIRI } ``` ### Response `200 application/dxf`, filename `.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 (architools 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 ```ts { 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 architools change required as each gis-api endpoint ships. --- ## Verification per endpoint After each ships: ```bash # From architools cwd, on the live deploy (assumes test cad 354686 in UAT Cluj-Napoca, siruta 54975): # (Replace with the GisFeature uuid for that cadref.) # Coord curl -fsS https://tools.beletage.ro/api/gis/parcel//coords \ -H "Cookie: $(...your session cookie...)" -o coord.xlsx # DXF curl -fsS https://tools.beletage.ro/api/gis/parcel//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//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. --- ## Related memory - [[architools-cutover-state-2026-05-20]] — V2 panel state - [[v2-panel-contract]] — panel section layout - [[parcel-enrich-contract]] — gis-api /parcel/enrich auto vs manual - [[gis-api-parcela-by-ref]] — find chain endpoint - [[architools-auto-deploy-webhook]] — push → live in ~90s