An ePay extract is valid 30 days after issuance; at 45 days it's worthless, so
delete the DB row + its MinIO object to declutter the list and free storage.
Only type='epay' rows are touched — the free cf-intern extracts are kept.
- cleanupExpiredEpayExtracts({olderThanDays=45, dryRun}): COALESCE(documentDate,
createdAt) < cutoff; deletes MinIO objects (batched, best-effort) then the
rows. Idempotent.
- Self-contained scheduler (epay-cleanup.ts, same pattern as
auto-refresh-scheduler): boot run (+90s) then every 24h, started from
instrumentation.ts. Works with zero external config; idempotent so a
redeploy/interrupt is harmless.
- GET/POST /api/ancpi/cleanup for manual preview (dry-run) / on-demand run —
staff session OR cron Bearer (EPAY_CLEANUP_CRON_SECRET /
NOTIFICATION_CRON_SECRET); excluded from the auth middleware (fail-closed
in-route). ?days overrides the window.
- deleteCfExtractObjects() helper in epay-storage.
Verified on prod: 0 epay rows currently qualify (all recent); the 8 old intern
rows are correctly left untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adversarial review (9 agents) of f7f7c59..28c870f found 4 confirmed bugs in
the hardening itself; all fixed:
1. Parallel-download index race: two items with the SAME nrCadastral in one
batch both scanned MinIO, both computed index 1, the second putObject
silently overwrote the first paid extract. Pre-allocate per-cadastral
indices sequentially before the parallel block; storeCfExtract takes an
explicit index (epay-queue.ts, epay-storage.ts).
2. Metadata-fail orphan charge: on saveMetadata failure the row was popped
from cleanup tracking even when deleteCartItem was NOT confirmed, leaving
an undeletable metadata-less row in the global cart that submitOrder would
check out and charge. Now: pop only on confirmed delete; if unconfirmed,
mark cartDirty and ABORT before submit (epay-queue.ts).
3. Recover vs live queue race: the widened recover WHERE (orderId:null +
cart/ordering/... states) could scoop a concurrently-processing batch's
rows and re-stamp them with the wrong orderId. Block recover while
getQueueStatus().processing (recover/route.ts).
4. 'review' status leaked as 'done' in the geoportal CF-order modal (minioPath
short-circuit) — handed an unverified PDF as a finished extract. Check
review/failed BEFORE the minioPath fallback (cf-order-modal.tsx).
Plus 2 nits: download-zip excludes 'review' rows server-side; retry button
surfaces recover errors/results instead of swallowing them.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live-path hardening from the 2026-06-04 deep-dive (11 confirmed criticals).
ArchiTools-only; the legacy queue is still the sole fulfiller.
Security:
- requireCfAccess() — staff-only, portal accounts blocked, fail-closed
in-route on download / download-zip / cf-status / orders (C4 IDOR/PII)
and order / recover (C3). order also enforces a daily credit cap
(ANCPI_DAILY_CREDIT_CAP, default 200) and stamps userId.
- /api/ancpi/test returns 404 in production — it was a GET that spends 2
real credits, CSRF-able (C5).
- drop the token-metadata debug blob from the session (QW8).
Correctness / robustness:
- cart hygiene (C1): build the ePay cart under an invariant — the Nth add
must report N items; any excess = pre-existing junk, so we wipe + abort
(never submit a cart we didn't fully build). Pre-submit failures clean
up our basket rows; post-submit we never touch the cart (recover owns
it). metadata-less rows are deleted from the cart.
- getOrderStatus fetches the whole order in ONE page (itemsPerPage, QW4);
navDir loop kept only as fallback. index-fallback matches are flagged
'review' instead of silently 'completed' with a possibly-wrong PDF (R4).
- downloadDocument asserts %PDF magic bytes — a login page returned mid
session no longer gets stored as a .pdf (R2). Session reuse TTL aligned
under ANCPI's ~10min expiry.
- recover accepts ?extractId= and pre-submit states; retry buttons in the
ePay tab re-run poll+download with no new charge (QW2/QW3).
Performance:
- parallel document downloads (V1, concurrency 4); poll writes only on
status change via updateMany (QW5); getNextFileIndex scans the cadastral
prefix instead of the whole bucket — and actually works now (it was
^-anchoring the full key, so every file got index 1) (V2); download-zip
streams instead of buffering the whole archive, capped at 100 (V3).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Plan 003 Faza F. Pilot users (session.useGisAc=true) get their CF
extract flow routed through api.gis.ac (RLS-filtered, RLS-owned
writes); everyone else keeps the legacy /api/ancpi/* path
unchanged. Feature-flag preserves rollback.
New routes (5):
- POST /api/cf/order → gisApi.enrichment.cf.create. Forwards
409 catalog_hit verbatim.
- GET /api/cf/orders → gisApi.enrichment.cf.list (limit, offset, status).
- GET /api/cf/[id] → gisApi.enrichment.cf.get.
- PATCH /api/cf/[id] → gisApi.enrichment.cf.patch.
- GET /api/cf/[id]/pdf → streams gisApi.enrichment.cf.getPdf
through to browser. Filename from documentName via cf.get; falls
back to cf-<id>.pdf.
- GET /api/cf/catalog → gisApi.enrichment.catalog.
All use getAuthSession() → 401 on no session, forward GisApiError
status+code+body, fallback {error:"internal_error", hint} at 500.
runtime=nodejs, dynamic=force-dynamic.
Helper module `cf-api-base.ts`:
- cfApiBase(useGisAc) → "/api/cf" | "/api/ancpi"
- adaptCfRow(row) → maps gisApi.CfExtractRow into the UI shape
expected by epay-tab.tsx (CfExtractRecord). Fields not in gis-api
(siruta, judetName, uatName, errorMessage, etc.) default to
empty/zero — filter-by-judet/uat on the pilot path is reduced
until gis-api enriches the response.
- fetchCfOrdersList, fetchCfHasCompletedForCadastral, placeCfOrder,
cfDownloadUrl — used by components.
UI changes:
- epay-tab.tsx: reads session.useGisAc; list fetch, reorder, single
+ bulk download routed via helpers. UI shape unchanged.
- epay-order-button.tsx: existence check uses catalog endpoint on
gis-ac path; order placement uses placeCfOrder which treats 409
catalog_hit as a soft success ("Extras CF valid").
Known gaps (followups):
- /api/ancpi/session still serves ePay session/credits — no gis-api
equivalent today. epay-connect.tsx untouched.
- ZIP bulk download has no gis-api analog; "Descarcă tot" falls back
to N tabs on gis-ac path.
- src/app/api/geoportal/cf-status returns hardcoded /api/ancpi/download
URL — separate followup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Logs session + token + duration on every search. Will revert after
cause identified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Client-side polling component mounted in providers.tsx. At mount,
captures the initial commit from /api/version. Every 60s, re-checks.
If commit differs from the captured one → renders a dismissible toast
in the bottom-right offering a hard reload.
Useful because Next.js bundles cache per commit hash → after a deploy
users would otherwise keep running the old client until they manually
refresh. Now they get a discoverable nudge.
Banner UX:
- "Versiune nouă disponibilă: <shortSha> · apasă pentru reîncărcare"
- [Reîncarcă] button (window.location.reload)
- [X] dismiss for current page life
- Tailwind animate-in fade slide-from-bottom
Polling interval 60s is fine for our deploy frequency; cheap (one
GET per minute, ~150 bytes). Cache-busted with cache: "no-store".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a public, no-auth endpoint at /api/version that returns:
{ commit, commitShort, buildTime, nodeEnv, cutover, nextVersion }
Build-time injection via GIT_COMMIT + BUILD_TIME ARG/ENV propagated
from compose build.args through Dockerfile builder + runner stages.
Excluded from middleware auth gating.
Deploy command (run on satra after git pull):
GIT_COMMIT=$(git rev-parse HEAD) \
BUILD_TIME=$(date -u +%FT%TZ) \
docker compose build architools
Without these env vars, falls back to "unknown" so the build never
fails; only the endpoint shows reduced info.
Useful for: confirming what's actually deployed after CI, cross-app
deploy correlation (api.gis.ac, eterra.live, orchestrator), uptime
monitors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
4 fixes for the symptoms Marius hit on Faza E pilot — search returning
"Eroare la căutare" after sessions got stale, even after relogin:
1. Refresh deduplication
Authentik rotates refresh_tokens — exchange-once. Parallel map +
search + parcela.get all hit jwt callback concurrently, each fires
its own refresh, the first wins, the rest get invalid_grant and
poison the JWT with token.error=RefreshAccessTokenError → user
appears logged out for no good reason. Cache the inflight refresh
promise in-memory keyed by refresh_token so concurrent callers
share one Authentik exchange.
2. Fetch timeout in gis-api-client
AbortSignal.timeout(30s) on every api.gis.ac call. Without it, a
slow upstream (ANCPI scrape, orchestrator hiccup) hangs the route
for the full Next.js default → Marius saw 10s gaps with no
feedback. Throws GisApiError(504, upstream_timeout) instead.
3. Better error surfacing
/api/gis/* routes return { error, hint: <first 200 chars> } on
non-GisApiError throws instead of a bare "internal_error". Easier
to triage from browser DevTools without paging through container
logs.
4. Remove diagnostic [gis-search] logs
Diagnostic served its purpose (identified the stale-token cause
pre-refresh-fix). Now noise; keep only [auth] refresh success/fail
+ per-route internal_error.
Also adds AbortSignal.timeout(8s) on the Authentik refresh fetch
itself to keep the jwt callback bounded.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Temporary diagnostic for Faza E debugging — Marius reports search
returning "Eroare la căutare" after relogin. Need to confirm whether
session.accessToken is reaching the route.
Will revert/clean up once cause identified.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pmtiles-webhook returns 409 when a rebuild is already in progress.
Previously this was treated as a failure, showing 'Webhook PMTiles
indisponibil' error to the user. Now 409 is handled as a valid state
with appropriate messaging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add pmtiles-webhook.ts shared helper for triggering PMTiles rebuild
- sync-county: trigger rebuild when new features synced, pass jobId to
syncLayer for sub-progress, update % after UAT completion (not before)
- sync-all-counties: same progress fix + rebuild trigger at end
- geoportal monitor: use shared helper instead of raw fetch
- weekend-deep-sync + auto-refresh: consolidate webhook code via helper
- docker-compose: default N8N_WEBHOOK_URL to pmtiles-webhook on satra:9876
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1 of unified sync scheduler:
- New Prisma model GisSyncRule: per-UAT or per-county sync frequency
rules with priority, time windows, step selection (T/C/N/E)
- CRUD API: /api/eterra/sync-rules (list, create, update, delete, bulk)
- Global default frequency via KeyValueStore
- /sync-management page with 3 tabs:
- Reguli: table with filters, add dialog (UAT search + county select)
- Status: stats cards, frequency distribution, coverage overview
- Judete: quick county-level frequency assignment
- Monitor page: link to sync management from eTerra actions section
Rule resolution: UAT-specific > county default > global default.
Scheduler engine (Phase 2) will read these rules to automate syncs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- /api/eterra/stats: lightweight polling endpoint (8 parallel Prisma queries, 30s poll)
- /api/eterra/sync-all-counties: iterates all counties in DB sequentially,
syncs TERENURI + CLADIRI + INTRAVILAN + enrichment (magic mode) per UAT
- Monitor page: live stat cards (UATs, parcels, buildings, DB size),
Sync All Romania button with progress tracking at county+UAT level
- Concurrency guard: blocks county sync while all-Romania sync runs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The eTerra connect/disconnect UI and session status were missing from the
deployed monitor page. Also, since ETERRA env vars are empty in the
container, the connect flow now accepts username/password from the UI.
This unblocks county sync which requires an active eTerra session.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Same pattern as sync-background: session credentials from eTerra login
take priority, env vars are fallback only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- GET /api/eterra/counties — distinct county list from GisUat
- POST /api/eterra/sync-county — background sync all UATs in a county
(TERENURI + CLADIRI + INTRAVILAN), magic mode for enriched UATs,
concurrency guard, creates notification on completion
- In-app notification service (KeyValueStore, CRUD, unread count)
- GET/PATCH /api/notifications/app — list and mark-read endpoints
- NotificationBell component in header with popover + polling
- Monitor page: county select dropdown + SyncTestButton with customBody
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Search results now JOIN GisUat to display UAT name prominently instead
of just SIRUTA codes. Map flyTo uses imperative handle instead of
stateful props that re-triggered on re-renders.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
First-run magic enrichment on partially-enriched UATs can take
30+ minutes per UAT. After first complete run, subsequent runs
will be seconds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New endpoint POST /api/eterra/refresh-all processes all 43 UATs
sequentially. UATs with >30% enrichment get magic mode, others
get base sync only. Each UAT uses the new delta engine (quick-count
+ VALID_FROM + rolling doc check). Progress tracked via progress store.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Always call syncLayer for TERENURI/CLADIRI (not gated by isFresh)
so that quick-count + VALID_FROM delta actually run on daily syncs
- syncLayer handles efficiency internally via quick-count match
- Add 48h freshness check for no-geom import (skip if recent)
- Admin layers: skip if synced within 24h
- Log sync summary (new features, updated features)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Cluj-Napoca (54975): base mode, parcele+cladiri only (no magic)
- Feleacu (57582): magic + no-geom (full enrichment test)
- Both with elapsed timer and phase-change logging
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Quick test button on /monitor page to trigger smart delta sync
(magic mode) on Cluj-Napoca and track progress via polling.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix: geoportal/enrich endpoint now looks up CLADIRI_ACTIVE from DB
instead of hardcoding HAS_BUILDING=0, BUILD_LEGAL=0
- Quick-count check: skip OBJECTID comparison when remote==local count
- VALID_FROM delta: detect attribute changes on existing parcels and
mark them for re-enrichment (catches spatial validity changes)
- Early bailout: skip all eTerra API calls when 0 features need enrichment
- Rolling doc check: probe 200 oldest-enriched parcels for new
documentation activity (catches ownership/CF changes VALID_FROM misses)
- Targeted doc fetch: only fetch documentation for immovable PKs that
actually need enrichment instead of all 10k+
Daily sync cost reduced from ~300+ API calls / 1-2h to ~6-10 calls / 10-15s.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Manual trigger now only processes sync_terenuri and sync_cladiri steps.
import_nogeom and enrich are left for the regular weekend window.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Auto-poll every 15s when sync is running, 60s when idle
- Live status banner: running (with city/step), error list, weekend window waiting, connection error
- Highlight active city card and currently-running step with pulse animation
- Send immediate error email per failed step (not just at session end)
- Expose syncStatus/currentActivity/inWeekendWindow in API response
- Stop silently swallowing fetch/action errors — show them in the UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>