- .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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
End of 2026-05-19 cutover-debug session. Saves the full state +
2 outstanding bugs + Faza G/H plan into docs/plans/ so the next
session can resume without re-investigating.
- prompt-handoff-2026-05-19.md: short prompt for the next session
to amend + resume.
- audit-2026-05-19.md: auditor-agent output (~30 findings).
- 004-faza-h-runbook.md: pg_dump + REVOKE + DROP runbook with
prereqs (eterra.live shares DB, unidentified writer, CfExtract
schema drift).
Memory entries also written:
- feedback/authentik-token-endpoint-shared
- project/architools-cutover-state-2026-05-19
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
THE bug behind every "data nu raman" / invalid_token incident this
session: refresh POSTed to `{issuer}/token/` = /application/o/architools/token/
which returns HTTP 405 + empty body. JSON.parse on the empty body
threw "Unexpected end of JSON input" → catch fired → token marked
RefreshAccessTokenError → 60s cooldown later, retry hit the same
broken URL → loop.
OIDC discovery at {issuer}/.well-known/openid-configuration confirms:
"token_endpoint": "https://auth.beletage.ro/application/o/token/"
This is the SHARED endpoint, not per-provider. Hard-fix the URL by
constructing it from the issuer's origin.
Marius's currently-stuck session will auto-recover on next request
(cooldown expires, refresh fires against the corrected URL,
refresh_token still valid 30d).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User pushback on the pool-based CF flow: he wants his own ePay account
(per-user creds, visible credit balance, decrement per order) — not the
shared orchestrator pool which hides cost attribution.
V2 panel "Comandă CF" now opens /parcel-sync?tab=epay&cad=<ref> in a
new tab where the legacy UI handles ordering with credits. The
/api/cf/* gis-api routes stay (used elsewhere + future SaaS consumers)
but the V2 button doesn't hit them.
Plus diagnostic on /api/gis/parcela showing enrichment presence + key
count to debug "data nu raman" — should reveal whether Marius's session
is getting full enrichment back from gis-api.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>