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>
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_scopeclaim must be at leastbasicfor CF intern;noneis 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/enrichinternally). 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.tssrc/lib/pad/renderer.tssrc/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,isSubjectflag 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.findManyalready targets gis_core in eterra.live — same shape on gis-api side, just swap the client import.- Drop
enrichmentandattributesreads 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 whenformat=dxf)src/lib/dxf-writer.ts(the pure-JS DXF emitter — no native deps)
Replace in port
- Strip the
geojson+gpkgbranches — 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.tssrc/lib/eterra-session.ts(account pool — likely already partially shared with/parcel/enrichorchestrator path)- Rate limit: keep eterra.live's
RATE_LIMITS.docDownloadwindow (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.cfExtractwriter (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/:nrCadastralendpoint can drive this.
Implementation order recommendation
- Coord-XLSX (Endpoint 3) — simplest, validates auth + contract.
- DXF (Endpoint 4) — also simple, no PDF lib needed.
- PAD (Endpoint 2) — first PDF endpoint, no basemap; isolates the skia-canvas / pdf lib selection question.
- CF intern (Endpoint 5) — depends on eTerra account pool; orth- ogonal to the others.
- 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/exportonce 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
- architots-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
- architots-auto-deploy-webhook — push → live in ~90s