c9f1219eaad46d31659ecfb0fdd0597917cef570
572 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c9f1219eaa |
feat(epay): three layers of download/poll resilience
After 327649 hit a transient ANCPI 500 on download (succeeded immediately on manual retry), make the pipeline self-heal instead of marking the row failed: 1. downloadDocument retries transient failures (5xx, network/timeout, empty body, non-PDF error page) up to 4 attempts with linear backoff (3/6/9s); a 4xx is permanent and stops immediately. The %PDF guard stays — a bad body is now retried rather than thrown on the first try. 2. pollUntilComplete tolerates a transient error on a single poll: it logs and continues to the next cycle instead of throwing out of the whole batch (one ANCPI blip during polling no longer fails a paid order). 3. finalizeOrder runs a final retry sweep: any row still failed after the parallel pass is re-attempted once more after a short pause (covers a longer ANCPI blip or a MinIO hiccup). No new charge — the order is already paid. Same downloadDocument + pollUntilComplete hardening ported to eterra-live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
1c8d7ea59c |
fix(epay): CRITICAL multi-item batch regressions — wrong basketRowId + stale order match
Found via a real 2-item batch (280067 + 327649) on 2026-06-05 that produced a wrong PDF (correctly caught as "De verificat" by the R4 safety net) and a failed download: 1. addToCartDetailed took items[items.length-1], but ePay returns the cart NEWEST-FIRST, so the just-added row is items[0]. On a 2+ item batch every add reported the OLDEST row's id → two rows collapsed onto one basketRowId → metadata saved to the wrong row → broken cart. Single-item orders were unaffected (one element). Reverted to items[0]. 2. findNewOrderId accepted any id != previousOrderId, so when our submit created nothing it adopted an unrelated OLDER order (yesterday's 10009605) and attached its 15 Feleacu PDFs to today's parcels. ePay order numbers are sequential, so a genuinely-new order must be numerically GREATER than the latest pre-submit order; otherwise fail (recoverable) instead of matching a stale order. Take the highest genuinely-new id. Removed the now-dead latest-id fallback. The R4 "review" flag did its job — the wrong PDF was flagged for verification, never shown as valid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5ad8870dc5 |
fix(epay-ui): stuck connect spinner + order button shows processing not instant-valid
Two UX issues reported from the field:
1. ePay pill spun forever on an already-connected (green) pill. Two causes:
the icon put connecting before connected (so a stuck connecting state
showed the spinner even when connected), and the auto-connect effect leaked
the connecting state — a cancelled early-return skipped clearing it, and
having connecting in the dep array made setConnecting(true) cancel its own
in-flight attempt. Fix: connected takes icon priority; a finally{} always
clears connecting unless retrying; drop connecting from deps.
2. The per-parcel CF button flipped straight to green "Extras CF valid" the
instant the order was queued, while it actually kept processing ~1-2 min in
the background (cart, submit, poll, download). Now it shows a pulsing
"Se proceseaza..." and polls until a completed extract truly exists before
flipping to valid.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
b62132ab9e |
fix(epay): 4 regressions from adversarial review of the hardening diff
Adversarial review (9 agents) of f7f7c59..28c870f found 4 confirmed bugs in the hardening itself; all fixed: 1. Parallel-download index race: two items with the SAME nrCadastral in one batch both scanned MinIO, both computed index 1, the second putObject silently overwrote the first paid extract. Pre-allocate per-cadastral indices sequentially before the parallel block; storeCfExtract takes an explicit index (epay-queue.ts, epay-storage.ts). 2. Metadata-fail orphan charge: on saveMetadata failure the row was popped from cleanup tracking even when deleteCartItem was NOT confirmed, leaving an undeletable metadata-less row in the global cart that submitOrder would check out and charge. Now: pop only on confirmed delete; if unconfirmed, mark cartDirty and ABORT before submit (epay-queue.ts). 3. Recover vs live queue race: the widened recover WHERE (orderId:null + cart/ordering/... states) could scoop a concurrently-processing batch's rows and re-stamp them with the wrong orderId. Block recover while getQueueStatus().processing (recover/route.ts). 4. 'review' status leaked as 'done' in the geoportal CF-order modal (minioPath short-circuit) — handed an unverified PDF as a finished extract. Check review/failed BEFORE the minioPath fallback (cf-order-modal.tsx). Plus 2 nits: download-zip excludes 'review' rows server-side; retry button surfaces recover errors/results instead of swallowing them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
28c870fb12 |
harden(epay): cart-hygiene invariant uses confirmed cart count + add service architecture plan
- cartCount tracks actual cart rows (decrement only on confirmed delete) so a failed cleanup delete can't trigger a false dirty-cart abort. - docs/plans/006: the multi-tenant CF-service architecture (DB-backed fulfiller, account pool, catalog dedup, per-tenant credential model, reversible flag flip) — the executable next phase. The Phase-F flag flip is gated on the orchestrator fulfiller existing (Plan 003 Faza F was wrong). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f49fdb1da0 |
harden(epay): cart hygiene, auth/IDOR gates, single-page fetch, parallel downloads
Live-path hardening from the 2026-06-04 deep-dive (11 confirmed criticals). ArchiTools-only; the legacy queue is still the sole fulfiller. Security: - requireCfAccess() — staff-only, portal accounts blocked, fail-closed in-route on download / download-zip / cf-status / orders (C4 IDOR/PII) and order / recover (C3). order also enforces a daily credit cap (ANCPI_DAILY_CREDIT_CAP, default 200) and stamps userId. - /api/ancpi/test returns 404 in production — it was a GET that spends 2 real credits, CSRF-able (C5). - drop the token-metadata debug blob from the session (QW8). Correctness / robustness: - cart hygiene (C1): build the ePay cart under an invariant — the Nth add must report N items; any excess = pre-existing junk, so we wipe + abort (never submit a cart we didn't fully build). Pre-submit failures clean up our basket rows; post-submit we never touch the cart (recover owns it). metadata-less rows are deleted from the cart. - getOrderStatus fetches the whole order in ONE page (itemsPerPage, QW4); navDir loop kept only as fallback. index-fallback matches are flagged 'review' instead of silently 'completed' with a possibly-wrong PDF (R4). - downloadDocument asserts %PDF magic bytes — a login page returned mid session no longer gets stored as a .pdf (R2). Session reuse TTL aligned under ANCPI's ~10min expiry. - recover accepts ?extractId= and pre-submit states; retry buttons in the ePay tab re-run poll+download with no new charge (QW2/QW3). Performance: - parallel document downloads (V1, concurrency 4); poll writes only on status change via updateMany (QW5); getNextFileIndex scans the cadastral prefix instead of the whole bucket — and actually works now (it was ^-anchoring the full key, so every file got index 1) (V2); download-zip streams instead of buffering the whole archive, capped at 100 (V3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f7f7c59d17 |
fix(epay): paginate ShowOrderDetails — orders >5 items only exposed first page
ePay paginates order documents 5/page (&navDir=<page>, 'Total items: N'). getOrderStatus only parsed page 1, so a 15-item order surfaced 5 docs: 5 parcels CF-matched correctly, 5 got WRONG PDFs via the index fallback, 5 failed with 'Document lipsă' (2026-06-04, order 10009605). - parseOrderPage(): per-page CF+solutii extraction, zipped per page - getOrderStatus(): walks all pages, dedupes by idDocument, stops on empty page Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2fed59dad6 |
fix(epay): submit timeout 60s→180s + order recovery for timed-out submits
2026-06-04 incident: a 15-item EditCartSubmit exceeded the 60s axios timeout — ANCPI completed order 10009605 and spent 15 credits, but the rows were marked failed with no orderId and no downloads. - SUBMIT_TIMEOUT_MS=180s for EditCartSubmit + CheckoutConfirmationSubmit - EditCartSubmit errors no longer abort the batch: fall through to order-id detection, which now refuses to adopt the previous/known order (stale-id guard in findNewOrderId) - extract steps 4-6 of processBatch into finalizeOrder, shared with new recoverBatch() - GET /api/ancpi/recover?orderId=N re-attaches recent failed rows to the ANCPI order and runs poll → download → MinIO (single-flight, idempotent) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f7468b23c2 |
fix(uats): never block on the feature-count groupBy — cold cache froze UAT selector
The ~30s groupBy over 9.7M GisFeature rows ran synchronously on the first /api/eterra/uats call after every redeploy (in-memory cache), freezing the UAT autocomplete right when users reload post-deploy. Counts only feed the decorative 'N local' badge — return the (possibly empty) cache immediately and refresh in the background, single-flight so concurrent cold requests don't stack 30s queries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
077ec401fb |
guard(epay): force legacy queue for paid CF orders — gis-api has no fulfiller yet
gis-api POST /enrichment/cf only inserts a pending row; no orchestrator worker executes the ePay purchase, so pilot-flag orders silently never complete. EPAY_ORDERING_VIA_GIS_AC=false routes all paid orders through /api/ancpi/order and restores the connected+credits gating on the per-parcel button. Flip the constant after the orchestrator ePay worker ships. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
372a9c55ea |
chore: clean .gitignore (utf-16 noise) + mark plan 005 shipped
- .gitignore had 2 lines saved as UTF-16-LE (temp-db-check.cjs and .playwright-mcp), so the patterns weren't actually ignoring those files. Rewrote in plain UTF-8. - Plan 005 (gis-api export endpoints) was marked "not yet built" but gis-api commit bbd6e7c shipped all five endpoints on 2026-05-21 with 38 new tests; update the status block to reflect that, including the one open caveat (PIZ basemap=orto still 501). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
0e9a47f6a7 |
feat(geoportal-v2): split-view compare mode (two basemaps, synced pan/zoom)
Adds a "Compară" toggle next to the basemap switcher. When active the
geoportal layout splits vertically into two MapViewer panes:
┌──────────────────┬─────────────────────┐
│ Primary pane │ Secondary pane │
│ - Search bar │ - Basemap switch │
│ - Basemap switch│ - Independent │
│ - Click→panel │ wayback/S2 │
│ │ - View-only │
└────────[<>]──────┴─────────────────────┘
▲
└── drag separator (10%-90% clamp)
Each pane keeps its own basemap + wayback release + sentinel year state,
so the user can show e.g. Google sat vs Esri Wayback 2018 side by side
or S2 2017 vs S2 2024 to spot rural changes.
MapViewer gains three optional props:
- viewState: controlled camera; jumpTo() on change with a sync flag
so the resulting moveend doesn't feed back into onViewChange.
- onViewChange: bubble user-driven moveend up to the parent so the
other pane stays in lockstep.
- disableFeatureClicks: secondary pane suppresses click→panel so the
panel only ever opens from primary clicks.
Behaviours preserved: when compareMode is off the layout collapses to
the original single full-width pane verbatim; basemap switcher + panel
sit in the same top-right slot. Clicking the toggle while in compare
mode auto-picks an alternative basemap for the secondary pane so the
comparison starts meaningful from the first frame.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
04f666638e |
feat(geoportal-v2): "S2" basemap — Sentinel-2 cloudless annual mosaics
Adds a 6th basemap option ("S2") backed by EOX's free, public,
CORS-open Sentinel-2 cloudless WMTS service. Annual mosaics from 2016
to 2024 (2025/2026 not yet shipped by EOX); 10 m/pixel resolution
good for large-scale rural change detection (deforestation,
greenhouses, halls, agriculture) but not for individual buildings.
Companion to the Wayback basemap shipped earlier — Wayback gives
high-res city detail at irregular snapshot dates, Sentinel-2 gives
predictable yearly cadence at coarse rural-scale resolution.
UI mirrors Wayback: when "S2" is selected the switcher reveals a year
dropdown beneath the basemap row; the map-viewer rebuilds the raster
source with the right EOX layer ID. Default year = latest (2024).
Note on licensing: EOX's 2018+ mosaics are CC BY-NC-SA 4.0 — non-
commercial. The UI surfaces this + the commercial-licence pointer
(cloudless.eox.at). 2016 (s2cloudless) + 2017 are CC BY 4.0, no
non-commercial restriction.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
47d6ba329c |
feat(geoportal-v2): "Istoric" basemap — ESRI Wayback with date picker
Adds a 5th basemap option ("Istoric") that loads historical ESRI World
Imagery snapshots from the public Wayback service. Free, CORS-open,
193+ releases dating back to 2014; each release is identified by a
numeric id baked into the tile URL pattern.
How it works:
- wayback-catalog.ts fetches the public waybackconfig.json once per
24h, parses each release's title for an ISO date, and exposes a
newest-first list of { id, date, title, itemUrl }.
- BasemapSwitcher reveals a date dropdown beneath the basemap buttons
when "Istoric" is selected. Auto-picks the latest release on first
show; user can pick any past date.
- map-viewer rebuilds the MapLibre style when basemap=="wayback" with
the user-picked release id patched into the raster source tiles[].
Tile URL format (WMTS, {z}/{y}/{x} not {z}/{x}/{y}):
https://wayback.maptiles.arcgis.com/arcgis/rest/services
/World_Imagery/WMTS/1.0.0/default028mm/MapServer
/tile/<releaseId>/{z}/{y}/{x}
Esri's edge deduplicates identical tiles via 301 → another release id
(MapLibre/browser follows the redirect transparently), so picking a
random old date doesn't always show a different image when nothing
changed in that pixel — that's a feature of Wayback, not a bug.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
9c496419fd |
fix(basemap-style): emit absolute URLs so MapLibre worker can fetch
The previous rewrite produced relative `/api/basemap-tile/…` URLs in the resolved style. MapLibre loads vector tiles + sprites + glyphs inside a Web Worker, where relative URLs have no base context and fetch() rejects them with "Failed to construct 'Request': Failed to parse URL". Browser console filled with one such error per tile request → empty cream map all over again. Fix: prepend the request origin (honoring Traefik's x-forwarded-proto / x-forwarded-host) so every rewritten URL is absolute. Same behaviour from the main thread; Web Worker fetch also works because it now has a parseable URL. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
44ba50f226 |
fix(basemap-tile): buffer body + drop upstream encoding/length headers
Initial proxy streamed upstream.body straight through with the upstream
Content-Encoding + Content-Length headers. Two ways that broke:
- Node's fetch auto-decodes gzip/br responses, so the body coming
out of upstream.body is already plain bytes. Forwarding
Content-Encoding: gzip made the browser (and curl) try to gunzip
plain bytes and fail.
- Content-Length was the upstream (compressed) length, not the
decoded byte count. Mid-stream the H2 layer noticed the mismatch
and dropped with INTERNAL_ERROR (curl returned status=000 + a
0-byte file).
Switch to arrayBuffer() + emit only Content-Type. Node serializes
the response with the right length and no encoding header, so the
browser gets the plain PBF / PNG / JSON it expects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
efcfa66c07 |
fix(geoportal-v2): proxy all openfreemap tiles, not just /planet TileJSON
OpenFreeMap's nginx blocks browser-origin requests on every endpoint — not only /planet (the TileJSON) but the versioned vector tiles too. Verified live: GET tiles.openfreemap.org/planet/20260520_001001_pt/6/36/22.pbf with Origin: https://tools.beletage.ro returns 403 (plain nginx, not Cloudflare). Yesterday's /api/basemap-style proxy fixed the TileJSON resolution, but every subsequent tile fetch still died at openfreemap's edge → empty cream map again. Two pieces here: 1. New /api/basemap-tile/[...path] catch-all that proxies ANY openfreemap resource (tiles, sprite, glyphs). Plain server-side fetch with no Origin header — passes openfreemap's filter — then streams the upstream body back to the browser. Cache-Control aggressive (24h public + 7d SWR + immutable) since openfreemap paths are versioned and never mutate. 2. /api/basemap-style rewrites every tiles.openfreemap.org URL inside the resolved style (tile templates + sprite + glyphs) to point at the proxy prefix above. The browser now never talks to openfreemap directly. Plus the middleware bypass widens from `api/basemap-style` to the prefix `api/basemap-` so both proxy routes load without auth. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d65cfd86df |
fix(geoportal-v2): exclude /api/basemap-style/* from middleware auth gate
MapLibre fetches the style URL from the browser as a `style:` source. For raster/style fetches it doesn't always carry the session cookie (varies by browser + request mode + cross-origin policy), so the middleware was hitting it with 401 "Authentication required" and the liberty basemap silently failed to load — back to the empty cream sheet we just fixed yesterday. The /api/basemap-style/[id] proxy returns a publicly-cached OpenFreeMap style with no user data — no reason to keep it behind auth. Adding it to the matcher's bypass list lets the browser fetch it cookie-less and the basemap renders correctly for everyone. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
9a7692f542 |
diag(cf-intern): instrument the proxy with session + upstream logging
Adds three log lines on the cf-intern route so we can pin down what the live "Unauthorized" message means without grepping for it: - "[cf-intern] in session=true hasAccess=… userEmail=…" at entry - "[cf-intern] forwarding to gis-api: …" before the upstream call - "[cf-intern] gis-api error status=… code=… body=…" on GisApiError - "[cf-intern] internal error: …" on anything else No behavioural change — purely diagnostic until we know whether the 401 originates in the architools session check, in the gis-api bearer validation, or in gis-api's enrichment_scope gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
588e4344e7 |
fix(cf): merge ePay + intern extracts into a single Extrase CF list
Yesterday's pin to /api/ancpi exposed a real architecture split: there are two CfExtract stores with no overlap and the previous pilot routing only ever showed one at a time: architools_postgres.CfExtract → ePay paid orders (type=epay) gis_core.CfExtract via gis-api → CF intern (type=intern) The pin made today's 50198 ePay visible but hid the 51 historic intern rows; the pre-pin state was the opposite. Neither was right — users think of "my CF extracts" as one timeline regardless of source. Revert the pin and add client-side merge for pilot users (`useGisAc=true`): fetchCfOrdersList now fans out to both /api/ancpi/orders and /api/cf/orders in parallel, normalizes each row through a dedicated adapter (legacy or gisApi), dedupes by id, and sorts by createdAt descending. fetchCfHas- CompletedForCadastral checks both backends too (either a fresh intern or a recent ePay row means "you already have one"). CfExtractRecord grows a required `type: 'epay' | 'intern'` field; the existing rendering adds a small colored badge (sky=intern, emerald=ePay) next to the status pill so users can tell where each row came from at a glance. cfDownloadUrl is now type-aware — intern rows download via /api/cf/:id/pdf, ePay rows via /api/ancpi/download regardless of pilot flag, matching how each store keeps its files. Legacy (useGisAc, id) signature still works for the few call sites that don't have the full row in scope. No data was deleted yesterday; the 51 intern rows in gis_core stayed intact (verified via gis_superuser). The single edit was cancelling the stuck 354686 pending row from 2026-05-19. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
a2581de599 |
fix(geoportal-v2): proxy OpenFreeMap planet TileJSON to bypass origin block
Root cause traced today: tiles.openfreemap.org/planet (the openmaptiles source's TileJSON ref inside the liberty + dark styles) returns 403 to ANY request that carries a browser Origin header. Cloudflare hot-link rule, presumably; bare curl (no Origin) gets 200 fine. Verified live with `curl -H 'Origin: https://tools.beletage.ro' …/planet` → 403, and with Playwright loading a minimal MapLibre test against openfreemap → "CORS policy: No 'Access-Control-Allow-Origin' header is present". Effect on the V2 panel: MapLibre fetches the liberty style fine, but the openmaptiles vector source is defined as { url: ".../planet" } and relies on a follow-up TileJSON fetch to learn the actual versioned tile URL (e.g. /planet/20260513_001001_pt/{z}/{x}/{y}.pbf). That fetch dies in the browser. No labels, no roads, no buildings — only the natural_earth raster background renders and the page looks like an empty cream sheet plus our PMTiles UAT outlines. That's exactly the "harta nu se mai vede bine" complaint. Other openfreemap endpoints (the style itself, sprites, glyphs, the individual versioned tile PBFs) all work fine with an Origin header — only /planet is blocked, so we only need to bypass that one. Fix: GET /api/basemap-style/[id] fetches the style + every source defined with `url:` server-side (no Origin → 200), inlines the resolved `tiles[]`/zoom range into the source, and returns a self-contained style. Browser only ever talks to tile endpoints directly afterwards, which work. Liberty + dark basemaps in the V2 map-viewer now route through this proxy. Cache-Control: 1h public + 1d SWR so we pick up new openfreemap versions promptly without hammering on every map load. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
d70442e26f |
fix(cf): bump modal poll 90→180s + pin CF list to /api/ancpi pre-Faza H
Three independent issues from the live test on 50198: 1. CfOrderModal poll timeout was 90s but ANCPI orders routinely take 60-180s end-to-end. The 50198 order completed at 129s (logs show 6 polls before docs matched), but the modal had already errored out 39s before that with "Procesarea durează mai mult decât ne-am așteptat". Bump to 180s + update the user-facing copy from "60 de secunde" to "3 minute" so the expectation matches reality. 2. cfApiBase(useGisAc=true) routed pilot users to /api/cf which proxies to gis-api → gis_core."CfExtract", but the ePay queue still writes ONLY to architools_postgres."CfExtract". Pilot users were therefore blind to their own fresh orders in the listing + catalog checks (50198 invisible despite being completed + downloadable). Pin all CF API calls to legacy /api/ancpi until Faza H mirrors writes to gis-api too; the source of truth then becomes a single table. 3. Manual cleanup of one stuck order in gis_enrichment.CfExtract (354686, pending since 2026-05-19) — never advanced past `pending`, was showing up as "În coadă" in the Extrase CF tab for ~4 days. Set status=cancelled with an explanatory errorMessage. (Applied via direct SQL on postgres-gis; no code change for this.) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5cfa6c8847 |
fix(geoportal-v2): disable Ortofoto ANCPI button in PIZ modal
gis-api currently returns 501 basemap_not_supported for basemap='orto' (needs orchestrator-side basemap endpoint that proxies the eTerra account pool — not yet wired). Showing a clickable button that errors out is bad UX; gate it with a dashed style + 'curând' badge + tooltip explaining the dependency so the user reaches for Google Satellite (default, fully working) instead. Re-enable when orchestrator ships the basemap endpoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
5a282234d2 |
chore: fix 'architots' → 'architools' typo in gis-api-client comments + plan 005
No code/contract change — comments and docs only. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
36840f31f6 |
fix(geoportal-v2): gate condo-owners on IS_CONDOMINIUM + visible empty state
Two issues from Marius's cladiri screenshots: 1. APARTAMENTE "se încarcă…" sat for ~10s then vanished — useEffect fired for every CLADIRI click regardless of whether the building was actually a condo. Orchestrator's /api/v1/building/condo-owners hit eTerra live, got back an empty list for non-condos, returned [], section auto-hid → user saw the spinner blink and disappear. New gate: useEffect waits for `detail` to land, then reads IS_CONDOMINIUM / PARCEL_IS_CONDOMINIUM from enrichment. If neither is `1`, skip the fetch entirely. Non-condos no longer pay the 10s eTerra round-trip just to show nothing. 2. EMPTY CONDO LISTS WERE HIDING SILENTLY — for buildings flagged condo where ANCPI hasn't registered units yet, the section would still vanish (`condoOwners.length > 0` check). Now: if the fetch returns [] AND the building is a condo, render the section with "Fără apartamente înregistrate la ANCPI." That's the truthful UX. Same fallback when the fetch errors — treat as empty rather than swallow. Render trigger flipped from (condoLoading || (condoOwners && condoOwners.length > 0)) to (condoLoading || condoOwners != null) so the section shows whenever the gate decided to fetch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
c7cf1aee49 |
feat(geoportal-v2): re-enable deep-enrich for cladiri + forward layerId
Orchestrator team shipped CLADIRI support in
/api/v1/parcel/enrich (PR4):
- DeepEnrichLayerId enum + optional layerId in input
- inferLayerIdFromCadref() regex /-C\d+(-U\d+)?$/ — covers apartments too
- loadFeature() now layer-aware (no more hardcoded
layerId='TERENURI_ACTIVE')
- fetchBuildingsForParcel skipped when running for a building
- HTTP layer accepts layerId in body
Confirmed live via gis-api container logs — manual-override calls
on "304629-C2" / "304629-C3" (CLADIRI) reach orchestrator and complete.
Architots side:
1. Re-enabled the "Actualizează" / "Încarcă din ANCPI" button for
cladiri. The disabled+tooltip gate I added last commit was the
right thing while the orchestrator path didn't accept buildings —
no longer needed.
2. refreshFromAncpi now forwards feature.layerId in the request body.
Skips the orchestrator's dash-suffix auto-detect — more reliable
than parsing "-C3" out of cadref each time.
3. ParcelRefBody type gained optional manualOverride + layerId fields
so callers can pass both through the thin client.
Note from orchestrator team: extractEnrichment populates only the
generic NR_CF / ADRESA / PROPRIETARI / PROPRIETARI_VECHI / SOLICITANT
keys today. The CLADIRE_TYPE / CLADIRE_DESTINATIE / CLADIRE_OBSERVATII
/ CLADIRE_NIVELURI etc. fields the V2 building panel renders come
from a different orchestrator pipeline (building-tech), already wired
+ populating those rows when the orchestrator's bulk-enrich runs.
Single-parcel deep-enrich for a building updates NR_CF/ADRESA/
PROPRIETARI but leaves CLADIRE_* alone unless that pipeline runs in
parallel — separate iteration if needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
49dcdadc44 |
fix(geoportal-v2): cladiri-aware deep-enrich button + clearer error
Marius hit "Parcela nu există în baza centrală gis_core." on building
304629-C3 (siruta=54975) — feature exists in gis_core (verified, id
70fd7485-fa39-4c38-9074-cfad154ed288) but orchestrator's
parcel-deep-enrich hardcodes layerId='TERENURI_ACTIVE' in its
pre-flight lookup:
// gis-sync-orchestrator src/lib/parcel-deep-enrich/fetch.ts L232–240
SELECT id, attributes, enrichment, "enrichedAt"
FROM gis_core."GisFeature"
WHERE "layerId" = 'TERENURI_ACTIVE'
AND siruta = $1 AND "cadastralRef" = $2
LIMIT 1
→ throws ParcelNotFoundError for any CLADIRI cadref. Architots
mapped that to a misleading "doesn't exist" message even though
by-ref correctly retrieved the building row up-front.
Architots-side fix (the proper fix is on orchestrator):
1. Error message for `parcel_not_found` now branches on
feature.layerId. For CLADIRI_ACTIVE the user sees:
"Deep-enrich nu suportă încă construcțiile — datele clădirii
vin via parcela părinte (gis-api orchestrator side)."
No more "parcela nu există" confusion when the parcel obviously
does.
2. "Actualizează" / "Încarcă din ANCPI" button in the Date eTerra
header is now disabled when isCladiri. Tooltip explains why.
The button is the one that triggers refreshFromAncpi →
/parcel/enrich → the very orchestrator path that doesn't handle
buildings.
Result: building panels show whatever's in gis_core (CLADIRE_TYPE,
CLADIRE_DESTINATIE, etc. if previously enriched via eterra.live's own
flow) and don't pretend they can be re-fetched.
Next step (orchestrator session): teach loadFeature() to accept an
optional layerId param OR auto-detect from cadref-suffix pattern
(/-C\\d+$/) so /parcel/enrich works for both layers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
18c0eed91e | diag(find): log by-ref status+code on every result | ||
|
|
52c31e3c4d |
feat(geoportal-v2): UAT name + SOLICITANT into Înscriere + Google Maps inline
Iteration on the info panel per Marius's feedback.
1. UAT NAME IN HEADER
New uat-lookup.ts hook loads public/uat.json (3,186 rows, ~95 KB,
one-shot fetch + Map cache + subscribers) and exposes
useUatName(siruta). Header reads:
Terenuri · 2.400 m² · FELEACU · 57582
instead of just "SIRUTA 57582". The localitate name lives in front
of the bare siruta number (muted, smaller weight) — siruta is
still there for ops + tooltip, just not the primary signal.
2. SOLICITANT MOVED INTO ÎNSCRIERE
Was rendered as a prominent User-icon line right above PROPRIETARI,
which led to "BOJAN ELENA = current owner?" confusion. The two
fields semantically differ: SOLICITANT is the person who filed the
most recent ANCPI application (e.g. the new buyer initiating a
transfer), PROPRIETARI is who's currently registered as owner. Now
SOLICITANT is collapsed into the existing Înscriere <details> next
to TIP_INSCRIERE / DATA_CERERE / ACT_PROPRIETATE — the
registration-metadata bucket where it belongs.
3. GOOGLE MAPS INLINE WITH ADDRESS
When ADRESA exists, the Google Maps text-link sits right of the
address (using feature.lat/lng for the query). One-tap go-to-map
without a separate Localizare section.
4. LOCALIZARE → COLLAPSIBLE
Bottom Localizare card becomes a closed-by-default <details>.
Inside: WGS84 lat/lng, SIRUTA, and a separate Google Maps link.
ID (objectId) shows in the summary line. Mirrors eterra.live's
approach. The redundant Feleacu/coords echo at the bottom is
gone — coords are still one click away when needed.
NOT in this commit (parked for follow-up):
- PIZ / Plan situație / Coord. / DXF actions — would mean porting
eterra.live's three /api/geoportal/{piz,pad,coords-xlsx} document
generators. Substantial work (mapbox-static-image render +
server-side PDF layout); needs its own session.
- CF intern (gratuit) vs Extras CF (1 credit) split — current
"Comandă CF" modal already handles both pool/connection states,
but the two-button visual split mirroring eterra.live's catalog-
hit fast path is a smaller follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
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>
|
||
|
|
653cffeee3 |
fix(geoportal-v2): use siruta from search response — no more N+1 misses
Marius hit "Date ne-încărcate" / "Parcela nu există" on Feleacu parcels
(SIRUTA 57582, cadref 61745 / 61746) even though gis_core has 28 rich
enrichment keys for them. Root cause: 232 features in gis_core share
cadref `61745` across different UATs. Our find proxy was doing:
1. gisApi.search(cad, limit=20)
2. for each candidate (up to 20): parcela.get(id), check siruta
Feleacu's parcel sat past position 20 in the search ranking, so we
never tried parcela.get on it — fallback returned a sibling parcel
with 0 keys (the "Date ne-încărcate" UI) or no readable candidate at
all (the "nu există în DB centrală" 404 UI).
This was wrong on two counts:
1. WE WERE DOING N+1: gis-api's /api/v1/search already returns siruta
per feature (see gis-api src/routes/search.ts:41). One round-trip
would have given us the answer; we just weren't reading the field.
Updated src/lib/gis-api-client.ts to declare siruta in the
response type + bumped default limit from 20 → 50 (gis-api's
server-side cap).
2. WE WERE FAILING SILENTLY: when search-cap was the actual bottleneck
the proxy returned 404 with no hint that gis-api had more
data we just couldn't reach. New find proxy:
- First pass: direct match on cadref + layerId + siruta from the
search response. Single follow-up parcela.get to fetch full
detail. No more sequential probing.
- If no direct match: log + report distinctively. When the search
returned MAX_LIMIT (50) features all with the same cadref, we
return 422 search_limit_exceeded with a hint about the missing
siruta filter. Otherwise 404 (genuinely not in gis_core).
3. Panel surfaces the 422 with a plain-language explanation rather
than the raw "Eroare: ..." dump.
For the long-term fix: gis-api needs either a `siruta` query param on
/api/v1/search OR a dedicated /api/v1/parcela/by-ref?siruta&cad&layerId
endpoint that does a single indexed lookup. Today's patch handles the
top-50 case (was top-20); the 422 surfaces the residual cases for
follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
7b01744fad |
feat(geoportal-v2): on-map selection highlight
When the user clicks a parcel or building, render a subtle overlay so they can tell at a glance which feature corresponds to the open info panel. Four new MapLibre layers: v2-terenuri-selected-fill — green tint (#15803d/0.25) v2-terenuri-selected-line — darker green stroke (#14532d/2.5px) v2-cladiri-selected-fill — strong blue (#1d4ed8/0.55) v2-cladiri-selected-line — navy stroke (#0c2050/1.6px) All four start with a filter that matches nothing (==,object_id,-1). A new useEffect in MapViewer watches `selectedFeature` (passed down from GeoportalV2's `clicked` state) and updates the filter on the matching layer pair via map.setFilter on every change. Switching TERENURI ↔ CLADIRI clears the other layer pair, so the highlight never doubles up. selectedFeature.objectId is the filter key — comes straight from PMTiles' object_id property and is reliable across both layers. If the value isn't numeric (search-dropdown features sometimes lack it), the filter falls back to no-match — the panel still works, just no on-map glow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
8d5316dd1b |
feat(geoportal-v2): map styling parity with eterra.live
Marius didn't like the previous map palette + missing labels.
Re-paint to match eterra.live's V2 map:
UATs:
Was: #9ca3af / #6b7280 (two-tone gray)
Now: #7c3aed violet, width ramps 0.3 → 0.6 by zoom band
Plus new v2-uats-z8-label symbol layer that prints the UAT name
at z9–12 (Noto Sans 10px, violet text + white halo).
Parcele (terenuri):
Was: #0ea5e9 sky-blue line, transparent 0.001 fill (hit-only)
Now: #22c55e green fill at 0.15 opacity (so the click area is
visible at the same time it acts as hit-target) + #15803d
darker green outline at 0.8 / 0.9 opacity. Cadastral-ref label
layer renders at z17+ (small enough zoom that the polygon can
carry a 10px label without overlapping neighbours).
Cladiri (buildings):
Was: #fb923c orange / #ea580c dark orange
Now: #3b82f6 blue fill at 0.5 / #1e3a5f navy line — same blue
eterra.live uses, distinct from the green parcel underlay and
from the violet UATs.
Cladiri „fără acte" (build_legal == 0):
NEW. Two amber overlay layers, filtered on the tile's
build_legal property:
v2-cladiri-unreg-fill — #f59e0b at 0.6 opacity
v2-cladiri-unreg-line — #b45309 at width 1
Renders on top of the default cladiri layers so illegal builds
visibly pop. If a PMTiles tile doesn't carry build_legal yet
the layer is empty — no regression on legal buildings.
Building suffix label (C1, C2, C3…):
NEW. v2-cladiri-label symbol at z16+. Mirrors eterra.live's
expression: extract the slice after the last "-" of
cadastral_ref ("354686-C1" → "C1"). Noto Sans 9px, navy text +
white halo so it reads on both light and dark basemaps.
Click handler unchanged — v2-cladiri-fill covers ALL buildings
(no filter), so legal vs unreg both route through the same query.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5fd8881571 |
feat(cf-order): wire session userId + surface DB-only cols in Prisma
Follow-up to the 2026-05-20 schema-drift ALTER. Now that the DB
accepts the create() call, also do the cleanup:
1. PRISMA SCHEMA — added the four DB-only columns that were
previously raw-SQL only. CfExtract now declares:
userId String? // Authentik sub of orderer
type String? @default("epay") // 'epay' | 'admin'
pdfData Bytes? // legacy inline PDF
adminOrderedBy String? // ops who placed for someone
Plus two new indices: @@index([userId]) and the composite
@@index([userId, nrCadastral]) so per-user "my orders" lookups
don't scan. Prisma client regenerated; type-check clean.
2. SESSION → USER ID PROPAGATION — /api/ancpi/order now reads the
NextAuth session at request time and stamps the userId onto each
parcel before enqueue:
const session = await getAuthSession();
const userId = session?.user.id ?? session?.user.email;
const stampedParcels = parcels.map(p => ({ ...p, userId: p.userId ?? userId }));
Body-supplied userId still wins (admin/cron paths can override).
3. ENQUEUEORDER PATH — CfExtractCreateInput gained an optional
userId field. epay-queue.ts's tx.cfExtract.create({}) now sets:
userId: input.userId, // (undefined → NULL, allowed post-patch)
type: "epay", // explicit; DB also has default but
// setting it makes the column visible
// in Prisma RETURNING reads.
After this commit, new orders carry the orderer's identity. Existing
NULL-userId rows from before this fix stay as-is (DB allows NULL).
Future RLS work on architots_postgres (if it survives Faza H) can
key off this column.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
52e16e7807 |
fix(cf-modal): portal to body + auto-close on parcel switch
Two related issues with the modal when the user kept clicking around the map while in CF order mode: 1. LAYOUT BREAK (Marius screenshot — modal header clipped above viewport): The V2 panel wrapper uses `backdrop-blur-md`. Per CSS spec, an element with non-none backdrop-filter establishes a containing block for `fixed`-positioned descendants. So `fixed inset-0` on the modal was relative to the panel (top-right, ~50px tall at min) instead of the viewport — the modal anchored to the panel and overflowed up. Fix: render via React's createPortal to document.body. The modal now escapes the panel's stacking context entirely and centers in the viewport. Also bumped z-index from 50 to 100 so the modal stays above the MapLibre canvas + panel itself. 2. STATE CARRY-OVER: clicking a different parcel while the modal was open silently re-targeted the modal at the new parcel — same modal showing different cadref/sold mid-flow could mislead the user about which parcel they were buying CF for. Fix: FeatureInfoPanel now has a useEffect that closes the modal when feature.cadastralRef / siruta / layerId changes. Modal stays scoped to a single decision. SSR guard: if (typeof document === "undefined") return null; before the portal call so the modal doesn't blow up during server-side render. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ad89da690f |
fix(cf-modal): inline auto-connect + credential form — no parcel-sync hop
Marius: don't kick the user out to /parcel-sync just to connect ePay,
do everything inside the modal. The parcel-sync page also wasn't
helpful when reached (UAT selector empty), so the redirect was a
dead-end anyway.
State machine rewrite:
loading-status → GET /api/ancpi/session
not-connected → DELETED (replaced by transparent flow)
connecting → POST /api/ancpi/session with {} — server picks up
ANCPI_USERNAME/ANCPI_PASSWORD from env (Infisical
has them in /architools), connects silently
need-credentials → only if env creds are missing OR invalid: shows
an inline form (username / password / Conectează
button + privacy note "nu sunt păstrate la noi
după sfârșitul sesiunii")
no-credits / ready / placing / processing / done / error — as before
Flow for the happy path (Marius's case): user clicks "Comandă CF" →
modal shows "Conectare la ePay ANCPI…" for ~1s → "Verificare credite"
done → "Ești sigur? 1 credit, mai ai X" → confirm → animated steps
→ done. Zero page navigations.
Flow for the no-env case (other tenants or first-run): user sees
inline form, types credentials, presses Conectează → server stores
them in the in-memory session for the lifetime of the request,
modal continues straight to "ready".
Removed:
- goToParcelSync() handler + "Conectează ePay" deep-link button
- "not-connected" UI panel
- Phase value "not-connected" (no longer reachable)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
5e4618b309 |
feat(geoportal-v2): inline CF order modal — confirmation + animated steps
Marius: click "Comandă CF" from the card itself, no new-tab to
parcel-sync. Show "Ești sigur? Costă 1 credit, mai ai X" first.
Animate the order through its phases until done.
New component cf-order-modal.tsx — a 7-state machine over a single
shadcn-style dialog:
loading-status — checks /api/ancpi/session for connection + credits
not-connected — ePay session offline → prompt to connect via
parcel-sync (the only place credentials live)
no-credits — 0 credits, can't proceed
ready — confirmation: 1 credit cost, current balance,
projected balance after the order, all in
rounded chips with Coins icon
placing — POST /api/ancpi/order, spinner on step 1
processing — poll /api/ancpi/orders every 3s until status
becomes completed/done/minioPath populated.
Shows live elapsed seconds; 90s timeout falls
through to error with "verifică din nou peste
câteva minute".
done — checkmark anim + "Descarcă PDF" if document URL
came back
error — destructive panel + Reîncearcă button
Animations (tailwindcss-animate utilities):
- Modal backdrop: fade-in 200ms
- Modal card: zoom-in-95 + slide-in-from-bottom 200ms
- Step rows: active row gets primary-tinted bg + Loader2 spin,
done rows turn emerald + Check icon zooms in 300ms
- Success/error final state: rounded badge + icon zooms in 500ms
Footer adapts per phase: Anulează+Confirmă (ready), Conectează ePay
(not-connected), Închide (loading/no-credits), Închide fereastra
(placing/processing — order continues in bg), Gata (done), Închide+
Reîncearcă (error).
Wires into feature-info-panel by replacing the "open /parcel-sync"
click handler with setCfModalOpen(true). Modal mounts at the
panel's root with fixed positioning + z-50 so it overlays the map.
Backdrop click dismisses except during placing/processing.
Uses the legacy /api/ancpi/* endpoints (not /api/cf/* gis-ac route)
per Marius's earlier decision to keep credit tracking on his own
ePay session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
8f86bab337 |
fix(geoportal-v2): remove eterra.live links + fix Actualizează wrap
Per Marius's feedback: eterra.live is a separate product, ArchiTools
shouldn't link out to it. Removed both touch-points:
1. The "eterra.live" button I'd added beside "Actualizează" in the
Date eTerra header — gone. This was also breaking layout (the
second button forced "acum câteva secunde" to wrap into "acum /
câteva / secunde" stacked above the button text).
2. The "Export GPKG" action in the toolbar — gone. It used to deep-
link to eterra.live/harta?…&autoexport=geopackage. Toolbar now
holds just "Comandă CF" which stays internal (/parcel-sync).
While in there:
- Actualizează button: `whitespace-nowrap` + `shrink-0` so it stops
wrapping when the panel is at min-width.
- Dropped the inline " · acum X min" beside the button label —
cleaner button, less truncation risk.
- Resurfaced the relative time as a small line at the bottom of the
Date eTerra body ("Actualizat din ANCPI · acum 3 min"). Same info,
no layout pressure.
- Cleaned unused lucide imports (Download, ExternalLink, Hash,
Layers, CalendarDays) that were leftover from the removed
eterra.live button + Cladire icon experiments.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
3004790ad2 |
feat(geoportal-v2): cladire characteristics + eterra.live link + collapsible Înscriere
Per Marius's iteration on the panel: 1. AUTO-ENRICH DISABLED — the panel no longer fires /parcel/enrich on sparse-data load. Refresh only via the explicit "Actualizează" button in the Date eTerra header. Keeps the eTerra account pool safe from browse-spam (was draining 500/h on rapid clicks). 2. eterra.live link button — sits beside "Actualizează" in the Date eTerra header. Opens https://eterra.live/harta?siruta=...&cad=... in a new tab so the user can cross-check the full eterra.live panel. 3. ÎNSCRIERE collapsible — Tip înscriere / Data cererii / Act proprietate now hide inside a <details> closed by default (per the highlighted-block screenshot). Keeps the "above the fold" info trimmed to what matters at a glance. 4. CARACTERISTICI CORP — new section, only rendered for CLADIRI_ACTIVE clicks. Shows the cladire-specific enrichment fields the orchestrator populates after a deep-enrich: - Chip row: tip / destinație / subtype (chips) + Condominium chip with unit count + Cu/Fără acte status pill - 3-col metric strip: Regim înălțime / Niveluri / An construire - Suprafață CF, CF IE, Clasă energetică, Părți comune (rows) - Observații (multi-line InfoBlock) Fields wired: CLADIRE_TYPE, CLADIRE_DESTINATIE, CLADIRE_SUBTYPE, CLADIRE_REGIM, CLADIRE_NIVELURI, CLADIRE_AN_CONSTRUIRE, CLADIRE_AREA_CF, CLADIRE_OBSERVATII, CLADIRE_LANDBOOK_IE, CLADIRE_COMMON_PARTS, CLADIRE_UNITS_NO_ANCPI, CLADIRE_ENERGETIC_CLASS, IS_LEGAL_BUILDING, IS_CONDOMINIUM. All show only when populated (no empty "-" rows). LABEL map extended with Romanian translations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
4f38fd1070 |
feat(geoportal-v2): compact eterra.live-style layout + buildings list
Significant restructure of the parcel info panel based on Marius's
side-by-side comparison with eterra.live. Same data + same workflow,
much denser layout.
Layout changes:
1. HEADER — status dot + cadref + small uat/area/SIRUTA line. Removed
the redundant "Activ"/"Inactiv" chip (the dot is the signal).
Building suffixes (C1, C2…) still resolve via the search-by-cadref
path; header shows the full cadref ("354686-C1") so the user sees
both parent and suffix.
2. CARACTERISTICI — chips row only; tighter padding (px-2 py-1.5
vs px-3 py-2.5). Same intravilan / categorie / corpuri.
3. METRIC STRIP — new. Three cells in a single divided pill: GIS /
2D eTerra / Legală. Same pattern eterra.live uses. Saves a whole
section worth of vertical space.
4. DATE ETERRA CARD — wrapped in a bordered subtle-bg container with
the refresh button INLINE in the section header (vs at the bottom
of the panel). Shows "acum X min" relative time when enriched.
Two-column NR. CF + Nr. topo. Adresă with pin icon. Solicitant
with user icon. Proprietari as InfoBlock (multi-line preserved).
Foști proprietari as <details> collapsible (closed by default).
Înscriere group (tip / data / act) as a small subsection.
5. CONSTRUCȚII LIST — new. For TERENURI parcels, fetches the
building siblings via gisApi.search(parentCadref) + filter on
"<parent>-" prefix. Renders BuildingRow per cladire:
- Icon (Home / Building2 / Factory / Warehouse from destinatie)
- C1/C2/C3… suffix (mono, font-semibold)
- Area
- "Cu acte" (green) / "Fără acte" (amber) / "Necunoscut"
pill from BUILD_LEGAL / PARCEL_HAS_LANDBOOK enrichment
Click row → onSelectFeature switches panel to that building.
Lazy isLegal hydration: row first shows "Necunoscut", then
parallel parcela.get for each building fills the pill (5ms per
cache hit, no blocking).
6. APARTAMENTE — same content as before (for CLADIRI clicks), now
sits beside Construcții in the same flow. Header consistent with
the other section labels.
7. LOCALIZARE — moved to a single tight strip (lat/lng + Google
Maps link). Removed the SIRUTA repetition since it's already in
the header.
8. ACTIONS toolbar — compressed. Removed the in-toolbar "Citește din
ANCPI" button since the refresh button is now inside the Date
eTerra card header (where it belongs contextually). Kept Export
GPKG + Comandă CF.
GeoportalV2 wires onSelectFeature={setClicked} so building rows
propagate. Building clicks reuse the same panel + same auto-enrich
flow — the second feature.layerId === CLADIRI_ACTIVE branch in the
condo-owners useEffect kicks in for buildings.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a4f61bf3d8 |
feat(geoportal-v2): manual fetch flag + friendlier pool-exhausted error
Two operational gaps observed after PR3 deep-enrich rollout:
1. Raw \"Eroare: no_available_account\" surfaced when the eTerra
account pool hit its hourly quota. Replace with a plain-language
note ("Pool-ul ANCPI e temporar epuizat — încearcă peste câteva
minute"). Same friendly treatment for the other common
orchestrator errors: no_immovable_match, parcel_not_found,
eterra_fetch_failed.
2. Marius wants the auto-trigger (fires on sparse-data load) and the
explicit "Citește din ANCPI" button to be separable on the
orchestrator side. Casual map browsing burns through the 500/h
quota with auto-triggers; a working session that needs 20-30
specific parcels shouldn't be starved.
refreshFromAncpi now takes { manual?: boolean }. The button passes
manual: true → request body includes manualOverride: true. The
auto-trigger useEffect calls it with no argument (manual defaults
to false). gis-api / orchestrator can later route manualOverride
to a separate-quota bucket or skip the per-hour check entirely.
Until then the flag is harmless (orchestrator ignores unknown
fields).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
02a466ccaa |
feat(geoportal-v2): swap refresh path to /parcel/enrich (deep-enrich)
gis-api session shipped PR3 (gis-api 09f1ab8 + gis-sync-orchestrator
0371d81): new POST /api/v1/parcel/enrich does the full eTerra
round-trip (searchImmovableByIdentifier → fetchDocumentationData
→ fetchImmovableParcelDetails) and merges NR_CF / ADRESA / PROPRIETARI
+ 20-plus fields into gis_core.GisFeature.enrichment with a 30-day
cache. Verified on 266888 + 328607 → 27 keys with full PII.
Wired in three places:
1. src/lib/gis-api-client.ts — gisApi.parcel.enrich({siruta,
cadastralRef, force?}) thin wrapper.
2. src/app/api/gis/parcel/enrich/route.ts — architots-side proxy,
matches the parcel/tech pattern (auth check → forward → bubble up
GisApiError status codes).
3. src/modules/geoportal/v2/feature-info-panel.tsx — refreshFromAncpi
now POSTs to /api/gis/parcel/enrich instead of /api/gis/parcel/tech.
After the orchestrator returns, the panel re-fetches the canonical
record via parcela.get (when uuid known) or parcela.find (when
not), so it sees exactly what gis_core stores rather than the
orchestrator response shape.
The existing auto-trigger (fires when detail has no NR_CF/ADRESA/
PROPRIETARI) now actually fills those fields. Subsequent clicks on the
same parcel hit gis-api's 30-day cache (5ms vs 1-2s live fetch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
87f9d72e4f |
feat(geoportal-v2): auto-fetch enrichment when DB only has tech keys
Parcel 328607 in Cluj-Napoca (and many others) is in gis_core with
only 10 enrichment keys, all tech-level (PARCEL_HAS_LANDBOOK,
PARCEL_IS_CONDOMINIUM, etc.) — no NR_CF, no ADRESA, no PROPRIETARI.
The panel renders correctly but with nothing of substance shown.
User had to manually click "Citește din ANCPI" to backfill.
Now auto-fires the fetch when:
- Panel mounts a fresh detail (or after parcel switch)
- AND enrichment lacks ALL three of NR_CF / ADRESA / PROPRIETARI
- AND we haven't already auto-fetched for this parcel this tab
session (sessionStorage dedupe keyed by uuid OR siruta+cad+layerId)
Visible feedback while it runs: a quiet "Se preiau date suplimentare
din ANCPI…" strip below the loading area. The user can keep reading
whatever is already on screen.
Side fixes:
- refreshFromAncpi → useCallback (stable deps) so it can sit in the
auto-trigger useEffect's dep array without infinite loops.
- Refresh path now uses /api/gis/parcela/find as the fallback when no
uuid is available, matching the initial-load logic. The old
orchestrator-shape projection still exists as a last-resort fallback
but is rarely hit.
Note: orchestrator's parcel/tech only re-populates PARCEL_* tech
fields. Truly enriching rich PII (NR_CF/ADRESA/PROPRIETARI) needs the
"deep enrich" orchestrator path which the gis-api proxy contract
doesn't expose yet — separate gis-api task. So parcels that only ever
got tech-level enrichment will stay at tech-level even after this
auto-fetch. The visible improvement is: parcels that DO have rich
data load it in the first second instead of needing a manual click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
342bdca648 |
fix(geoportal-v2): structured panel sections + readable labels (back to basics)
Marius's feedback: "datele arată foarte ciudat" — the flat dl renderer
showed enrichment keys in whatever order gis-api returned them, with
raw key names (PARCEL_POSTAL_NO instead of "Nr. poștal") and no visual
hierarchy. With 20+ keys the important things (NR_CF, ADRESA,
PROPRIETARI) ended up below the fold under PARCEL_* tech metadata.
Restructure to mirror eterra.live's parcel-info layout:
1. HERO BLOCK at the top of the data area
- "Nr. Carte Funciară" big mono number (NR_CF)
- NR_CF_VECHI shown below when different
- Adresă with pin icon and proper text wrapping
2. NAMED SECTIONS (rendered in fixed order, only when populated)
- Proprietari — splits comma/semicolon-separated names into a list
- Cadastru — NR_CAD, NR_TOPO, PARCEL_TOPO_NO
- Suprafețe — SUPRAFATA_R, SUPRAFATA_2D, PARCEL_LEGAL_AREA, SUPRAFATA
(values parsed + " m²" suffix)
- Înscriere — SOLICITANT, TIP_INSCRIERE, DATA_CERERE, DOC
- (CF/Adresă removed from the list because they're in the hero)
3. CARACTERISTICI CHIPS (existing) — Intravilan / Categorie / nr corpuri /
status stay at the top. CARACTERISTICI_KEYS excludes them from the
sections below so nothing is shown twice.
4. DETALII TEHNICE (collapsed by default) — anything not in a named
section: PARCEL_HAS_LANDBOOK, PARCEL_IS_CONDOMINIUM,
PARCEL_TECH_ENRICHED_AT, etc. Renders with friendly labels (Da/Nu
for flags, dd/mm/yyyy hh:mm for dates) instead of "1" / ISO strings.
5. Value renderers per key type:
- formatAreaValue("456" | "456.06" | "456 mp") → "456 m²"
- formatFlag(0/1, da/nu) → "Da" / "Nu"
- formatDate(ISO) → "19.05.2026, 04:40" (Romanian locale)
- parseOwners("MATHBOUT MOHAMED MAHER, DR.") → list items
6. enrichedAt moved to a single small line at the bottom of the data
area instead of a per-section caption.
LABEL map covers all 25 enrichment keys observed in DB. Anything new
falls back to raw key name (visible — easy to spot and add).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
a23ba1957f |
fix(geoportal-v2): silent auto re-grant on scope-missing 403
Removes the "Re-loghează-te" button + scope-mismatch warning prose.
On 403 from /api/gis/parcela/find the panel now:
1. Checks sessionStorage flag — false on first 403 of the tab
2. Sets the flag, fires signIn("authentik", { callbackUrl: current
URL }) silently. For an SSO'd user this is a sub-second Authentik
redirect cycle that mints a fresh access_token with the right
scope claims, lands the user back on the same panel, and the
re-mount fetches successfully — no visible message, no prompt.
3. If another 403 happens after the retry (i.e., Authentik genuinely
can't grant the scope — config issue, not a stale-token issue),
falls through to a discreet "Datele detaliate nu pot fi încărcate
momentan." note. No call-to-action, no jargon.
4. On any successful 200 fetch, clears the sessionStorage flag so a
future 403 in the same tab can re-trigger the silent retry.
Per Marius: "vreau doar să meargă, safe și fix" — no auth-flow
chrome shown to the user. The recovery is part of the system's
correctness contract, not a feature for the user to manage.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
71df1ee9ec |
fix(geoportal-v2): surface scope-insufficient instead of silent 404
The other-session's gis-api investigation found that gis-api is
working correctly — full/basic/none scopes all behave per spec.
The bug was in our /api/gis/parcela/find proxy: when EVERY candidate
returned 403 from gis-api (because the caller's JWT carried no
enrichment_scope claim), the proxy swallowed the 403s and returned
silent 404. The panel then rendered the "not in central DB" empty
state instead of prompting re-login.
This was the case for Marius today — his pre-refresh-fix session
held a token without the enrichment claim. After the auth self-heal
fix (commit
|
||
|
|
8ff67d19fb |
fix(auth): self-heal + auto re-login on refresh failure
Three-layer fix for the "session keeps dying with invalid_grant" pain:
1. Authentik provider config (separate change via API):
access_token_validity bumped 5min → 60min so refreshes are 12x less
frequent. Refresh-token rotation collisions only happen during the
refresh, so a longer access_token TTL means far fewer windows.
2. jwt callback (auth-options.ts): when Authentik responds 400
invalid_grant on refresh, the stored refresh_token is permanently
dead — Authentik rotated it on a previous successful refresh and the
old value can't be reused. Clear it (and the access_token) from the
JWT so subsequent session checks see a clean RefreshAccessTokenError
instead of looping into the same 400 every 5 minutes.
3. SessionErrorWatcher (new client component, mounted in providers
tree): listens for session.error === "RefreshAccessTokenError" and
calls signIn("authentik") with the current URL as callback. The
cleared JWT cookie means Authentik runs a full OIDC flow, mints fresh
tokens, and the user lands back where they were. No manual logout.
Net effect: refresh storms become invisible — at worst there's a single
redirect to Authentik (silent if the user is still SSO'd) instead of a
broken session that 401s every API call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
1786c254d5 |
diag(gis): /api/gis/me proxy → surface Authentik claims for scope debugging
f9bf2ca4 has 25 enrichment keys in gis_core.GisFeature but parcela.get returns only 10 — all PII (NR_CF, ADRESA, PROPRIETARI) redacted. Symptom of enrichment_scope=basic. Plan 003 §Faza B says Arhitecti LDAP group should get full. Need to verify the mapping. Calls gisApi.me() and returns the claims. Logs them server-side (truncated to 500 chars). Marius hits the URL once, we see what enrichment_scope his JWT actually carries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
7afba6e1a9 |
fix(geoportal-v2): siruta-aware parcela lookup (B1 round 2)
Previous fix searched by cadastralRef and picked the first layerId-matching result. But cadastral refs collide across UATs: "354686" exists in multiple counties. The Cluj-Napoca f9bf2ca4-... parcel with full enrichment got passed over for a same-cad parcel in another UAT that has no enrichment → panel rendered header + "Caracteristici" with empty Intravilan, no "Date eTerra" section. New server-side /api/gis/parcela/find?siruta&cad&layerId proxy: - gisApi.search(cad) → filter by layerId → up to ~20 candidates - For each candidate, parcela.get and check stored siruta - Return the siruta-matching detail - Fallback: first readable candidate (so the panel still has data even if siruta mismatch — better than empty) Panel useEffect simplified: fast path = parcela.get by uuid when the tile has one, slow path = parcela/find when not. 404 from find sets the "not in central DB yet" empty state (user can hit Citește din ANCPI to trigger orchestrator live-fetch). Diagnostic logs: [gis-parcela-find] siruta=… cad=… layerId=… candidates=N + per-hit "has_enrich=true keys=N" so we can tell from container logs whether the right parcel resolved. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |