Commit Graph

565 Commits

Author SHA1 Message Date
Claude VM 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>
2026-06-04 18:08:33 +03:00
Claude VM 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>
2026-06-04 18:00:17 +03:00
Claude VM 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>
2026-06-04 17:45:55 +03:00
Claude VM 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>
2026-05-27 15:32:33 +03:00
Claude VM 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>
2026-05-25 09:38:16 +03:00
Claude VM 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>
2026-05-25 07:03:51 +03:00
Claude VM 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>
2026-05-25 00:49:12 +03:00
Claude VM 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>
2026-05-24 12:38:06 +03:00
Claude VM 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>
2026-05-24 10:47:18 +03:00
Claude VM 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>
2026-05-24 10:43:34 +03:00
Claude VM 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>
2026-05-24 10:33:28 +03:00
Claude VM 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>
2026-05-24 00:51:02 +03:00
Claude VM 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>
2026-05-24 00:37:15 +03:00
Claude VM 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>
2026-05-23 22:52:57 +03:00
Claude VM 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>
2026-05-23 18:15:27 +03:00
Claude VM 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>
2026-05-21 14:58:29 +03:00
Claude VM 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>
2026-05-21 08:59:58 +03:00
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
Claude VM 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>
2026-05-20 22:03:18 +03:00
Claude VM 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>
2026-05-20 21:41:12 +03:00
Claude VM 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>
2026-05-20 19:18:08 +03:00
Claude VM 18c0eed91e diag(find): log by-ref status+code on every result 2026-05-20 19:09:21 +03:00
Claude VM 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>
2026-05-20 17:49:46 +03:00
Claude VM 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>
2026-05-20 15:10:59 +03:00
Claude VM 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>
2026-05-20 12:57:31 +03:00
Claude VM 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>
2026-05-20 09:10:39 +03:00
Claude VM 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>
2026-05-20 09:08:41 +03:00
Claude VM 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>
2026-05-20 08:53:14 +03:00
Claude VM 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>
2026-05-20 08:30:26 +03:00
Claude VM 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>
2026-05-20 08:10:33 +03:00
Claude VM 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>
2026-05-20 07:53:15 +03:00
Claude VM 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>
2026-05-20 07:44:12 +03:00
Claude VM 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>
2026-05-20 07:36:01 +03:00
Claude VM 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>
2026-05-20 06:57:27 +03:00
Claude VM 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>
2026-05-19 22:44:25 +03:00
Claude VM 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>
2026-05-19 22:24:02 +03:00
Claude VM 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>
2026-05-19 19:52:00 +03:00
Claude VM 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>
2026-05-19 17:17:48 +03:00
Claude VM 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>
2026-05-19 16:57:42 +03:00
Claude VM 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 8ff67d1) the next gis-api call would have re-authed
correctly, but the panel never gave him that signal because find
hid the 403.

Fix in two places:

1. /api/gis/parcela/find:
   - Count 403s seen during candidate iteration
   - If forbiddenCount > 0 && forbiddenCount === candidates.length,
     return 403 { error: "scope_insufficient", ... } with a log line
     [gis-parcela-find] all_candidates_forbidden siruta=X cad=Y N
   - Otherwise log [gis-parcela-find] no_match (so we never go silent)

2. feature-info-panel: when fetch returns 403, the existing
   "forbidden" UI was a passive warning. Now it shows an actionable
   "Re-loghează-te" button that fires signIn("authentik", {
   callbackUrl: current }) — same path SessionErrorWatcher uses for
   RefreshAccessTokenError.

Reference: gis-api session report 2026-05-19 (Marius forwarded
analysis); the gis-api repo is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:52:47 +03:00
Claude VM 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>
2026-05-19 16:23:50 +03:00
Claude VM 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>
2026-05-19 16:02:36 +03:00
Claude VM 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>
2026-05-19 15:26:49 +03:00
Claude VM b5eff5acc1 fix(geoportal-v2): rewrite info panel — auto-fetch + sections + condo + basic mode
Root cause of B1 (panel showed "Apasă din ANCPI" even with full enrichment
in DB): PMTiles overview tiles don't carry the GisFeature uuid, only
siruta/cadastral_ref/object_id. The panel's useEffect bailed out at
`!feature.id` and never fetched. So the data was there, the UI just
refused to ask for it.

Fix: when the click feature has no uuid, the panel now calls
`/api/gis/search?q=<cadref>`, filters by layerId match, and uses the
returned id to do `parcela.get(id)`. One extra round trip (~50ms with
the trigram-idx fix from 2026-05-18). For features arriving from the
search dropdown the uuid is already known — that path is unchanged.

Panel redesign — same data shape as eterra.live, ArchiTools styling
(shadcn instead of HeroUI), single-file:
  - Header: cadref + layer + area + status chip + close
  - Caracteristici: intravilan + categorie folosință + nr corpuri (chips)
  - Date eTerra: all enrichment fields, PII passes through gis-api scope
    redaction (scope=basic → PROPRIETARI/NR_CF/DOC already null)
  - Apartamente (condominium): for CLADIRI_ACTIVE clicks, fetches
    /api/gis/building/condo-owners and renders units with owners + cf + area
  - Localizare: click lat/lng + Google Maps link + SIRUTA echo

Two new proxy routes (thin wrappers over gis-api):
  - POST /api/gis/parcel/units-fetch
  - POST /api/gis/building/condo-owners

Basic-panel mode for restricted users (per Marius: "for users I don't
want to give full access to"):
  - New env BASIC_PANEL_USERS (csv emails) → session.basicPanel flag
  - Optional PANEL_BASIC_GLOBAL=1 to force-basic everyone
  - When true, panel renders only header + cadref + suprafață + a
    restriction notice; all sections + condo fetch are skipped
  - Defaults to off; pilot user Marius gets full panel as before

map-viewer now forwards lngLat on click so the Localizare section has
coordinates without a second lookup.

Type-check clean. Production build (NODE_ENV=production npx next build)
passes. The dev-mode prerender error on / page is pre-existing (Next 16
useContext-null on client component during static export, unrelated).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 12:35:09 +03:00
Claude VM ac193128d9 test(deploy): verify webhook id=5 signs request 2026-05-19 11:38:26 +03:00
Claude VM f8ae0f02ff test(deploy): pcap capture 2026-05-19 11:30:39 +03:00
Claude VM fcb788ebdf test(deploy): tcpdump capture headers 2026-05-19 11:30:22 +03:00
Claude VM a3904a8960 test(deploy): verify webhook chain (round 2 — after PATCH secret) 2026-05-19 11:29:52 +03:00
Claude VM 9e1c2e7ac0 test(deploy): verify webhook auto-deploy chain 2026-05-19 11:28:11 +03:00
Claude VM b957de77b9 feat(faza-c.2): gate legacy GisFeature writes under USE_GIS_AC
Adds gateLegacyGisWrite() helper that returns 410 when the caller is on
the api.gis.ac path (global USE_GIS_AC=1 or per-user GIS_AC_PILOT_USERS).
Wired into 13 routes covering every entry point that touches Gis*
tables on architools_postgres — directly or via parcel-sync services.

Why: yesterday 4 GisFeature rows were updated on architools_postgres
even though the scheduler is officially disabled. Root cause: pilot
user opened the legacy /geoportal UI in a stale tab and clicked
parcels; POST /api/geoportal/enrich wrote directly to the local DB.
Without a write gate, Faza H (pg_dump + REVOKE + DROP) is unsafe —
any stale tab in any user's browser can still trip writes between
freeze and DROP.

Gated routes (writes only — reads stay open for rollback ergonomics):
- /api/geoportal/enrich (POST) — the writer of the 4 rows
- /api/eterra/sync-rules (POST), /api/eterra/sync-rules/[id] (PATCH+DELETE)
- /api/eterra/sync-rules/bulk (POST)
- /api/eterra/uats (POST+PATCH)
- /api/eterra/sync (POST), /api/eterra/sync-county (POST)
- /api/eterra/sync-background (POST), /api/eterra/sync-all-counties (POST)
- /api/eterra/auto-refresh (POST), /api/eterra/refresh-all (POST)
- /api/eterra/export-layer-gpkg (POST), /api/eterra/export-bundle (POST)
  (last two trigger syncLayer write-first-then-export)

Read-only routes intentionally NOT gated: sync-status, no-geom-scan
(scanNoGeometryParcels is read-only), export-local, db-summary,
counties, search, features (GET), stats, uat-dashboard, sync-rules
(GET), sync-rules/scheduler.

Operations: after redeploy, flip USE_GIS_AC=1 in Infisical /architools
prod env and restart container. Then monitor docker logs for ~30 min:
grep "deprecated" + "/api/geoportal/enrich|/api/eterra/sync*" lines
indicate stale-tab clients that need a refresh. pg_stat_user_tables
write count on GisFeature should hit 0 within one hour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 11:00:16 +03:00