Compare commits

...

83 Commits

Author SHA1 Message Date
Claude VM 265e1c934b chore(parcel-sync): disable auto-refresh scheduler during GIS DB overhaul
Prevents nightly delta sync (Mon-Fri 01-05) and weekend deep sync
(Fri-Sun 23-04) from writing to GisFeature/GisUat while the schema
is being reworked. Re-enable by uncommenting the import in
src/instrumentation.ts once the new DB layout is stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 23:34:47 +03:00
Claude VM ddf27d9b17 fix(webhook): treat HTTP 409 (rebuild already running) as success, not error
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>
2026-04-10 15:29:01 +03:00
Claude VM 377b88c48d feat(sync): auto-trigger PMTiles rebuild after sync + fix progress display
- 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>
2026-04-09 14:59:18 +03:00
Claude VM b356e70148 fix(session-store): rename globalThis key collision between session-store and eterra-client
Both session-store.ts and eterra-client.ts used globalThis.__eterraSessionStore
but for completely different purposes (EterraSession vs Map<string, SessionEntry>).
The Map from eterra-client made getSessionStatus() report connected: true on
server start (Map is truthy), while getSessionCredentials() returned undefined
username/password — causing "Credentiale lipsa" on sync attempts despite the
UI showing a green "Conectat" dot.

Renamed eterra-client's global keys to __eterraClientCache and
__eterraClientCleanupTimer to eliminate the collision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 21:36:33 +03:00
Claude VM 708e550d06 fix(parcel-sync): allow DB download regardless of layer freshness
The top export buttons required all primary layers to be "fresh" (<7 days)
before using the DB download path. When stale, they fell through to live
eTerra sync which requires credentials — blocking users who just want to
download existing data. Now any UAT with data in DB uses the local export
path directly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:16:41 +03:00
Claude VM 0cce1c8170 feat(sync-management): rule-based sync scheduling page + API
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>
2026-04-08 12:03:50 +03:00
Claude VM 34be6c58bc feat(monitor): add Sync All Romania + live GIS stats
- /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>
2026-04-08 11:42:01 +03:00
Claude VM 7bc9e67e96 feat(monitor): add eTerra session indicator + login form
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>
2026-04-08 10:40:31 +03:00
Claude VM 93b3904755 fix(sync-county): use eTerra session credentials, not just env vars
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>
2026-04-07 23:47:16 +03:00
Claude VM f44d57629f feat: county sync on monitor page + in-app notification system
- 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>
2026-04-07 22:56:59 +03:00
Claude VM 8222be2f0e fix(geoportal): search input text invisible in dark mode
Changed bg-background/95 to bg-background (opaque) and added
text-foreground to ensure input text is visible on dark theme.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:11:46 +03:00
Claude VM 177f2104c1 fix(geoportal): show UAT name in search results + fix map snap-back
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>
2026-04-07 21:21:09 +03:00
AI Assistant f106a2bb02 feat(auto-refresh): upgrade nightly scheduler to delta sync all UATs
Replace old approach (5 stale UATs, 7-day freshness gate, no enrichment)
with new delta engine on ALL UATs:
- Quick-count + VALID_FROM delta for sync (~7s/UAT)
- Rolling doc check + early bailout for magic UATs (~10s/UAT)
- All 43 UATs in ~5-7 minutes instead of 5 UATs in 30+ minutes
- Runs Mon-Fri 1:00-5:00 AM, weekend deep sync unchanged
- Small delays (3-10s) between UATs to be gentle on eTerra

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 08:08:39 +03:00
AI Assistant 27960c9a43 fix(monitor): increase refresh-all timeout to 3h
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>
2026-03-31 07:01:36 +03:00
AI Assistant fc7a1f9787 feat(monitor): add Refresh ALL UATs button with delta sync
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>
2026-03-31 01:02:47 +03:00
AI Assistant ef3719187d perf(enrich): rolling doc check resolves changes in-place, always returns early
Instead of marking features enrichedAt=null and falling through to the
full enrichment flow (which downloads the entire immovable list ~5min),
the rolling doc check now merges updated PROPRIETARI/DATA_CERERE directly
into existing enrichment and returns immediately.

Also touches enrichedAt on all checked features to rotate the batch,
ensuring different features are checked on each daily run.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:53:25 +03:00
AI Assistant 7a93a28055 fix(parcel-sync): always run syncLayer for delta detection + no-geom freshness
- 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>
2026-03-30 23:25:23 +03:00
AI Assistant f822509169 feat(monitor): separate delta test buttons for Cluj-Napoca and Feleacu
- 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>
2026-03-30 23:02:37 +03:00
AI Assistant d76c49fb9e feat(monitor): add delta sync test button for Cluj-Napoca
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>
2026-03-30 22:59:04 +03:00
AI Assistant 9e7abfafc8 feat(parcel-sync): smart delta sync + fix HAS_BUILDING bug
- 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>
2026-03-30 22:57:02 +03:00
AI Assistant 4d1883b459 feat(registratura): add manual toggle for monitoring (Opreste/Reactiveaza)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:52:59 +03:00
AI Assistant 5bcf65ff02 feat(registratura): auto-close monitoring on resolved, inline check, edit tracking
- Auto-deactivate monitoring when status is 'solutionat' or 'respins'
- 'Verifica acum' shows result inline (no page reload/close)
- 'Modifica' button opens edit dialog to fix petitioner/regNumber/date
- Show monitoring section even when inactive (final status visible)
- Display tracking config info (nr, date, petitioner) in detail view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:17:18 +03:00
AI Assistant 89e7d08d19 feat(parcel-sync): add Monitor link next to WDS on export tab
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:07:30 +03:00
AI Assistant 126a121056 feat(auto-refresh): trigger PMTiles rebuild via N8N after nightly sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 09:04:22 +03:00
AI Assistant 31877fde9e feat(wds): add 'Descarca parcele' button for quick terenuri+cladiri sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:58:18 +03:00
AI Assistant 0a38b2c374 fix(wds): restore full-step manual trigger button
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 08:23:57 +03:00
AI Assistant b8061ae31f feat(wds): limit force sync to terenuri + cladiri only
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>
2026-03-30 07:36:35 +03:00
AI Assistant 145aa11c55 fix(wds): remove time window restriction for manual force sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 07:30:49 +03:00
AI Assistant 730eee6c8a feat(wds): add manual sync trigger button with force-run mode
- triggerForceSync() resets error steps, clears lastSessionDate, starts sync immediately
- Force mode uses extended night window (22:00-05:00) instead of weekend-only
- API action 'trigger' starts sync in background, returns immediately
- 'Porneste sync' button in header (hidden when already running)
- Respects __parcelSyncRunning guard to prevent concurrent runs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:59:07 +03:00
AI Assistant 4410e968db feat(wds): live status banner, auto-poll, and instant error emails
- 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>
2026-03-30 01:41:55 +03:00
AI Assistant 82a225de67 fix(tippecanoe): remove --simplification=10 — distorts buildings at z18
Simplification with 10px tolerance makes rectangular buildings look
jagged/trapezoidal at high zoom. Without it, tippecanoe still simplifies
at lower zooms via --drop-densest-as-needed but preserves full geometry
at the base zoom (z18).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:07:51 +03:00
AI Assistant adc0b0a0d0 fix(monitor): resolve relative PMTILES_URL for server-side health check
Server-side fetch() cannot resolve relative URLs like /tiles/pmtiles/...
Route through internal tile-cache proxy (http://tile-cache:80) instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:01:47 +03:00
AI Assistant 9bf79a15ed fix(geoportal): proxy PMTiles through HTTPS + fix click/selection + optimize rebuild
PMTiles was loaded via HTTP from MinIO (10.10.10.166:9002) on an HTTPS page,
causing browser mixed-content blocking — parcels invisible on geoportal.

Fixes:
- tile-cache nginx proxies /pmtiles/ → MinIO with Range header support
- PMTILES_URL changed to relative path (resolves to HTTPS automatically)
- clickableLayers includes PMTiles fill layers (click on parcels works)
- Selection highlight uses PMTiles source at z13+ (was Martin z17+ only)
- tippecanoe per-layer zoom ranges (terenuri z13-z18, cladiri z14-z18)
  skips processing millions of features at z0-z12 — faster rebuild

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:56:49 +03:00
AI Assistant b46eb7a70f feat(parcel-sync): add building status layer to Harta tab (gis_cladiri_status)
Buildings now color-coded on legal status in ParcelSync map view:
- Blue fill: building has legal documents (build_legal = 1)
- Red fill: building without legal documents (build_legal = 0)

Previously only parcels had status coloring; buildings were plain blue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:53:12 +02:00
AI Assistant ee86af6183 docs: update tile evaluation + monitoring + add geoportal improvement mega prompt
- TILE-SERVER-EVALUATION.md: updated to reflect current architecture (PMTiles z0-z18)
- MODULE-MAP.md: added PMTiles + tile-cache to Geoportal section
- Monitor: timeout increased to 90 min for z18 builds, description updated
- Added PROMPT-GEOPORTAL-IMPROVE.md with mega prompt for future sessions
  (includes MLT check, mvt-rs evaluation prompt, operational commands)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:38:53 +02:00
AI Assistant 870e1bd4c2 perf(geoportal): extend PMTiles to z18 — eliminate Martin for terenuri/cladiri entirely
PMTiles now covers z0-z18 (full zoom range). Martin sources kept only for
selection highlight and fallback when PMTiles not configured.
All terenuri/cladiri fill/line/label layers served from PMTiles when active.
Zero PostGIS load for tile rendering at any zoom level.

File will be ~1-2 GB but eliminates all cold-load latency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 17:23:04 +02:00
AI Assistant c269d8b296 fix(docker): declare PMTILES_URL + MARTIN_URL as ARG+ENV in Dockerfile
Without ARG/ENV declarations in the build stage, docker-compose build args
are silently ignored. webpack never sees the values → NEXT_PUBLIC_ vars
are empty → PMTiles disabled → all tiles go through Martin → PostGIS at 90% CPU.

This was the root cause of slow tile loading all along.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:41:24 +02:00
AI Assistant aac93678bb fix(geoportal): move PMTILES_URL + MARTIN_URL to build args (NEXT_PUBLIC_ requires build time)
NEXT_PUBLIC_ env vars are inlined by webpack at build time in client components.
Setting them only in environment (runtime) has no effect — the map-viewer
was falling back to Martin for ALL tiles, causing 90% PostgreSQL CPU.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:30:26 +02:00
AI Assistant c00d4fe157 fix(monitor): increase rebuild timeout to 30min + fix sample tile z14→z17
Martin now starts at z17, so z14 sample tile returned 404.
Rebuild timeout increased from 15 to 30 min for z16 builds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:21:34 +02:00
AI Assistant f5c8cf5fdc perf(geoportal): extend PMTiles to z16 — near-zero PostGIS load for tile serving
- PMTiles now covers z0-z16 (was z0-z14) for terenuri + cladiri
- Martin only serves z17-z18 (very close zoom, few features per tile)
- map-viewer: PMTiles layers serve z13-z16 for terenuri, z14-z16 for cladiri
- Labels at z16 now from PMTiles (cadastral_ref included in tiles)
- Remove failed compound index from postgis-setup.sql

This eliminates PostgreSQL as bottleneck for 99% of tile requests.
PMTiles file will be ~300-500MB (vs 104MB at z14).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:40:03 +02:00
AI Assistant b33fe35c4b perf(martin+postgres): connection pool limit + compound indexes + minzoom alignment
- Martin: pool_size=8 (prevents overwhelming PostgreSQL with concurrent queries)
- Martin: gis_terenuri minzoom 10→14, gis_cladiri minzoom 12→15
  (PMTiles serves z0-z14, no point in Martin generating those)
- PostGIS: add compound index layerId+geom for Martin view queries
- PostGIS: add B-tree index on layerId for LIKE filtering in views

Fixes 90% CPU on PostgreSQL during cold tile loads at detail zoom levels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 15:15:13 +02:00
AI Assistant 73456c1424 feat(monitor): activity log with rebuild polling + warm cache details
- Rebuild: shows webhook status, then polls every 15s until PMTiles
  last-modified changes, then shows success with new size/timestamp
- Warm cache: shows HIT/MISS/error breakdown after completion
- Activity log panel with timestamps, color-coded status, scrollable
- 15-minute timeout on rebuild polling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:59:35 +02:00
AI Assistant 9eb2b12fea fix(parcel-sync): safety check prevents mass deletion on stale remote data
If >80% of local features would be deleted (and >100 exist), skip deletion
and log a warning. This protects against session expiry returning empty
remote results, which previously caused the sync to mark ALL local
features as "removed" and attempt to delete them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:19:47 +02:00
AI Assistant dfb5ceb926 fix(parcel-sync): batch deleteMany to avoid PostgreSQL 32767 bind variable limit
Cluj-Napoca sync failed with 62,307 removed features exceeding the limit.
Batch deletions in chunks of 30,000 in both sync-service and no-geom-sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:17:57 +02:00
AI Assistant 91fb23bc53 feat(geoportal): live tile infrastructure monitor at /monitor
Dashboard page showing:
- nginx tile-cache status (connections, requests)
- Martin tile server sources
- PMTiles file info (size, last modified)
- Cache HIT/MISS test on sample tiles
- Configuration summary

Action buttons:
- Rebuild PMTiles (triggers N8N webhook)
- Warm Cache (fetches common tiles from container)

Auto-refreshes every 30 seconds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:14:28 +02:00
AI Assistant 58442da355 fix(parcel-sync): fix session expiry during long pagination (Cluj 0 features bug)
Three bugs caused sync to return 0 features after 37 minutes:

1. reloginAttempted was instance-level flag — once set to true after first
   401, all subsequent 401s threw immediately without retry. Moved to
   per-request scope so each request can independently relogin on 401.

2. Session lastUsed never updated during pagination — after ~10 min of
   paginating, the session store considered it expired and cleanup could
   evict it. Added touchSession() call before every request.

3. Single eTerra client shared across all cities/steps for hours — now
   creates a fresh client per city/step (session cache still avoids
   unnecessary logins when session is valid).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 10:05:06 +02:00
AI Assistant 9bab9db4df feat(geoportal): N8N webhook on sync completion + tile cache monitoring
- weekend-deep-sync.ts: fire webhook to N8N_WEBHOOK_URL after each sync cycle
  (N8N triggers tippecanoe PMTiles rebuild via SSH on host)
- nginx tile-cache: add stub_status at /status, custom log format with cache status
- Add tile-cache-stats.sh: shows HIT/MISS ratio, cache size, slow tiles
- docker-compose: add N8N_WEBHOOK_URL env var

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 09:44:38 +02:00
AI Assistant c82e234d6c perf(tile-cache): fix compression passthrough + 7d TTL + browser caching
- Remove Accept-Encoding stripping — Martin gzip passes through to client
  (was sending uncompressed tiles, wasting bandwidth and cache space)
- Increase cache TTL from 1h to 7d (tiles change only on weekly sync)
- Increase inactive eviction from 24h to 7d
- Add Cache-Control header for browser caching (24h + stale-while-revalidate 7d)
- 204 (empty tiles) cached 1h instead of 1m (they don't change either)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:47:39 +02:00
AI Assistant ecf61e7e1d fix(tippecanoe): remove cache warming from Docker container (no host network access)
Cache warming must run from host, not from Docker container.
Use scripts/warm-tile-cache.sh standalone instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 22:36:37 +02:00
AI Assistant dafb3555d7 fix(tippecanoe): fix empty terenuri/cladiri export — remove ST_Simplify from ogr2ogr
ogr2ogr doesn't auto-detect geometry from ST_SimplifyPreserveTopology().
Export raw geometry instead, let tippecanoe handle simplification (--simplification=10).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:59:01 +02:00
AI Assistant 0d5fcf909c feat(geoportal): PMTiles for terenuri/cladiri overview + cache warming + cleanup
- Extend PMTiles to include simplified terenuri (5m tolerance) and cladiri (3m)
- map-viewer: terenuri z13 from PMTiles, z14+ from Martin (live detail)
- map-viewer: cladiri z14 from PMTiles, z15+ from Martin
- Martin sources start at higher minzoom when PMTiles active (less DB load)
- Add warm-tile-cache.sh: pre-populate nginx cache for major cities
- Rebuild script now includes cache warming step after PMTiles upload
- Remove deprecated docker-compose version: "3.8"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:46:47 +02:00
AI Assistant 236635fbf4 fix(geoportal): show only building body suffix (C1, C2) instead of full cadastral_ref
Strip parcel number prefix from building labels — "291479-C1" now displays as "C1".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 21:32:38 +02:00
AI Assistant 0572097fb2 feat(geoportal): activate PMTiles overview tiles from MinIO
UAT + administrativ layers now served from pre-generated PMTiles (~5ms)
instead of Martin/PostGIS (~200-2000ms) for zoom levels 0-14.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:51:57 +02:00
AI Assistant 938aa2c6d3 fix(tippecanoe): use GHCR registry for GDAL image (migrated from Docker Hub)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:44:02 +02:00
AI Assistant 8ebd7e4ee2 fix(tippecanoe): build from source instead of unavailable ghcr.io image
ghcr.io/felt/tippecanoe:latest returns 403 — no public Docker image.
Build tippecanoe from GitHub source in a multi-stage Alpine build.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:41:37 +02:00
AI Assistant 536b3659bb feat(geoportal): nginx tile cache + PMTiles overview layers + tippecanoe pipeline
- Add nginx reverse proxy cache in front of Martin (2GB, 1h TTL, stale serving, CORS)
- Martin no longer exposes host port — all traffic routed through tile-cache on :3010
- Add PMTiles support in map-viewer.tsx (conditional: NEXT_PUBLIC_PMTILES_URL env var)
  - When set: single PMTiles source for UAT + administrativ layers (z0-z14, ~5ms/tile)
  - When empty: fallback to Martin tile sources (existing behavior, zero breaking change)
- Add tippecanoe Docker service (profiles: tools) for on-demand PMTiles generation
- Add rebuild-overview-tiles.sh: ogr2ogr export → tippecanoe → MinIO atomic upload
- Install pmtiles npm package for MapLibre protocol registration

Performance impact:
- nginx cache: 10-100x faster on repeat tile requests, zero PostGIS load on cache hit
- PMTiles: sub-10ms overview tiles, zero PostGIS load for z0-z14

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 20:28:49 +02:00
AI Assistant 67f3237761 docs(geoportal): update evaluation + skills with deployment lessons learned
- Portainer CE volume mount pitfall (silent empty directory creation)
- Martin Docker tag format change at v1.0 (v prefix dropped)
- UNKNOWN GEOMETRY TYPE log is normal for views
- Bake-into-image pattern for config files in Portainer deployments
- Updated all implementation prompts with Portainer-safe instructions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:41:54 +02:00
AI Assistant 675b1e51dd fix(martin): bake config into image via Dockerfile (Portainer volume mount fix)
Portainer CE deploys only docker-compose.yml — ./martin.yaml not present on host,
so Docker creates an empty directory instead of mounting the file. Solution: COPY
martin.yaml into the image at build time, eliminating the volume dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:18:30 +02:00
AI Assistant a83f9e63b9 fix(martin): correct Docker image tag to 1.4.0 (no v prefix for v1.x+)
Martin changed tag format at v1.0: v0.15.0 → 1.4.0 (dropped the v prefix).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:06:59 +02:00
AI Assistant a75d0e1adc fix(geoportal): mount Martin config + upgrade v1.4 + enable building labels
Root cause: martin.yaml was never mounted in docker-compose.yml — Martin ran
in auto-discovery mode which dropped cadastral_ref from gis_cladiri tiles.

Changes:
- docker-compose: mount martin.yaml, upgrade Martin v0.15→v1.4.0, use --config
- map-viewer: add cladiriLabel layer (cadastral_ref at z16+), wire into visibility
- martin.yaml: update version comment
- geoportal/: tile server evaluation doc + 3 skill files (vector tiles, PMTiles, MapLibre perf)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:28:20 +02:00
AI Assistant e42eeb6324 feat(parcel-sync): extended enrichment fields from existing API data
New fields extracted from already-fetched documentation/GIS data
(zero extra API calls, no performance impact):

- TIP_INSCRIERE: "Intabulare, drept de PROPRIETATE, dobandit prin..."
- ACT_PROPRIETATE: "hotarare judecatoreasca nr... / contract vanzare..."
- COTA_PROPRIETATE: "1/1" or fractional
- DATA_CERERE: date of registration application
- NR_CORPURI: number of building bodies on parcel
- CORPURI_DETALII: "C1:352mp, C2:248mp, C3:104mp"
- IS_CONDOMINIUM: condominium flag
- DATA_CREARE: parcel creation date in eTerra

Also fixed HAS_BUILDING: now also uses NR_CORPURI count as fallback
(was 0 for parcels where buildingMap cross-ref missed matches).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:57:13 +02:00
AI Assistant 9d45799900 revert: disable building labels + remove debug endpoints
Building labels (C1/C2/C3) disabled — Martin MVT tiles don't include
cadastral_ref as a property despite the PostgreSQL view exposing it.
Root cause needs investigation (Martin config or alternative tile server).

Removed temporary debug endpoints:
- /api/eterra/debug-tile-props
- /api/eterra/debug-tile-sample

Kept /api/eterra/debug-fields (useful long-term diagnostic).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:43:20 +02:00
AI Assistant 946723197e debug: red building labels with template string syntax
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:30:13 +02:00
AI Assistant 3ea57f00b6 debug: try cladiri labels at minzoom 15
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:23:48 +02:00
AI Assistant 311f63e812 debug: add /api/eterra/debug-tile-sample for Martin tile diagnostics
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:22:26 +02:00
AI Assistant 1d233fdc19 fix(geoportal): building labels — inline addLayer like terenuriLabel
Removed wrapper function/setTimeout approach. Now uses exact same
inline addLayer pattern as terenuriLabel which is proven to work.
Same source, same font, same coalesce pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:13:40 +02:00
AI Assistant c6eb1a9450 fix(geoportal): building labels — force overlap + delayed init
Labels were hidden by MapLibre collision detection with terrain
labels. Now using text-allow-overlap + text-ignore-placement to
force visibility. Also added retry with setTimeout in case source
isn't ready when layer is first added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:07:38 +02:00
AI Assistant 49a239006d fix(geoportal): simplify building labels — show full cadastral_ref
Previous index-of/slice expression wasn't rendering. Simplified to
just show the full cadastral_ref (e.g. "77102-C1") as-is. MapLibre
auto-hides overlapping labels. This is a diagnostic step to verify
the tile property is accessible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:57:45 +02:00
AI Assistant 6c5aa61f09 debug: add /api/eterra/debug-tile-props to check Martin tile columns
Temporary diagnostic to verify what columns gis_cladiri view exposes
for Martin vector tiles. Needed to debug missing C1/C2 labels.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:39:21 +02:00
AI Assistant 4c1ffe3d01 fix(geoportal): building labels C1/C2 — simpler expression + minzoom 16
Previous index-of expression wasn't rendering. Simplified to use
filter with index-of on dash + slice from dash position.
Also lowered minzoom from 17 to 16.

Added diagnostic log in enrichment for building cross-ref count
to debug HAS_BUILDING=0 cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 07:11:56 +02:00
AI Assistant 4e67c29267 feat(parcel-sync): add /api/eterra/debug-fields diagnostic endpoint
Shows all available eTerra fields for a parcel + buildings:
- GIS layer attributes (raw from ArcGIS)
- Immovable parcel details (intravilan, categories)
- Immovable list entry (address, areas)
- Documentation data (owners, registrations)
- Local DB state (enrichment, sync dates)

Usage: /api/eterra/debug-fields?siruta=161829&cadRef=77102

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:59:35 +02:00
AI Assistant acb9be8345 feat(geoportal): building body labels (C1, C2, C3...) on map at zoom 17+
Extracts body suffix from cadastral_ref (e.g. "77102-C1" → "C1") and
displays as centered label on each building polygon. Only visible at
zoom 17+ to avoid clutter at lower zooms.

Applied to both geoportal map-viewer and parcel-sync map tab.
Uses siruta filter in parcel-sync tab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:53:45 +02:00
AI Assistant 189e9a218a fix(parcel-sync): fix [object Object] in address field + re-enrich corrupted
The eTerra API returns street and locality as objects ({name: "..."})
not strings. formatAddress now extracts .name correctly.

Also added:
- streetNumber fallback (alongside buildingNo)
- String() safety on addressDescription
- Corruption check: any enrichment containing "[object Object]" is
  automatically re-enriched on next cycle

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:43:04 +02:00
AI Assistant c4516c6f23 fix: set TZ=Europe/Bucharest in Docker + scheduler diagnostic logs
The container was running on UTC by default — the 1-5 AM window was
actually 4-8 AM Romania time, missing the intended night window.

- Add TZ=Europe/Bucharest + tzdata package to Dockerfile
- Add startup diagnostic logs: server time, timezone, ETERRA creds check

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 06:35:23 +02:00
AI Assistant 798b3e4f6b feat(wds): replace 3-field form with UAT autocomplete search
Same search pattern as parcel-sync module: type name or SIRUTA code,
pick from dropdown, city is added instantly. Already-queued cities
are filtered out from results.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:57:07 +02:00
AI Assistant a6d7e1d87f fix(wds): auto-initialize queue with default cities on first access
The /wds page was showing 0 cities because the KeyValueStore was empty
until the scheduler ran for the first time. Now the GET endpoint
initializes the queue with the 9 default cities on first access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:46:50 +02:00
AI Assistant 54d9a36686 fix(parcel-sync): enrichment robustness — 5 fixes for better coverage
1. Completeness check with real values: features with all "-" values
   are now re-enriched instead of being considered "complete"

2. Age-based re-enrichment: features older than 30 days are re-enriched
   on next run (catches eTerra data updates)

3. Per-feature try-catch: one feature failing no longer aborts the
   entire UAT enrichment — logs warning and continues

4. fetchParcelFolosinte wrapped in try-catch: was a hard failure that
   killed the whole enrichment process

5. Workspace resolution logging: warns when immovable list is empty
   (wrong workspace), warns on fallback to PK=65

These fixes should progressively improve enrichment coverage toward
100% with each weekend sync cycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:39:32 +02:00
AI Assistant 24b565f5ea feat(parcel-sync): DXF export in ZIP + detailed tooltips on hero buttons
DXF Export:
- Add gpkgToDxf() helper using ogr2ogr -f DXF (non-fatal fallback)
- export-local: terenuri.dxf, cladiri.dxf, terenuri_magic.dxf in ZIP
- export-bundle: same DXF files alongside GPKGs
- Zero overhead — conversion runs locally from DB data, no eTerra calls

Hero Button Tooltips:
- Hover shows ZIP contents: layer names, entity counts, sync dates
- Base tooltip: "GPKG + DXF per layer"
- Magic tooltip: "GPKG + DXF + CSV complet + Raport calitate"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:24:35 +02:00
AI Assistant bde25d8d84 feat(parcel-sync): add LIMITE_UAT to sync package everywhere
All sync paths now include both admin layers (LIMITE_INTRAV_DYNAMIC +
LIMITE_UAT) as best-effort alongside terenuri + cladiri:
- export-bundle (hero buttons)
- sync-background (fire-and-forget)
- auto-refresh scheduler (weekday nights)
- weekend deep sync (weekend nights)
- freshness check (export tab badge)

LIMITE_UAT rarely changes so incremental sync will skip it almost
every time, but it stays fresh in the DB freshness check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:13:29 +02:00
AI Assistant 8b6d6ba1d0 fix(parcel-sync): add intravilan to primary layers + tooltip on stale badge
- LIMITE_INTRAV_DYNAMIC added to primary layers checked for freshness
- Auto-refresh scheduler and weekend sync now also sync intravilan
- "X vechi" badge shows tooltip with exact layer names and dates
- "Proaspete" badge also shows tooltip with layer details

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:10:26 +02:00
AI Assistant e5da0301de fix(parcel-sync): freshness check only on primary layers (terenuri + cladiri)
Secondary layers (LIMITE_INTRAV, LIMITE_UAT) are synced once and rarely
change. They were causing permanent "1 vechi" badge even after fresh
sync of terenuri+cladiri.

Now canExportLocal and the freshness badge only consider TERENURI_ACTIVE
and CLADIRI_ACTIVE — the layers that actually matter for export.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 22:07:15 +02:00
AI Assistant 318cb6037e fix(parcel-sync): fix unicode escapes in JSX + refresh on bg sync complete
- Replace \u00ce with actual Î character in JSX text (was rendering as literal \u00cenchide)
- Add onSyncRefresh + onDbRefresh calls when closing bg sync card
- Ensures DB freshness badge updates after background sync completes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:10:34 +02:00
AI Assistant 3b456eb481 feat(parcel-sync): incremental sync, smart export, auto-refresh + weekend deep sync
Sync Incremental:
- Add fetchObjectIds (returnIdsOnly) to eterra-client — fetches only OBJECTIDs in 1 request
- Add fetchFeaturesByObjectIds — downloads only delta features by OBJECTID IN (...)
- Rewrite syncLayer: compare remote IDs vs local, download only new features
- Fallback to full sync for first sync, forceFullSync, or delta > 50%
- Reduces sync time from ~10 min to ~5-10s for typical updates

Smart Export Tab:
- Hero buttons detect DB freshness — use export-local (instant) when data is fresh
- Dynamic subtitles: "Din DB (sync acum Xh)" / "Sync incremental" / "Sync complet"
- Re-sync link when data is fresh but user wants forced refresh
- Removed duplicate "Descarca din DB" buttons from background section

Auto-Refresh Scheduler:
- Self-contained timer via instrumentation.ts (Next.js startup hook)
- Weekday 1-5 AM: incremental refresh for existing UATs in DB
- Staggered processing with random delays between UATs
- Health check before processing, respects eTerra maintenance

Weekend Deep Sync:
- Full Magic processing for 9 large municipalities (Cluj, Bistrita, TgMures, etc.)
- Runs Fri/Sat/Sun 23:00-04:00, round-robin intercalated between cities
- 4 steps per city: sync terenuri, sync cladiri, import no-geom, enrichment
- State persisted in KeyValueStore — survives restarts, continues across nights
- Email status report at end of each session via Brevo SMTP
- Admin page at /wds: add/remove cities, view progress, reset
- Hint link on export tab pointing to /wds

API endpoints:
- POST /api/eterra/auto-refresh — N8N-compatible cron endpoint (Bearer token auth)
- GET/POST /api/eterra/weekend-sync — queue management for /wds page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:50:34 +02:00
66 changed files with 9271 additions and 456 deletions
+2 -2
View File
@@ -49,8 +49,8 @@ AUTHENTIK_CLIENT_ID=your-authentik-client-id
AUTHENTIK_CLIENT_SECRET=your-authentik-client-secret
AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
# N8N automation (future)
# N8N_WEBHOOK_URL=http://10.10.10.166:5678/webhook
# PMTiles rebuild webhook (pmtiles-webhook systemd service on satra)
N8N_WEBHOOK_URL=http://10.10.10.166:9876
# External tool URLs (displayed in dashboard)
NEXT_PUBLIC_GITEA_URL=http://10.10.10.166:3002
+6 -1
View File
@@ -24,9 +24,13 @@ COPY . .
ARG NEXT_PUBLIC_STORAGE_ADAPTER=database
ARG NEXT_PUBLIC_APP_NAME=ArchiTools
ARG NEXT_PUBLIC_APP_URL=https://tools.beletage.ro
ARG NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
ARG NEXT_PUBLIC_PMTILES_URL=
ENV NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER}
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
ENV NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL}
ENV NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
# Increase memory for Next.js build if VM has limited RAM
ENV NODE_OPTIONS="--max-old-space-size=2048"
@@ -37,9 +41,10 @@ FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV TZ=Europe/Bucharest
# Install system deps + create user in a single layer
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf \
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf tzdata \
&& addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
+45 -6
View File
@@ -1,5 +1,3 @@
version: "3.8"
services:
architools:
build:
@@ -8,6 +6,8 @@ services:
- NEXT_PUBLIC_STORAGE_ADAPTER=${NEXT_PUBLIC_STORAGE_ADAPTER:-database}
- NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME:-ArchiTools}
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL:-https://tools.beletage.ro}
- NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
- NEXT_PUBLIC_PMTILES_URL=/tiles/pmtiles/overview.pmtiles
container_name: architools
restart: unless-stopped
ports:
@@ -58,6 +58,8 @@ services:
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
# Martin vector tile server (geoportal)
- NEXT_PUBLIC_MARTIN_URL=https://tools.beletage.ro/tiles
# PMTiles overview tiles — proxied through tile-cache nginx (HTTPS, no mixed-content)
- NEXT_PUBLIC_PMTILES_URL=/tiles/pmtiles/overview.pmtiles
# DWG-to-DXF sidecar
- DWG2DXF_URL=http://dwg2dxf:5001
# Email notifications (Brevo SMTP)
@@ -68,6 +70,10 @@ services:
- NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
- NOTIFICATION_FROM_NAME=Alerte Termene
- NOTIFICATION_CRON_SECRET=1547a198feca43af6c05622588c6d3b820bad5163b8c20175b2b5bbf8fc1a987
# Weekend Deep Sync email reports (comma-separated for multiple recipients)
- WEEKEND_SYNC_EMAIL=${WEEKEND_SYNC_EMAIL:-}
# PMTiles rebuild webhook (pmtiles-webhook systemd service on host)
- N8N_WEBHOOK_URL=${N8N_WEBHOOK_URL:-http://10.10.10.166:9876}
# Portal-only users (comma-separated, redirected to /portal)
- PORTAL_ONLY_USERS=dtiurbe,d.tiurbe
# Address Book API (inter-service auth for external tools)
@@ -100,11 +106,44 @@ services:
start_period: 10s
martin:
image: ghcr.io/maplibre/martin:v0.15.0
build:
context: .
dockerfile: martin.Dockerfile
container_name: martin
restart: unless-stopped
ports:
- "3010:3000"
command: ["--default-srid", "3844"]
# No host port — only accessible via tile-cache nginx proxy
command: ["--config", "/config/martin.yaml"]
environment:
- DATABASE_URL=postgresql://architools_user:stictMyFon34!_gonY@10.10.10.166:5432/architools_db
tile-cache:
build:
context: .
dockerfile: tile-cache.Dockerfile
container_name: tile-cache
restart: unless-stopped
ports:
- "3010:80"
depends_on:
- martin
volumes:
- tile-cache-data:/var/cache/nginx/tiles
tippecanoe:
build:
context: .
dockerfile: tippecanoe.Dockerfile
container_name: tippecanoe
profiles: ["tools"]
environment:
- DB_HOST=10.10.10.166
- DB_PORT=5432
- DB_NAME=architools_db
- DB_USER=architools_user
- DB_PASS=stictMyFon34!_gonY
- MINIO_ENDPOINT=http://10.10.10.166:9002
- MINIO_ACCESS_KEY=admin
- MINIO_SECRET_KEY=MinioStrongPass123
volumes:
tile-cache-data:
+5 -3
View File
@@ -129,9 +129,11 @@ Quick reference: entry points, key files, API routes, and cross-module dependenc
### Geoportal
- **Route**: `/geoportal`
- **Main component**: `components/geoportal-module.tsx`
- **Key components**: `components/map-viewer.tsx` (MapLibre), `components/basemap-switcher.tsx`, `components/selection-toolbar.tsx`, `components/feature-info-panel.tsx`
- **API routes**: `/api/geoportal/*` (search, boundary-check, uat-bounds, setup-views)
- **Cross-deps**: **parcel-sync** (declared dependency — uses PostGIS data)
- **Key components**: `components/map-viewer.tsx` (MapLibre, PMTiles protocol), `components/basemap-switcher.tsx`, `components/selection-toolbar.tsx`, `components/feature-info-panel.tsx`
- **Tile infrastructure**: Martin v1.4.0 (live MVT) -> nginx tile-cache (7d TTL) -> Traefik; PMTiles (z0-z18, MinIO) for pre-generated overview tiles
- **Monitor page**: `/monitor` — nginx/Martin/PMTiles status, rebuild + warm-cache actions
- **API routes**: `/api/geoportal/*` (search, boundary-check, uat-bounds, setup-views, monitor)
- **Cross-deps**: **parcel-sync** (declared dependency — uses PostGIS data), **MinIO** (PMTiles storage), **N8N** (rebuild webhook)
### Visual CoPilot
- **Route**: `/visual-copilot`
+116
View File
@@ -0,0 +1,116 @@
# Geoportal Continuous Improvement — Mega Prompt
Use this prompt to start a new session focused on geoportal tile serving improvements.
---
## Context Prompt (copy-paste to Claude)
```
Scopul acestei sesiuni este imbunatatirea continua a tile serving-ului pentru modulul Geoportal din ArchiTools.
Citeste aceste fisiere INAINTE de orice:
- CLAUDE.md (project conventions)
- geoportal/TILE-SERVER-EVALUATION.md (current architecture + roadmap)
- src/modules/geoportal/components/map-viewer.tsx (MapLibre + PMTiles integration)
- martin.yaml (Martin tile server config)
- docker-compose.yml (infrastructure stack)
- scripts/rebuild-overview-tiles.sh (PMTiles generation pipeline)
- src/app/api/geoportal/monitor/route.ts (monitoring API)
- src/app/(modules)/monitor/page.tsx (monitoring dashboard)
## Arhitectura curenta (2026-03-28):
Pipeline: Browser → PMTiles (MinIO, z0-z18, ~1-2 GB) | Martin (PostGIS) doar pentru gis_terenuri_status + gis_cladiri_status
Cache: nginx tile-cache (7d TTL) in fata Martin | Browser cache 24h | PMTiles servit direct din MinIO
Stack:
- PMTiles: overview.pmtiles pe MinIO (10.10.10.166:9002/tiles/overview.pmtiles)
- nginx tile-cache: port 3010, proxy_cache 2GB, 7d TTL
- Martin v1.4: port intern 3000, config baked in image, pool_size 8
- tippecanoe Docker: one-shot rebuild, profiles: ["tools"]
- N8N webhook: auto-rebuild dupa weekend deep sync
Rebuild PMTiles: ~45-60 min (565K+ features, z0-z18)
Server: VM satra (10.10.10.166), 6 CPU, 16 GB RAM, Docker, Portainer CE
IMPORTANT:
- NEXT_PUBLIC_* vars TREBUIE declarate ca ARG+ENV in Dockerfile (altfel webpack nu le vede)
- Portainer CE nu monteaza fisiere din repo — bake configs in Docker images
- Dupa schimbari la Dockerfile/NEXT_PUBLIC_: docker compose build --no-cache architools
Comenzi server (SSH bulibasa@10.10.10.166):
cd /tmp/ArchiTools && git pull && docker compose --profile tools build tippecanoe && docker compose --profile tools run --rm tippecanoe
docker compose build --no-cache architools && docker compose up -d architools
bash /tmp/ArchiTools/scripts/warm-tile-cache.sh http://10.10.10.166:3010
Monitor dashboard: https://tools.beletage.ro/monitor
N8N: http://n8n.beletage.ro (workflow "PMTiles Rebuild")
npx next build TREBUIE sa treaca dupa fiecare schimbare.
```
---
## Checklist periodic (lunar):
### 1. Check MLT Production Readiness
```
Verifica daca Martin suporta generare MLT din PostGIS (nu doar servire din MBTiles).
Cauta:
- Martin releases: https://github.com/maplibre/martin/releases
- Martin MLT PR: https://github.com/maplibre/martin/pull/2512
- PostGIS MLT: cauta "ST_AsMLT" in PostGIS development
- MapLibre GL JS MLT: https://maplibre.org/maplibre-tile-spec/implementation-status/
Daca Martin poate genera MLT din PostGIS live:
1. Testeaza pe un layer (gis_terenuri) cu encoding: "mlt" in map-viewer
2. Compara tile sizes MVT vs MLT
3. Daca merge, aplica pe toate layerele Martin
Status curent (2026-03-28): NU e viabil. Martin doar serveste MLT pre-generat, nu transcodeaza din PostGIS.
```
### 2. mvt-rs Parallel Evaluation
```
Evalueaza mvt-rs ca alternativa Martin pentru deployment multi-tenant.
Prompt gata de folosit:
"Deployeaza mvt-rs v0.16+ in parallel cu Martin pe ArchiTools.
Context:
- PostgreSQL: 10.10.10.166:5432, db architools_db, user architools_user
- Martin actual: martin.yaml cu 9 surse PostGIS (EPSG:3844)
- Docker stack: Portainer CE, Traefik v3
- Scopul: per-layer access control pentru clienti externi
Steps:
1. Adauga mvt-rs in docker-compose.yml pe port 3011
2. Configureaza aceleasi layere ca martin.yaml
3. Test: toate proprietatile apar in MVT? Performance vs Martin?
4. Admin UI: creeaza user test, asigneaza permisiuni per layer
5. Decision matrix: cand trecem de la Martin la mvt-rs
NU modifica setup-ul Martin existent. Evaluare paralela doar.
mvt-rs repo: https://github.com/mvt-proj/mvt-rs
Citeste CLAUDE.md si geoportal/TILE-SERVER-EVALUATION.md inainte."
```
### 3. PMTiles Rebuild Optimization
```
Daca rebuild dureaza >60 min sau fisierul >3 GB:
- Evalueaza tile-join pentru rebuild partial (doar layerul modificat)
- Evalueaza --no-tile-size-limit vs --drop-densest-as-needed trade-off
- Evalueaza split: un PMTiles per UAT sincronizat (rebuild doar orasul modificat)
- Evalueaza cron nightly vs rebuild per sync event
```
---
## Known Issues & Limitations
- tippecanoe `--drop-densest-as-needed` poate pierde features in zone dense la zoom mic
- PMTiles data e statica — parcele noi nu apar pana la rebuild
- MinIO CORS headers necesita Range + Content-Range exposed
- Martin `pool_size: 8` — nu creste fara upgrade PostgreSQL
- Portainer CE nu injecteaza env vars la build — toate in docker-compose.yml
+334
View File
@@ -0,0 +1,334 @@
# Tile Server Evaluation — ArchiTools Geoportal (March 2026)
## Context
ArchiTools Geoportal serves vector tiles (MVT) from PostgreSQL 16 + PostGIS 3 via Martin.
Data: ~330K GIS features (parcels, buildings, admin boundaries) in EPSG:3844 (Stereo70), growing to 1M+.
Frontend: MapLibre GL JS 5.21, Next.js 16, Docker self-hosted via Portainer CE.
---
## Problem Statement
1. Martin v0.15.0 was running in **auto-discovery mode** — the existing `martin.yaml` config was never mounted
2. Building labels (`cadastral_ref`) missing from MVT tiles despite the view exposing them
3. Performance concerns at scale (330K -> 1M+ features)
---
## Solutions Evaluated (7 options + emerging tech)
### 1. Martin (Fix + Upgrade) — WINNER
| Aspect | Detail |
|---|---|
| Root cause | `martin.yaml` not mounted in docker-compose — Martin ran in auto-discovery mode |
| Fix | Bake config into custom image via Dockerfile + upgrade v0.15 -> v1.4.0 |
| Performance | Fastest tile server benchmarked (2-3x faster than #2 Tegola) |
| EPSG:3844 | Native support via `default_srid: 3844` |
| New in v1.4 | ZSTD compression, MLT format, materialized views, better logging |
**Status: IMPLEMENTED AND VERIFIED IN PRODUCTION** (2026-03-27)
### 2. pg_tileserv (CrunchyData)
| Aspect | Detail |
|---|---|
| Architecture | Go binary, zero-config, delegates ST_AsMVT to PostGIS |
| Property control | Auto from schema + URL `?properties=` parameter |
| Performance | 2-3x slower than Martin (Rechsteiner benchmark) |
| EPSG:3844 | Supported (auto-reprojects via ST_Transform) |
| Killer feature | Function-based sources (full SQL tile functions) |
| Dealbreaker | View extent estimation bug (#156) affects all our views, development stagnant (last release Feb 2025) |
**Verdict: NO** — slower, buggy with views, stagnant development.
### 3. Tegola (Go-based)
| Aspect | Detail |
|---|---|
| Architecture | Go, TOML config, explicit per-layer SQL |
| Performance | 2nd in benchmarks, but 2-3x slower than Martin |
| Built-in cache | File, S3/MinIO, Redis — with seed/purge CLI |
| EPSG:3844 | **NOT SUPPORTED** (only 3857/4326) — requires ST_Transform in every query |
| Killer feature | Built-in tile seeding and cache purging |
**Verdict: NO** — EPSG:3844 not supported, dealbreaker for our data.
### 4. t-rex (Rust-based)
| Aspect | Detail |
|---|---|
| Status | **Abandoned/unmaintained** — no releases since 2023 |
**Verdict: NO** — dead project.
### 5. GeoJSON Direct from Next.js API
| Aspect | Detail |
|---|---|
| 330K features | ~270 MB uncompressed, 800 MB-1.4 GB browser memory |
| Browser impact | 10-30s main thread freeze, mobile crash |
| Pan/zoom | Full re-fetch on every viewport change, flickering |
| Viable range | Only at zoom 16+ with <500 features in viewport |
**Verdict: NO** — does not scale beyond ~20K features.
### 6. PMTiles (Pre-generated)
| Aspect | Detail |
|---|---|
| Architecture | Single-file tile archive, HTTP Range Requests, no server needed |
| Performance | ~5ms per tile (vs 200-2000ms for Martin on low-zoom) |
| Property control | tippecanoe gives explicit include/exclude per property |
| Update strategy | Full rebuild required (~3-7 min for 330K features) |
| EPSG:3844 | Requires reprojection to 4326 via ogr2ogr before tippecanoe |
| MinIO serving | Yes — direct HTTP Range Requests with CORS |
**Verdict: YES as hybrid complement** — excellent for static UAT overview layers (z0-z12), Martin for live detail.
### 7. Emerging Solutions
| Solution | Status | Relevance |
|---|---|---|
| **mvt-rs** (Rust) | v0.16.2, active | Admin UI, auth per layer, cache — good for multi-tenant |
| **MLT format** | Stable Jan 2026 | 6x compression, 4x faster decode — Martin v1.3+ supports it |
| **BBOX** | Maturing | Similar to Tegola performance, unified raster+vector |
| **DuckDB tiles** | Early | Not PostGIS replacement, interesting for GeoParquet |
| **FlatGeobuf** | Stable | Good for <100K features, not a tile replacement |
---
## Benchmark Reference (Rechsteiner, April 2025)
| Rank | Server | Language | Relative Speed |
|---|---|---|---|
| 1 | **Martin** | Rust | 1x (fastest) |
| 2 | Tegola | Go | 2-3x slower |
| 3 | BBOX | Rust | ~same as Tegola |
| 4 | pg_tileserv | Go | ~4x slower |
| 5 | TiPg | Python | Slower |
| 6 | ldproxy | Java | 4-70x slower |
Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
---
## Implementation Roadmap
### Phase 1: Martin Fix — DONE (2026-03-27)
Changes applied:
- `martin.Dockerfile`: custom image that COPY-s `martin.yaml` into `/config/`
- `docker-compose.yml`: Martin v0.15 -> v1.4.0, build from Dockerfile, `--config` flag
- `martin.yaml`: comment updated to reflect v1.4
- `map-viewer.tsx`: building labels layer activated (`cladiriLabel` at minzoom 16)
#### Deployment Lessons Learned
1. **Docker image tag format changed at v1.0**: old tags use `v` prefix (`v0.15.0`), new tags do not (`1.4.0`). The tag `ghcr.io/maplibre/martin:v1.4.0` does NOT exist — correct is `ghcr.io/maplibre/martin:1.4.0`.
2. **Portainer CE volume mount pitfall**: volume `./martin.yaml:/config/martin.yaml:ro` fails because Portainer deploys only the docker-compose.yml content, not the full git repo. Docker silently creates an empty directory instead of failing. Solution: bake config into a custom image with a 2-line Dockerfile:
```dockerfile
FROM ghcr.io/maplibre/martin:1.4.0
COPY martin.yaml /config/martin.yaml
```
3. **Martin config format is stable**: YAML format unchanged from v0.15 to v1.4 — `postgres.tables`, `connection_string`, `auto_publish`, `properties` map all work identically. No migration needed.
4. **PostGIS view geometry type**: Martin logs `UNKNOWN GEOMETRY TYPE` for all views — this is normal for nested views (`SELECT * FROM parent_view`). Views don't register specific geometry types in `geometry_columns`. Does not affect tile generation or property inclusion.
### Phase 2A: nginx Tile Cache — DONE (2026-03-27)
**Impact**: 10-100x faster on repeat requests, zero PostGIS load for cached tiles.
Changes applied:
- `nginx/tile-cache.conf`: proxy_cache config with 2GB cache zone, 7-day TTL, stale serving
- `tile-cache.Dockerfile`: bakes nginx config into custom image (Portainer CE pattern)
- `docker-compose.yml`: `tile-cache` container, Martin no longer exposed on host
- Gzip passthrough (Martin already compresses), browser caching via Cache-Control headers
- CORS headers for cross-origin tile requests
### Phase 2B: PMTiles — DONE (2026-03-27)
**Impact**: Sub-10ms overview tiles, zero PostGIS load for z0-z18.
Changes applied:
- `scripts/rebuild-overview-tiles.sh`: ogr2ogr export (3844->4326) + tippecanoe generation
- PMTiles archive: z0-z18, ~1-2 GB, includes all terenuri, cladiri, UATs, and administrativ layers
- `map-viewer.tsx`: pmtiles:// protocol registered on MapLibre, hybrid source switching
- MinIO bucket `tiles` with public read + CORS for Range Requests
- N8N webhook trigger for rebuild (via monitor page)
- Monitor page (`/monitor`): rebuild + warm-cache actions with live status polling
### Phase 2C: MLT Format — DEFERRED
Martin v1.4 advertises MLT support, but it cannot generate MLT from PostGIS live queries.
MLT generation requires pre-built tile archives (tippecanoe does not output MLT either).
No actionable path until Martin or tippecanoe adds MLT output from PostGIS sources.
### Phase 2D: mvt-rs Evaluation — FUTURE (Multi-Tenant)
**Impact**: Built-in auth, admin UI, per-layer access control.
**Effort**: 1-2 days for evaluation + migration.
Reserved for when external client access to the geoportal is needed.
mvt-rs (v0.16.2+, Rust, Salvo framework) provides per-layer auth and admin UI.
---
## Phase 3: Current Architecture (as of 2026-03-27)
Full tile-serving pipeline in production:
```
PostGIS (EPSG:3844)
|
+--> Martin v1.4.0 (live MVT from 9 PostGIS views)
| |
| +--> tile-cache (nginx reverse proxy, 2GB disk, 7d TTL)
| |
| +--> Traefik (tools.beletage.ro/tiles)
|
+--> ogr2ogr (3844->4326) + tippecanoe (z0-z18)
|
+--> PMTiles archive (~1-2 GB)
|
+--> MinIO bucket "tiles" (HTTP Range Requests)
|
+--> MapLibre (pmtiles:// protocol)
```
**Hybrid strategy**:
- PMTiles serves pre-generated overview tiles (all zoom levels, all layers)
- Martin serves live detail tiles (real-time PostGIS data)
- nginx tile-cache sits in front of Martin to absorb repeat requests
- Rebuild triggered via N8N webhook from the `/monitor` page
---
## Operational Commands
### Rebuild PMTiles
Trigger from the Monitor page (`/monitor` -> "Rebuild PMTiles" button), which sends a webhook to N8N.
N8N runs `scripts/rebuild-overview-tiles.sh` on the server.
Manual rebuild (SSH to 10.10.10.166):
```bash
cd /path/to/architools
bash scripts/rebuild-overview-tiles.sh
```
### Warm nginx Cache
Trigger from the Monitor page (`/monitor` -> "Warm Cache" button).
Pre-loads frequently accessed tiles into the nginx disk cache.
### Purge nginx Tile Cache
```bash
docker exec tile-cache rm -rf /var/cache/nginx/tiles/*
docker exec tile-cache nginx -s reload
```
### Restart Martin (after PostGIS view changes)
```bash
docker restart martin
```
Martin caches source schema at startup — must restart after DDL changes to pick up new columns.
### Check PMTiles Status
```bash
# Check file size and last modified in MinIO
docker exec minio mc stat local/tiles/overview.pmtiles
```
---
## Key Technical Details
### Martin v1.4.0 Deployment Architecture
```
Gitea repo (martin.yaml + martin.Dockerfile)
-> Portainer CE builds custom image: FROM martin:1.4.0, COPY martin.yaml
-> Container starts with --config /config/martin.yaml
-> Reads DATABASE_URL from environment
-> Serves 9 PostGIS view sources on port 3000
-> Host maps 3010:3000
-> Traefik proxies tools.beletage.ro/tiles -> host:3010
```
**Critical**: Do NOT use volume mounts for config files in Portainer CE stacks.
Always bake configs into custom images via Dockerfile COPY.
### Martin Config (validated compatible v0.15 through v1.4)
The `martin.yaml` at project root defines 9 sources with explicit properties.
Config format unchanged from v0.15 to v1.4 — no migration needed.
Key config features used:
- `auto_publish: false` — only explicitly listed sources are served
- `default_srid: 3844` — all sources use Stereo70
- `properties:` map per source — explicit column name + PostgreSQL type
- `minzoom/maxzoom` per source — controls tile generation range
- `bounds: [20.2, 43.5, 30.0, 48.3]` — approximate Romania extent
### Docker Image Tag Convention
Martin changed tag format at v1.0:
- Pre-1.0: `ghcr.io/maplibre/martin:v0.15.0` (with `v` prefix)
- Post-1.0: `ghcr.io/maplibre/martin:1.4.0` (no `v` prefix)
- Also available: `latest`, `nightly`
### PostGIS View Chain
```
GisFeature table (Prisma) -> gis_features view -> gis_terenuri / gis_cladiri / gis_administrativ
-> gis_terenuri_status / gis_cladiri_status (with JOINs)
GisUat table -> gis_uats_z0/z5/z8/z12 (with ST_SimplifyPreserveTopology)
```
### MapLibre Layer Architecture
```
Sources (Martin): gis_uats_z0, z5, z8, z12, administrativ, terenuri, cladiri
Layers per source: fill + line + label (where applicable)
Selection: Separate highlight layers on terenuri source
Drawing: GeoJSON source for freehand/rect polygon
Building labels: cladiriLabel layer, cadastral_ref at minzoom 16
```
---
## Deployment Pitfalls (Discovered During Implementation)
1. **Portainer CE does not expose repo files to containers at runtime.** Volume mounts like `./file.conf:/etc/file.conf:ro` fail silently — Docker creates an empty directory. Always bake config files into custom images via Dockerfile COPY.
2. **Martin Docker tag format change at v1.0.** `v1.4.0` does not exist, `1.4.0` does. Always check [ghcr.io/maplibre/martin](https://github.com/maplibre/martin/pkgs/container/martin) for actual tags.
3. **Martin logs `UNKNOWN GEOMETRY TYPE` for PostGIS views.** This is normal — nested views don't register geometry types in `geometry_columns`. Does not affect functionality.
4. **Martin auto-discovery mode is unreliable for property inclusion.** Always use explicit config with `auto_publish: false` and per-source `properties:` definitions.
5. **Martin caches source schema at startup.** After PostGIS view DDL changes (e.g., adding columns to gis_features), Martin must be restarted to pick up new columns.
---
## References
- [Martin Documentation](https://maplibre.org/martin/)
- [Martin Releases](https://github.com/maplibre/martin/releases)
- [Martin Container Registry](https://github.com/maplibre/martin/pkgs/container/martin)
- [Vector Tiles Benchmark (Rechsteiner 2025)](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
- [PMTiles Specification](https://github.com/protomaps/PMTiles)
- [tippecanoe (Felt)](https://github.com/felt/tippecanoe)
- [MLT Format Announcement](https://maplibre.org/news/2026-01-23-mlt-release/)
- [mvt-rs](https://github.com/mvt-proj/mvt-rs)
- [pg_tileserv](https://github.com/CrunchyData/pg_tileserv)
- [Tegola](https://github.com/go-spatial/tegola)
- [Serving Vector Tiles Fast (Spatialists)](https://spatialists.ch/posts/2025/04/05-serving-vector-tiles-fast/)
+292
View File
@@ -0,0 +1,292 @@
# Skill: MapLibre GL JS Performance for Large GIS Datasets
## When to Use
When building web maps with MapLibre GL JS that display large spatial datasets (>10K features). Covers source type selection, layer optimization, label rendering, and client-side performance tuning.
---
## Source Type Decision Matrix
| Dataset Size | Recommended Source | Reason |
|---|---|---|
| <2K features | GeoJSON | Simple, full property access, smooth |
| 2K-20K features | GeoJSON (careful) | Works but `setData()` updates lag 200-400ms |
| 20K-100K features | Vector tiles (MVT) | GeoJSON causes multi-second freezes |
| 100K+ features | Vector tiles (MVT) | GeoJSON crashes mobile, 1GB+ memory on desktop |
| Static/archival | PMTiles | Pre-generated, ~5ms per tile, zero server load |
### GeoJSON Memory Profile
| Features (polygons, ~20 coords each) | JSON Size | Browser Memory | Load Time |
|---|---|---|---|
| 1K | 0.8 MB | ~50 MB | <1s |
| 10K | 8 MB | ~200 MB | 1-3s |
| 50K | 41 MB | ~600 MB | 5-15s freeze |
| 100K | 82 MB | ~1.2 GB | 15-30s freeze |
| 330K | 270 MB | ~1.5 GB+ | Crash |
The bottleneck is `JSON.stringify` on the main thread when data is transferred to the Web Worker for `geojson-vt` tiling.
---
## Vector Tile Source Configuration
### Zoom-Dependent Source Loading
Don't load data you don't need. Set `minzoom`/`maxzoom` on sources and layers:
```typescript
// Source: only request tiles in useful zoom range
map.addSource('parcels', {
type: 'vector',
tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
minzoom: 10, // don't request below z10
maxzoom: 18, // server maxzoom (tiles overzoom beyond this)
});
// Layer: only render when meaningful
map.addLayer({
id: 'parcels-fill',
type: 'fill',
source: 'parcels',
'source-layer': 'parcels',
minzoom: 13, // visible from z13 (even if source loads from z10)
maxzoom: 20, // render up to z20 (overzooming tiles from z18)
paint: { ... },
});
```
### Multiple Sources at Different Detail Levels
For large datasets, serve simplified versions at low zoom:
```typescript
// Simplified overview (server: ST_Simplify, fewer properties)
map.addSource('parcels-overview', {
type: 'vector',
tiles: ['https://tiles.example.com/parcels_simplified/{z}/{x}/{y}'],
minzoom: 6, maxzoom: 14,
});
// Full detail
map.addSource('parcels-detail', {
type: 'vector',
tiles: ['https://tiles.example.com/parcels/{z}/{x}/{y}'],
minzoom: 14, maxzoom: 18,
});
// Layers with zoom handoff
map.addLayer({
id: 'parcels-overview-fill', source: 'parcels-overview',
minzoom: 10, maxzoom: 14, // disappears at z14
...
});
map.addLayer({
id: 'parcels-detail-fill', source: 'parcels-detail',
minzoom: 14, // appears at z14
...
});
```
---
## Label Rendering Best Practices
### Text Labels on Polygons
```typescript
map.addLayer({
id: 'parcel-labels',
type: 'symbol',
source: 'parcels',
'source-layer': 'parcels',
minzoom: 16, // only show labels at high zoom
layout: {
'text-field': ['coalesce', ['get', 'cadastral_ref'], ''],
'text-font': ['Noto Sans Regular'],
'text-size': 10,
'text-anchor': 'center',
'text-allow-overlap': false, // prevent label collisions
'text-max-width': 8, // wrap long labels (in ems)
'text-optional': true, // label is optional — feature renders without it
'symbol-placement': 'point', // placed at polygon centroid
},
paint: {
'text-color': '#1e3a5f',
'text-halo-color': '#ffffff',
'text-halo-width': 1, // readability on any background
},
});
```
### Performance Tips for Labels
- **`text-allow-overlap: false`** — essential for dense datasets, MapLibre auto-removes colliding labels
- **`text-optional: true`** — allow symbol layer to show icon without text if text collides
- **High `minzoom`** (16+) — labels are expensive to render, only show when meaningful
- **`text-font`** — use fonts available in the basemap style. Custom fonts require glyph server.
- **`symbol-sort-key`** — prioritize which labels show first (e.g., larger parcels)
```typescript
layout: {
'symbol-sort-key': ['*', -1, ['get', 'area_value']], // larger areas get priority
}
```
---
## Selection and Interaction Patterns
### Click Selection (single feature)
```typescript
map.on('click', 'parcels-fill', (e) => {
const feature = e.features?.[0];
if (!feature) return;
const props = feature.properties;
// Highlight via filter
map.setFilter('selection-highlight', ['==', 'object_id', props.object_id]);
});
```
### queryRenderedFeatures for Box/Polygon Selection
```typescript
// Rectangle selection
const features = map.queryRenderedFeatures(
[[x1, y1], [x2, y2]], // pixel bbox
{ layers: ['parcels-fill'] }
);
// Features are from rendered tiles — properties may be limited
// For full properties, fetch from API by ID
```
**Important:** `queryRenderedFeatures` only returns features currently rendered in the viewport tiles. Properties in MVT tiles may be a subset of the full database record. For detailed properties, use a separate API endpoint.
### Highlight Layer Pattern
Dedicated layer with dynamic filter for selection highlighting:
```typescript
// Add once during map setup
map.addLayer({
id: 'selection-fill',
type: 'fill',
source: 'parcels',
'source-layer': 'parcels',
filter: ['==', 'object_id', '__NONE__'], // show nothing initially
paint: { 'fill-color': '#f59e0b', 'fill-opacity': 0.5 },
});
// Update filter on selection
const ids = Array.from(selectedIds);
map.setFilter('selection-fill',
ids.length > 0
? ['in', ['to-string', ['get', 'object_id']], ['literal', ids]]
: ['==', 'object_id', '__NONE__']
);
```
---
## Basemap Management
### Multiple Basemap Support
Switching basemaps requires recreating the map (MapLibre limitation). Preserve view state:
```typescript
const viewStateRef = useRef({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM });
// Save on every move
map.on('moveend', () => {
viewStateRef.current = {
center: map.getCenter().toArray(),
zoom: map.getZoom(),
};
});
// On basemap switch: destroy map, recreate with saved view state
// All sources + layers must be re-added after style load
```
### Raster Basemaps
```typescript
const style: StyleSpecification = {
version: 8,
sources: {
basemap: {
type: 'raster',
tiles: ['https://mt0.google.com/vt/lyrs=s&x={x}&y={y}&z={z}'],
tileSize: 256,
attribution: '&copy; Google',
},
},
layers: [{
id: 'basemap', type: 'raster', source: 'basemap',
minzoom: 0, maxzoom: 20,
}],
};
```
### Vector Basemaps (OpenFreeMap, MapTiler)
```typescript
// Style URL — includes all sources + layers
const map = new maplibregl.Map({
style: 'https://tiles.openfreemap.org/styles/liberty',
});
// Hide unwanted built-in layers (e.g., admin boundaries you'll replace)
for (const layer of map.getStyle().layers) {
if (/boundar|admin/i.test(layer.id)) {
map.setLayoutProperty(layer.id, 'visibility', 'none');
}
}
```
---
## Performance Checklist
### Server Side
- [ ] Spatial index (GiST) on geometry column
- [ ] Zoom-dependent simplified views for overview levels
- [ ] `minzoom`/`maxzoom` per tile source to prevent pathological tiles
- [ ] HTTP cache (nginx proxy_cache / Varnish) in front of tile server
- [ ] PMTiles for static layers (no DB hit)
- [ ] Exclude large geometry columns from list queries
### Client Side
- [ ] Set `minzoom` on layers to avoid rendering at useless zoom levels
- [ ] `text-allow-overlap: false` on all symbol layers
- [ ] Use `text-optional: true` for labels
- [ ] Don't add GeoJSON sources for >20K features
- [ ] Use `queryRenderedFeatures` (not `querySourceFeatures`) for interaction
- [ ] Preserve view state across basemap switches (ref, not state)
- [ ] Debounce viewport-dependent API calls (search, feature loading)
### Memory Management
- [ ] Remove unused sources/layers when switching views
- [ ] Clear GeoJSON sources with `setData(emptyFeatureCollection)` before removing
- [ ] Use `map.remove()` in cleanup (useEffect return)
- [ ] Don't store large GeoJSON in React state (use refs)
---
## Common Pitfalls
1. **GeoJSON `setData()` freezes main thread**`JSON.stringify` runs synchronously for every update
2. **`queryRenderedFeatures` returns simplified geometry** — don't use for area/distance calculations
3. **Vector tile properties may be truncated** — tile servers can drop properties to fit tile size limits
4. **Basemap switch requires full map recreation** — save/restore view state and re-add all overlay layers
5. **`text-font` must match basemap fonts** — if using vector basemap, use its font stack; if raster, you need a glyph server
6. **Popup/tooltip on dense data causes flicker** — debounce mousemove handlers
7. **Large fill layers without `minzoom` tank performance** — 100K polygons at z0 is pathological
8. **`map.setFilter` with huge ID lists is slow** — for >1000 selected features, consider a separate GeoJSON source
9. **MapLibre CSS must be loaded manually in SSR frameworks** — inject `<link>` in `useEffect` or import statically
10. **React strict mode double-mounts effects** — guard map initialization with ref check
+272
View File
@@ -0,0 +1,272 @@
# Skill: PMTiles Generation Pipeline from PostGIS
## When to Use
When you need to pre-generate vector tiles from PostGIS data for fast static serving. Ideal for overview/boundary layers that change infrequently, serving from S3/MinIO/CDN without a tile server, or eliminating database load for tile serving.
---
## Complete Pipeline
### Prerequisites
| Tool | Purpose | Install |
|---|---|---|
| ogr2ogr (GDAL) | PostGIS export + reprojection | `apt install gdal-bin` or Docker |
| tippecanoe | MVT tile generation → PMTiles | `ghcr.io/felt/tippecanoe` Docker image |
| mc (MinIO client) | Upload to MinIO/S3 | `brew install minio/stable/mc` |
### Step 1: Export from PostGIS
```bash
# Single layer — FlatGeobuf is fastest for tippecanoe input
ogr2ogr -f FlatGeobuf \
-s_srs EPSG:3844 \ # source SRID (your data)
-t_srs EPSG:4326 \ # tippecanoe REQUIRES WGS84
parcels.fgb \
"PG:host=10.10.10.166 dbname=mydb user=myuser password=mypass" \
-sql "SELECT id, name, area, geom FROM my_view WHERE geom IS NOT NULL"
# Multiple layers in parallel
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
parcels.fgb "PG:..." -sql "SELECT ... FROM gis_terenuri" &
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
buildings.fgb "PG:..." -sql "SELECT ... FROM gis_cladiri" &
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
uats.fgb "PG:..." -sql "SELECT ... FROM gis_uats_z12" &
wait
```
**Why FlatGeobuf over GeoJSON:**
- Binary columnar format — tippecanoe reads it 3-5x faster
- No JSON parsing overhead
- Streaming read (no need to load entire file in memory)
- tippecanoe native support since v2.17+
### Step 2: Generate PMTiles with tippecanoe
```bash
# Single layer
tippecanoe \
-o parcels.pmtiles \
--name="Parcels" \
--layer="parcels" \
--minimum-zoom=6 \
--maximum-zoom=15 \
--base-zoom=15 \
--drop-densest-as-needed \
--extend-zooms-if-still-dropping \
--detect-shared-borders \
--simplification=10 \
--hilbert \
--force \
parcels.fgb
# Multi-layer (combined file)
tippecanoe \
-o combined.pmtiles \
--named-layer=parcels:parcels.fgb \
--named-layer=buildings:buildings.fgb \
--named-layer=uats:uats.fgb \
--minimum-zoom=0 \
--maximum-zoom=15 \
--drop-densest-as-needed \
--detect-shared-borders \
--hilbert \
--force
```
#### Key tippecanoe Flags
| Flag | Purpose | When to Use |
|---|---|---|
| `--minimum-zoom=N` | Lowest zoom level | Always set |
| `--maximum-zoom=N` | Highest zoom level (full detail) | Always set |
| `--base-zoom=N` | Zoom where ALL features kept (no dropping) | Set to max-zoom |
| `--drop-densest-as-needed` | Drop features in dense areas at low zoom | Large polygon datasets |
| `--extend-zooms-if-still-dropping` | Auto-increase max zoom if needed | Safety net |
| `--detect-shared-borders` | Prevent gaps between adjacent polygons | **Critical for parcels/admin boundaries** |
| `--coalesce-densest-as-needed` | Merge small features at low zoom | Building footprints |
| `--simplification=N` | Pixel tolerance for geometry simplification | Reduce tile size at low zoom |
| `--hilbert` | Hilbert curve ordering | Better compression, always use |
| `-y col1 -y col2` | Include ONLY these properties | Reduce tile size |
| `-x col1 -x col2` | Exclude these properties | Remove large/unnecessary fields |
| `--force` | Overwrite existing output | Scripts |
| `--no-feature-limit` | No limit per tile | When density matters |
| `--no-tile-size-limit` | No tile byte limit | When completeness matters |
#### Property Control
```bash
# Include only specific properties (whitelist)
tippecanoe -o out.pmtiles -y name -y area -y type parcels.fgb
# Exclude specific properties (blacklist)
tippecanoe -o out.pmtiles -x raw_json -x internal_id parcels.fgb
# Zoom-dependent properties (different attributes per zoom)
# Use tippecanoe-json format with per-feature "tippecanoe" key
```
### Step 3: Upload to MinIO (Atomic Swap)
```bash
# Upload to temp name first
mc cp combined.pmtiles myminio/tiles/combined_new.pmtiles
# Atomic rename (zero-downtime swap)
mc mv myminio/tiles/combined_new.pmtiles myminio/tiles/combined.pmtiles
```
### Step 4: MinIO CORS Configuration
```bash
# Required for browser-direct Range Requests
mc admin config set myminio api cors_allow_origin="https://tools.beletage.ro"
# Or bucket policy for public read
mc anonymous set download myminio/tiles
```
MinIO CORS must expose Range/Content-Range headers:
```json
{
"CORSRules": [{
"AllowedOrigins": ["https://your-domain.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["Range", "If-None-Match"],
"ExposeHeaders": ["Content-Range", "Content-Length", "ETag"],
"MaxAgeSeconds": 3600
}]
}
```
---
## MapLibre GL JS Integration
```bash
npm install pmtiles
```
```typescript
import maplibregl from 'maplibre-gl';
import { Protocol } from 'pmtiles';
// Register ONCE at app initialization
const protocol = new Protocol();
maplibregl.addProtocol('pmtiles', protocol.tile);
// Add source to map
map.addSource('my-tiles', {
type: 'vector',
url: 'pmtiles://https://minio.example.com/tiles/combined.pmtiles',
});
// Add layers
map.addLayer({
id: 'parcels-fill',
type: 'fill',
source: 'my-tiles',
'source-layer': 'parcels', // layer name from tippecanoe --layer or --named-layer
minzoom: 10,
maxzoom: 16,
paint: { 'fill-color': '#22c55e', 'fill-opacity': 0.15 },
});
// Cleanup on unmount
maplibregl.removeProtocol('pmtiles');
```
---
## Hybrid Architecture (PMTiles + Live Tile Server)
```
Zoom 0-14: PMTiles from MinIO (pre-generated, ~5ms, zero DB load)
Zoom 14+: Martin from PostGIS (live, always-current, ~50-200ms)
```
```typescript
// PMTiles for overview
map.addSource('overview', {
type: 'vector',
url: 'pmtiles://https://minio/tiles/overview.pmtiles',
});
// Martin for detail
map.addSource('detail', {
type: 'vector',
tiles: ['https://tiles.example.com/{source}/{z}/{x}/{y}'],
minzoom: 14,
maxzoom: 18,
});
// Layers with zoom handoff
map.addLayer({
id: 'parcels-overview', source: 'overview', 'source-layer': 'parcels',
minzoom: 6, maxzoom: 14, // PMTiles handles low zoom
...
});
map.addLayer({
id: 'parcels-detail', source: 'detail', 'source-layer': 'gis_terenuri',
minzoom: 14, // Martin handles high zoom
...
});
```
---
## Rebuild Strategies
### Nightly Cron
```bash
# crontab -e
0 2 * * * /opt/scripts/rebuild-tiles.sh >> /var/log/tile-rebuild.log 2>&1
```
### After Data Sync (webhook/API trigger)
```bash
# Call from sync completion handler
curl -X POST http://n8n:5678/webhook/rebuild-tiles
```
### Partial Rebuild (single layer update)
```bash
# Rebuild just parcels, then merge with existing layers
tippecanoe -o parcels_new.pmtiles ... parcels.fgb
tile-join -o combined_new.pmtiles --force \
parcels_new.pmtiles \
buildings_existing.pmtiles \
uats_existing.pmtiles
mc cp combined_new.pmtiles myminio/tiles/combined.pmtiles
```
---
## Build Time Estimates
| Features | Type | Zoom Range | Time | Output Size |
|---|---|---|---|---|
| 500 | Polygons (UAT) | z0-z12 | <5s | 10-30 MB |
| 100K | Polygons (buildings) | z12-z15 | 30-90s | 100-200 MB |
| 330K | Polygons (parcels) | z6-z15 | 2-5 min | 200-400 MB |
| 1M | Polygons (mixed) | z0-z15 | 8-15 min | 500 MB-1 GB |
tippecanoe is highly optimized and uses parallel processing.
---
## Common Pitfalls
1. **tippecanoe only accepts WGS84 (EPSG:4326)** — always reproject with ogr2ogr first
2. **`--detect-shared-borders` is critical for parcels** — without it, gaps appear between adjacent polygons
3. **GeoJSON input is slow** — use FlatGeobuf for 3-5x faster reads
4. **No incremental updates** — must rebuild entire file (use `tile-join` for layer-level replacement)
5. **MinIO needs CORS for browser-direct access** — Range + Content-Range headers must be exposed
6. **Large properties bloat tile size** — use `-y`/`-x` flags to control what goes into tiles
7. **`--no-tile-size-limit` can produce huge tiles** — use with `--drop-densest-as-needed` safety valve
8. **Atomic upload prevents serving partial files** — always upload as temp name then rename
+213
View File
@@ -0,0 +1,213 @@
# Skill: Vector Tile Serving from PostGIS
## When to Use
When building a web map that serves vector tiles from PostgreSQL/PostGIS data. Applies to any project using MapLibre GL JS, Mapbox GL JS, or OpenLayers with MVT tiles from a spatial database.
---
## Core Architecture Decision
**Always use a dedicated tile server over GeoJSON for datasets >20K features.**
GeoJSON limits:
- 20K polygons: visible jank on `setData()`, 200-400ms freezes
- 50K polygons: multi-second freezes, 500MB+ browser memory
- 100K+ polygons: crashes mobile browsers, 1-2GB memory on desktop
- `JSON.stringify` runs on main thread — blocks UI proportional to data size
Vector tiles (MVT) solve this:
- Only visible tiles loaded (~50-200KB per viewport)
- Incremental pan/zoom (no re-fetch)
- ~100-200MB client memory regardless of total dataset size
- Works on mobile
---
## Tile Server Rankings (Rechsteiner Benchmark, April 2025)
| Rank | Server | Language | Speed | Notes |
|---|---|---|---|---|
| 1 | **Martin** | Rust | 1x | Clear winner, 95-122ms range |
| 2 | Tegola | Go | 2-3x slower | Only supports SRID 3857/4326 |
| 3 | BBOX | Rust | ~same as Tegola | Unified raster+vector |
| 4 | pg_tileserv | Go | ~4x slower | Zero-config but limited control |
| 5 | TiPg | Python | Slower | Not for production scale |
| 6 | ldproxy | Java | 4-70x slower | Enterprise/OGC compliance |
Source: [github.com/FabianRechsteiner/vector-tiles-benchmark](https://github.com/FabianRechsteiner/vector-tiles-benchmark)
---
## Martin: Best Practices
### Always use explicit config (not auto-discovery)
Auto-discovery can drop properties, misdetect SRIDs, and behave unpredictably with nested views.
```yaml
postgres:
connection_string: ${DATABASE_URL}
default_srid: 3844 # your source SRID
auto_publish: false # explicit sources only
tables:
my_layer:
schema: public
table: my_view_name
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3] # approximate extent
minzoom: 10
maxzoom: 18
properties:
object_id: text # explicit column name: pg_type
name: text
area: float8
```
### Docker image tags
Martin changed tag format at v1.0:
- Pre-1.0: `ghcr.io/maplibre/martin:v0.15.0` (with `v` prefix)
- Post-1.0: `ghcr.io/maplibre/martin:1.4.0` (no `v` prefix)
### Docker deployment
**If your orchestrator has access to the full repo** (docker-compose CLI, Docker Swarm with repo checkout):
```yaml
martin:
image: ghcr.io/maplibre/martin:1.4.0
command: ["--config", "/config/martin.yaml"]
environment:
- DATABASE_URL=postgresql://user:pass@host:5432/db
volumes:
- ./martin.yaml:/config/martin.yaml:ro
ports:
- "3010:3000"
```
**If using Portainer CE or any system that only sees docker-compose.yml** (not full repo):
Volume mounts for repo files fail silently — Docker creates an empty directory instead.
Bake config into a custom image:
```dockerfile
# martin.Dockerfile
FROM ghcr.io/maplibre/martin:1.4.0
COPY martin.yaml /config/martin.yaml
```
```yaml
martin:
build:
context: .
dockerfile: martin.Dockerfile
command: ["--config", "/config/martin.yaml"]
environment:
- DATABASE_URL=postgresql://user:pass@host:5432/db
ports:
- "3010:3000"
```
### Custom SRID handling
Martin handles non-4326/3857 SRIDs natively. Set `default_srid` globally or `srid` per source. Martin reprojects to Web Mercator (3857) internally for tile envelope calculations. Your PostGIS spatial indexes on the source SRID are used correctly.
### Zoom-dependent simplification
Create separate views per zoom range with `ST_SimplifyPreserveTopology`:
```sql
-- z0-5: heavy simplification (2000m tolerance)
CREATE VIEW my_layer_z0 AS
SELECT id, name, ST_SimplifyPreserveTopology(geom, 2000) AS geom
FROM my_table;
-- z8-12: moderate (50m)
CREATE VIEW my_layer_z8 AS
SELECT id, name, ST_SimplifyPreserveTopology(geom, 50) AS geom
FROM my_table;
-- z12+: full precision
CREATE VIEW my_layer_z12 AS
SELECT * FROM my_table;
```
### Performance at 1M+ features
- Set `minzoom` per source to avoid pathological low-zoom tiles
- Buildings: minzoom 14 (skip at overview levels)
- Use zoom-dependent simplified views for boundaries
- Add HTTP cache (nginx proxy_cache) in front of Martin
- Consider PMTiles for static overview layers
---
## PMTiles: Pre-generated Tile Archives
Best for: static/rarely-changing layers, overview zoom levels, eliminating DB load.
### Pipeline
```bash
# 1. Export from PostGIS, reproject to WGS84
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
layer.fgb "PG:dbname=mydb" \
-sql "SELECT id, name, geom FROM my_table"
# 2. Generate PMTiles
tippecanoe -o output.pmtiles \
--layer="my_layer" layer.fgb \
--minimum-zoom=0 --maximum-zoom=14 \
--drop-densest-as-needed \
--detect-shared-borders \
--hilbert --force
# 3. Serve from any HTTP server with Range request support (MinIO, nginx, CDN)
```
### MapLibre integration
```typescript
import { Protocol } from 'pmtiles';
maplibregl.addProtocol('pmtiles', new Protocol().tile);
// Add source
map.addSource('overview', {
type: 'vector',
url: 'pmtiles://https://my-server/tiles/overview.pmtiles',
});
```
### Hybrid approach (recommended for large datasets)
- PMTiles for overview (z0-z14): pre-generated, ~5ms serving, zero DB load
- Martin for detail (z14+): live from PostGIS, always-current data
- Rebuild PMTiles on schedule (nightly) or after data sync
---
## MLT (MapLibre Tiles) — Next-Gen Format (2026)
- 6x better compression than MVT (column-oriented layout)
- 3.7-4.4x faster client decode (SIMD-friendly)
- Martin v1.3+ supports serving MLT
- MapLibre GL JS 5.x supports decoding MLT
- Spec: [github.com/maplibre/maplibre-tile-spec](https://github.com/maplibre/maplibre-tile-spec)
---
## Common Pitfalls
1. **Martin auto-discovery drops properties** — always use explicit config with `auto_publish: false`
2. **Martin Docker tag format changed at v1.0**`v0.15.0` (with v) but `1.4.0` (without v). Check actual tags at ghcr.io.
3. **Portainer CE volume mounts fail silently** — Docker creates empty directory instead of file. Bake configs into images via Dockerfile COPY.
4. **Martin logs `UNKNOWN GEOMETRY TYPE` for views** — normal for nested views, does not affect tile generation
5. **Nested views lose SRID metadata** — cast geometry: `geom::geometry(Geometry, 3844)`
6. **GisUat.geometry is huge** — always `select` to exclude in list queries
7. **Low-zoom tiles scan entire dataset** — use zoom-dependent simplified views
8. **No tile cache by default** — add nginx/Varnish in front of any tile server
9. **tippecanoe requires WGS84** — reproject from custom SRID before generating PMTiles
10. **PMTiles not incrementally updatable** — full rebuild required on data change
11. **Tegola doesn't support custom SRIDs** — only 3857/4326, requires ST_Transform everywhere
12. **pg_tileserv `ST_Estimated_Extent` fails on views** — use materialized views or function layers
13. **Martin caches source schema at startup** — restart after view DDL changes
+2
View File
@@ -0,0 +1,2 @@
FROM ghcr.io/maplibre/martin:1.4.0
COPY martin.yaml /config/martin.yaml
+4 -3
View File
@@ -1,9 +1,10 @@
# Martin v0.15 configuration — optimized tile sources for ArchiTools Geoportal
# Martin v1.4 configuration — optimized tile sources for ArchiTools Geoportal
# All geometries are EPSG:3844 (Stereo70). Bounds are approximate Romania extent.
# Original table data is NEVER modified — views compute simplification on-the-fly.
postgres:
connection_string: ${DATABASE_URL}
pool_size: 8
default_srid: 3844
auto_publish: false
tables:
@@ -83,7 +84,7 @@ postgres:
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 10
minzoom: 17
maxzoom: 18
properties:
object_id: text
@@ -138,7 +139,7 @@ postgres:
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 12
minzoom: 17
maxzoom: 18
properties:
object_id: text
+117
View File
@@ -0,0 +1,117 @@
# nginx tile cache for Martin vector tile server
# Proxy-cache layer: 10-100x faster on repeat requests, zero PostGIS load for cached tiles
proxy_cache_path /var/cache/nginx/tiles
levels=1:2
keys_zone=tiles:64m
max_size=2g
inactive=7d
use_temp_path=off;
# Log format with cache status for monitoring (docker logs tile-cache | grep HIT/MISS)
log_format tiles '$remote_addr [$time_local] "$request" $status '
'cache=$upstream_cache_status size=$body_bytes_sent '
'time=$request_time';
server {
access_log /var/log/nginx/access.log tiles;
listen 80;
server_name _;
# Health check
location = /health {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# nginx status (active connections, request counts) — for monitoring
location = /status {
access_log off;
stub_status on;
}
# Martin catalog endpoint (no cache)
location = /catalog {
proxy_pass http://martin:3000/catalog;
proxy_set_header Host $host;
}
# PMTiles from MinIO — HTTPS proxy for browser access (avoids mixed-content block)
# Browser fetches: /pmtiles/overview.pmtiles → MinIO http://10.10.10.166:9002/tiles/overview.pmtiles
location /pmtiles/ {
proxy_pass http://10.10.10.166:9002/tiles/;
proxy_set_header Host 10.10.10.166:9002;
proxy_http_version 1.1;
# Range requests — essential for PMTiles (byte-range tile lookups)
proxy_set_header Range $http_range;
proxy_set_header If-Range $http_if_range;
proxy_pass_request_headers on;
# Browser cache — file changes only on rebuild (~weekly)
add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
# CORS — PMTiles loaded from tools.beletage.ro page
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range, If-None-Match, If-Range, Accept-Encoding" always;
add_header Access-Control-Expose-Headers "Content-Range, Content-Length, ETag, Accept-Ranges" always;
# Preflight
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
add_header Access-Control-Allow-Headers "Range, If-Range";
add_header Access-Control-Max-Age 86400;
add_header Content-Length 0;
return 204;
}
}
# Tile requests — cache aggressively
location / {
proxy_pass http://martin:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Cache config — tiles change only on sync (weekly), long TTL is safe
proxy_cache tiles;
proxy_cache_key "$request_uri";
proxy_cache_valid 200 7d;
proxy_cache_valid 204 1h;
proxy_cache_valid 404 1m;
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
# Browser caching — tiles are immutable between syncs
add_header Cache-Control "public, max-age=86400, stale-while-revalidate=604800" always;
# Pass cache status header (useful for debugging)
add_header X-Cache-Status $upstream_cache_status always;
# CORS headers for tile requests
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range, If-None-Match, Accept-Encoding" always;
add_header Access-Control-Expose-Headers "Content-Range, Content-Length, ETag, X-Cache-Status" always;
# Handle preflight
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin "*";
add_header Access-Control-Allow-Methods "GET, HEAD, OPTIONS";
add_header Access-Control-Max-Age 86400;
add_header Content-Length 0;
return 204;
}
# Let Martin gzip natively — pass compressed response through to client and cache
gzip off;
# Timeouts (Martin can be slow on low-zoom tiles)
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
proxy_send_timeout 30s;
}
}
+10
View File
@@ -25,6 +25,7 @@
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"pmtiles": "^4.4.0",
"proj4": "^2.20.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
@@ -10806,6 +10807,15 @@
"pathe": "^2.0.3"
}
},
"node_modules/pmtiles": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.4.0.tgz",
"integrity": "sha512-tCLI1C5134MR54i8izUWhse0QUtO/EC33n9yWp1N5dYLLvyc197U0fkF5gAJhq1TdWO9Tvl+9hgvFvM0fR27Zg==",
"license": "BSD-3-Clause",
"dependencies": {
"fflate": "^0.8.2"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+1
View File
@@ -26,6 +26,7 @@
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"pmtiles": "^4.4.0",
"proj4": "^2.20.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
+4
View File
@@ -59,6 +59,10 @@ WHERE geometry IS NOT NULL AND geom IS NULL;
CREATE INDEX IF NOT EXISTS gis_feature_geom_idx
ON "GisFeature" USING GIST (geom);
-- B-tree index on layerId for view filtering (LIKE 'TERENURI%', 'CLADIRI%')
CREATE INDEX IF NOT EXISTS gis_feature_layer_id_idx
ON "GisFeature" ("layerId");
-- =============================================================================
-- 7. QGIS-friendly views
-- - Clean snake_case column names
+30
View File
@@ -19,6 +19,36 @@ model KeyValueStore {
@@index([namespace])
}
// ─── GIS: Sync Scheduling ──────────────────────────────────────────
model GisSyncRule {
id String @id @default(uuid())
siruta String? /// Set = UAT-specific rule
county String? /// Set = county-wide default rule
frequency String /// "3x-daily"|"daily"|"weekly"|"monthly"|"manual"
syncTerenuri Boolean @default(true)
syncCladiri Boolean @default(true)
syncNoGeom Boolean @default(false)
syncEnrich Boolean @default(false)
priority Int @default(5) /// 1=highest, 10=lowest
enabled Boolean @default(true)
allowedHoursStart Int? /// null = no restriction, e.g. 1 for 01:00
allowedHoursEnd Int? /// e.g. 5 for 05:00
allowedDays String? /// e.g. "1,2,3,4,5" for weekdays, null = all days
lastSyncAt DateTime?
lastSyncStatus String? /// "done"|"error"
lastSyncError String?
nextDueAt DateTime?
label String? /// Human-readable note
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([siruta, county])
@@index([enabled, nextDueAt])
@@index([county])
@@index([frequency])
}
// ─── GIS: eTerra ParcelSync ────────────────────────────────────────
model GisFeature {
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# rebuild-overview-tiles.sh — Export all overview layers from PostGIS, generate PMTiles, upload to MinIO
# Includes: UAT boundaries, administrativ, simplified terenuri (z10-z14), simplified cladiri (z12-z14)
# Usage: ./scripts/rebuild-overview-tiles.sh
# Dependencies: ogr2ogr (GDAL), tippecanoe, mc (MinIO client)
set -euo pipefail
# ── Configuration ──
DB_HOST="${DB_HOST:-10.10.10.166}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-architools_db}"
DB_USER="${DB_USER:-architools_user}"
DB_PASS="${DB_PASS:-stictMyFon34!_gonY}"
MINIO_ALIAS="${MINIO_ALIAS:-myminio}"
MINIO_BUCKET="${MINIO_BUCKET:-tiles}"
MINIO_ENDPOINT="${MINIO_ENDPOINT:-http://10.10.10.166:9002}"
MINIO_ACCESS_KEY="${MINIO_ACCESS_KEY:-admin}"
MINIO_SECRET_KEY="${MINIO_SECRET_KEY:-MinioStrongPass123}"
TMPDIR="${TMPDIR:-/tmp/tile-rebuild}"
OUTPUT_FILE="overview.pmtiles"
PG_CONN="PG:host=${DB_HOST} port=${DB_PORT} dbname=${DB_NAME} user=${DB_USER} password=${DB_PASS}"
echo "[$(date -Iseconds)] Starting overview tile rebuild..."
# ── Setup ──
mkdir -p "$TMPDIR"
cd "$TMPDIR"
# ── Step 1: Export views from PostGIS (parallel) ──
echo "[$(date -Iseconds)] Exporting PostGIS views to FlatGeobuf..."
# UAT boundaries (4 zoom levels)
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
uats_z0.fgb "$PG_CONN" \
-sql "SELECT name, siruta, geom FROM gis_uats_z0 WHERE geom IS NOT NULL" &
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
uats_z5.fgb "$PG_CONN" \
-sql "SELECT name, siruta, geom FROM gis_uats_z5 WHERE geom IS NOT NULL" &
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
uats_z8.fgb "$PG_CONN" \
-sql "SELECT name, siruta, county, geom FROM gis_uats_z8 WHERE geom IS NOT NULL" &
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
uats_z12.fgb "$PG_CONN" \
-sql "SELECT name, siruta, county, geom FROM gis_uats_z12 WHERE geom IS NOT NULL" &
# Administrativ (intravilan, arii speciale)
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
administrativ.fgb "$PG_CONN" \
-sql "SELECT object_id, siruta, layer_id, cadastral_ref, geom FROM gis_administrativ WHERE geom IS NOT NULL" &
# Terenuri for overview — let tippecanoe handle simplification
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
terenuri_overview.fgb "$PG_CONN" \
-sql "SELECT object_id, siruta, cadastral_ref, area_value, layer_id, geom
FROM gis_terenuri WHERE geom IS NOT NULL" &
# Cladiri for overview — let tippecanoe handle simplification
ogr2ogr -f FlatGeobuf -s_srs EPSG:3844 -t_srs EPSG:4326 \
cladiri_overview.fgb "$PG_CONN" \
-sql "SELECT object_id, siruta, cadastral_ref, area_value, layer_id, geom
FROM gis_cladiri WHERE geom IS NOT NULL" &
wait
echo "[$(date -Iseconds)] Export complete."
# ── Step 2: Generate PMTiles with tippecanoe ──
echo "[$(date -Iseconds)] Generating PMTiles..."
# Per-layer zoom ranges — avoids processing features at zoom levels where they won't appear
# UAT boundaries: only at their respective zoom bands (saves processing z13-z18 for simple polygons)
# Terenuri/Cladiri: only z13+/z14+ (the expensive layers skip z0-z12 entirely)
tippecanoe \
-o "$OUTPUT_FILE" \
-L'{"layer":"gis_uats_z0","file":"uats_z0.fgb","minzoom":0,"maxzoom":5}' \
-L'{"layer":"gis_uats_z5","file":"uats_z5.fgb","minzoom":5,"maxzoom":8}' \
-L'{"layer":"gis_uats_z8","file":"uats_z8.fgb","minzoom":8,"maxzoom":12}' \
-L'{"layer":"gis_uats_z12","file":"uats_z12.fgb","minzoom":12,"maxzoom":14}' \
-L'{"layer":"gis_administrativ","file":"administrativ.fgb","minzoom":10,"maxzoom":16}' \
-L'{"layer":"gis_terenuri","file":"terenuri_overview.fgb","minzoom":13,"maxzoom":18}' \
-L'{"layer":"gis_cladiri","file":"cladiri_overview.fgb","minzoom":14,"maxzoom":18}' \
--base-zoom=18 \
--drop-densest-as-needed \
--detect-shared-borders \
--no-tile-stats \
--hilbert \
--force
echo "[$(date -Iseconds)] PMTiles generated: $(du -h "$OUTPUT_FILE" | cut -f1)"
# ── Step 3: Upload to MinIO (atomic swap) ──
echo "[$(date -Iseconds)] Uploading to MinIO..."
# Configure MinIO client alias (idempotent)
mc alias set "$MINIO_ALIAS" "$MINIO_ENDPOINT" "$MINIO_ACCESS_KEY" "$MINIO_SECRET_KEY" --api S3v4 2>/dev/null || true
# Ensure bucket exists
mc mb --ignore-existing "${MINIO_ALIAS}/${MINIO_BUCKET}" 2>/dev/null || true
# Upload as temp file first
mc cp "$OUTPUT_FILE" "${MINIO_ALIAS}/${MINIO_BUCKET}/overview_new.pmtiles"
# Atomic rename (zero-downtime swap)
mc mv "${MINIO_ALIAS}/${MINIO_BUCKET}/overview_new.pmtiles" "${MINIO_ALIAS}/${MINIO_BUCKET}/overview.pmtiles"
echo "[$(date -Iseconds)] Upload complete."
# ── Step 4: Cleanup ──
rm -f uats_z0.fgb uats_z5.fgb uats_z8.fgb uats_z12.fgb administrativ.fgb \
terenuri_overview.fgb cladiri_overview.fgb "$OUTPUT_FILE"
echo "[$(date -Iseconds)] Rebuild finished successfully."
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# tile-cache-stats.sh — Show tile cache hit/miss statistics
# Usage: ./scripts/tile-cache-stats.sh [MINUTES]
# Reads recent nginx logs from tile-cache container.
set -euo pipefail
MINUTES="${1:-60}"
echo "=== Tile Cache Stats (last ${MINUTES}min) ==="
echo ""
# Get nginx status (active connections)
echo "--- Connections ---"
curl -s "http://10.10.10.166:3010/status" 2>/dev/null || echo "(status endpoint unavailable)"
echo ""
# Parse recent logs for cache hit/miss ratio
echo "--- Cache Performance ---"
docker logs tile-cache --since "${MINUTES}m" 2>/dev/null | \
grep -oP 'cache=\K\w+' | sort | uniq -c | sort -rn || echo "(no logs in timeframe)"
echo ""
echo "--- Cache Size ---"
docker exec tile-cache du -sh /var/cache/nginx/tiles/ 2>/dev/null || echo "(cannot read cache dir)"
echo ""
echo "--- Slowest Tiles (>1s) ---"
docker logs tile-cache --since "${MINUTES}m" 2>/dev/null | \
grep -oP 'time=\K[0-9.]+' | awk '$1 > 1.0 {print $1"s"}' | sort -rn | head -5 || echo "(none)"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
# warm-tile-cache.sh — Pre-populate nginx tile cache with common tiles
# Usage: ./scripts/warm-tile-cache.sh [BASE_URL]
# Run after deploy or cache purge to ensure fast first-load for users.
set -euo pipefail
BASE="${1:-http://10.10.10.166:3010}"
PARALLEL="${PARALLEL:-8}"
TOTAL=0
HITS=0
echo "[$(date -Iseconds)] Warming tile cache at $BASE ..."
# ── Helper: fetch a range of tiles ──
fetch_tiles() {
local source="$1" z="$2" x_min="$3" x_max="$4" y_min="$5" y_max="$6"
for x in $(seq "$x_min" "$x_max"); do
for y in $(seq "$y_min" "$y_max"); do
echo "${BASE}/${source}/${z}/${x}/${y}"
done
done
}
# ── Romania bounding box at various zoom levels ──
# Lon: 20.2-30.0, Lat: 43.5-48.3
# Tile coords computed from slippy map formula
# z5: UATs coarse (2 tiles)
fetch_tiles gis_uats_z5 5 17 18 11 11
# z7: UATs moderate (12 tiles)
fetch_tiles gis_uats_z8 7 69 73 44 46
# z8: UATs + labels (40 tiles)
fetch_tiles gis_uats_z8 8 139 147 88 92
# z9: UATs labels (100 tiles — major cities area)
fetch_tiles gis_uats_z8 9 279 288 177 185
# z10: Administrativ + terenuri sources start loading
# Focus on major metro areas: Bucharest, Cluj, Timisoara, Iasi, Brasov
# Bucharest area (z12)
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
# Cluj area (z12)
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
echo "[$(date -Iseconds)] Fetching tiles ($PARALLEL concurrent)..."
# Pipe all URLs through xargs+curl for parallel fetching
fetch_tiles gis_uats_z5 5 17 18 11 11
fetch_tiles gis_uats_z8 7 69 73 44 46
fetch_tiles gis_uats_z8 8 139 147 88 92
fetch_tiles gis_uats_z8 9 279 288 177 185
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
# Actually execute all fetches
{
fetch_tiles gis_uats_z5 5 17 18 11 11
fetch_tiles gis_uats_z8 7 69 73 44 46
fetch_tiles gis_uats_z8 8 139 147 88 92
fetch_tiles gis_uats_z8 9 279 288 177 185
fetch_tiles gis_terenuri 12 2300 2310 1490 1498
fetch_tiles gis_cladiri 12 2300 2310 1490 1498
fetch_tiles gis_terenuri 12 2264 2270 1460 1465
fetch_tiles gis_cladiri 12 2264 2270 1460 1465
} | xargs -P "$PARALLEL" -I {} curl -sf -o /dev/null {} 2>/dev/null
echo "[$(date -Iseconds)] Cache warming complete."
+736
View File
@@ -0,0 +1,736 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
type MonitorData = {
timestamp: string;
nginx?: { activeConnections?: number; requests?: number; reading?: number; writing?: number; waiting?: number; error?: string };
martin?: { status?: string; sources?: string[]; sourceCount?: number; error?: string };
pmtiles?: { url?: string; status?: string; size?: string; lastModified?: string; error?: string };
cacheTests?: { tile: string; status: string; cache: string }[];
config?: { martinUrl?: string; pmtilesUrl?: string; n8nWebhook?: string };
};
type EterraSessionStatus = {
connected: boolean;
username?: string;
connectedAt?: string;
activeJobCount: number;
eterraAvailable?: boolean;
eterraMaintenance?: boolean;
eterraHealthMessage?: string;
};
type GisStats = {
totalUats: number;
totalFeatures: number;
totalTerenuri: number;
totalCladiri: number;
totalEnriched: number;
totalNoGeom: number;
countiesWithData: number;
lastSyncAt: string | null;
dbSizeMb: number | null;
};
export default function MonitorPage() {
const [data, setData] = useState<MonitorData | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState("");
const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]);
const [counties, setCounties] = useState<string[]>([]);
const [selectedCounty, setSelectedCounty] = useState("");
const [eterraSession, setEterraSession] = useState<EterraSessionStatus>({ connected: false, activeJobCount: 0 });
const [eterraConnecting, setEterraConnecting] = useState(false);
const [showLoginForm, setShowLoginForm] = useState(false);
const [eterraUser, setEterraUser] = useState("");
const [eterraPwd, setEterraPwd] = useState("");
const [gisStats, setGisStats] = useState<GisStats | null>(null);
const rebuildPrevRef = useRef<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/geoportal/monitor");
if (res.ok) setData(await res.json());
} catch { /* noop */ }
setLoading(false);
}, []);
useEffect(() => { refresh(); }, [refresh]);
// Auto-refresh every 30s
useEffect(() => {
const interval = setInterval(refresh, 30_000);
return () => clearInterval(interval);
}, [refresh]);
const addLog = useCallback((type: "info" | "ok" | "error" | "wait", msg: string) => {
setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]);
}, []);
// Fetch counties for sync selector
useEffect(() => {
fetch("/api/eterra/counties")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: { counties: string[] }) => setCounties(d.counties ?? []))
.catch(() => {});
}, []);
// eTerra session status — poll every 30s
const fetchEterraSession = useCallback(async () => {
try {
const res = await fetch("/api/eterra/session");
if (res.ok) setEterraSession(await res.json() as EterraSessionStatus);
} catch { /* noop */ }
}, []);
useEffect(() => {
void fetchEterraSession();
const interval = setInterval(() => void fetchEterraSession(), 30_000);
return () => clearInterval(interval);
}, [fetchEterraSession]);
// GIS stats — poll every 30s
const fetchGisStats = useCallback(async () => {
try {
const res = await fetch("/api/eterra/stats");
if (res.ok) setGisStats(await res.json() as GisStats);
} catch { /* noop */ }
}, []);
useEffect(() => {
void fetchGisStats();
const interval = setInterval(() => void fetchGisStats(), 30_000);
return () => clearInterval(interval);
}, [fetchGisStats]);
const handleEterraConnect = async () => {
setEterraConnecting(true);
try {
const payload: Record<string, string> = { action: "connect" };
if (eterraUser.trim()) payload.username = eterraUser.trim();
if (eterraPwd.trim()) payload.password = eterraPwd.trim();
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const d = await res.json() as { success?: boolean; error?: string };
if (d.success) {
await fetchEterraSession();
addLog("ok", "eTerra conectat");
setShowLoginForm(false);
setEterraPwd("");
} else {
addLog("error", `eTerra: ${d.error ?? "Eroare conectare"}`);
}
} catch {
addLog("error", "eTerra: eroare retea");
}
setEterraConnecting(false);
};
const handleEterraDisconnect = async () => {
const res = await fetch("/api/eterra/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "disconnect" }),
});
const d = await res.json() as { success?: boolean; error?: string };
if (d.success) {
setEterraSession({ connected: false, activeJobCount: 0 });
addLog("info", "eTerra deconectat");
} else {
addLog("error", `Deconectare: ${d.error ?? "Eroare"}`);
}
};
// Cleanup poll on unmount
useEffect(() => {
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, []);
const triggerRebuild = async () => {
setActionLoading("rebuild");
addLog("info", "Se trimite webhook la N8N...");
try {
const res = await fetch("/api/geoportal/monitor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "rebuild" }),
});
const result = await res.json() as { ok?: boolean; error?: string; alreadyRunning?: boolean; previousPmtiles?: { lastModified: string } };
if (!result.ok) {
addLog("error", result.error ?? "Eroare necunoscuta");
setActionLoading("");
return;
}
addLog("ok", result.alreadyRunning
? "Rebuild deja in curs. Se monitorizeaza..."
: "Webhook trimis. Rebuild pornit...");
rebuildPrevRef.current = result.previousPmtiles?.lastModified ?? null;
// Poll every 15s to check if PMTiles was updated
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const checkRes = await fetch("/api/geoportal/monitor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "check-rebuild", previousLastModified: rebuildPrevRef.current }),
});
const check = await checkRes.json() as { changed?: boolean; current?: { size: string; lastModified: string } };
if (check.changed) {
addLog("ok", `Rebuild finalizat! PMTiles: ${check.current?.size}, actualizat: ${check.current?.lastModified}`);
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setActionLoading("");
refresh();
}
} catch { /* continue polling */ }
}, 15_000);
// Timeout after 90 min (z18 builds can take 45-60 min)
setTimeout(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
addLog("error", "Timeout: rebuild nu s-a finalizat in 90 minute");
setActionLoading("");
}
}, 90 * 60_000);
} catch {
addLog("error", "Nu s-a putut trimite webhook-ul");
setActionLoading("");
}
};
const triggerWarmCache = async () => {
setActionLoading("warm-cache");
addLog("info", "Se incarca tile-uri in cache...");
try {
const res = await fetch("/api/geoportal/monitor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "warm-cache" }),
});
const result = await res.json() as { ok?: boolean; error?: string; total?: number; hits?: number; misses?: number; errors?: number; message?: string };
if (result.ok) {
addLog("ok", result.message ?? "Cache warming finalizat");
} else {
addLog("error", result.error ?? "Eroare");
}
} catch {
addLog("error", "Eroare la warm cache");
}
setActionLoading("");
setTimeout(refresh, 2000);
};
return (
<div className="mx-auto max-w-5xl p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Tile Infrastructure Monitor</h1>
<p className="text-sm text-muted-foreground">
{data?.timestamp ? `Ultima actualizare: ${new Date(data.timestamp).toLocaleTimeString("ro-RO")}` : "Se incarca..."}
</p>
</div>
<button
onClick={refresh}
disabled={loading}
className="px-4 py-2 rounded bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
>
{loading ? "..." : "Reincarca"}
</button>
</div>
{/* Status Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{/* Nginx Card */}
<Card title="nginx Tile Cache">
{data?.nginx?.error ? (
<StatusBadge status="error" label={data.nginx.error} />
) : data?.nginx ? (
<div className="space-y-2 text-sm">
<StatusBadge status="ok" label="Online" />
<div className="grid grid-cols-2 gap-1 mt-2">
<Stat label="Conexiuni active" value={data.nginx.activeConnections} />
<Stat label="Total requests" value={data.nginx.requests?.toLocaleString()} />
<Stat label="Reading" value={data.nginx.reading} />
<Stat label="Writing" value={data.nginx.writing} />
<Stat label="Waiting" value={data.nginx.waiting} />
</div>
</div>
) : <Skeleton />}
</Card>
{/* Martin Card */}
<Card title="Martin Tile Server">
{data?.martin?.error ? (
<StatusBadge status="error" label={data.martin.error} />
) : data?.martin ? (
<div className="space-y-2 text-sm">
<StatusBadge status="ok" label={`${data.martin.sourceCount} surse active`} />
<div className="mt-2 space-y-1">
{data.martin.sources?.map((s) => (
<span key={s} className="inline-block mr-1 mb-1 px-2 py-0.5 rounded bg-muted text-xs">{s}</span>
))}
</div>
</div>
) : <Skeleton />}
</Card>
{/* PMTiles Card */}
<Card title="PMTiles Overview">
{data?.pmtiles?.error ? (
<StatusBadge status="error" label={data.pmtiles.error} />
) : data?.pmtiles?.status === "not configured" ? (
<StatusBadge status="warn" label="Nu e configurat" />
) : data?.pmtiles ? (
<div className="space-y-2 text-sm">
<StatusBadge status="ok" label={data.pmtiles.size ?? "OK"} />
<Stat label="Ultima modificare" value={data.pmtiles.lastModified} />
</div>
) : <Skeleton />}
</Card>
</div>
{/* Cache Test Results */}
<Card title="Cache Test">
{data?.cacheTests ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left">
<th className="py-2 pr-4">Tile</th>
<th className="py-2 pr-4">HTTP</th>
<th className="py-2">Cache</th>
</tr>
</thead>
<tbody>
{data.cacheTests.map((t, i) => (
<tr key={i} className="border-b border-border/50">
<td className="py-2 pr-4 font-mono text-xs">{t.tile}</td>
<td className="py-2 pr-4">{t.status}</td>
<td className="py-2">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${
t.cache === "HIT" ? "bg-green-500/20 text-green-400" :
t.cache === "MISS" ? "bg-yellow-500/20 text-yellow-400" :
"bg-red-500/20 text-red-400"
}`}>
{t.cache}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : <Skeleton />}
</Card>
{/* eTerra Connection + Live Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* Connection card */}
<Card title="Conexiune eTerra">
<div className="space-y-3">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full shrink-0 ${
eterraSession.eterraMaintenance ? "bg-yellow-400 animate-pulse" :
eterraSession.connected ? "bg-green-400" : "bg-red-400"
}`} />
<span className="text-sm font-medium">
{eterraSession.eterraMaintenance ? "Mentenanta" :
eterraSession.connected ? (eterraSession.username || "Conectat") : "Deconectat"}
</span>
</div>
{eterraSession.connected && eterraSession.connectedAt && (
<div className="text-xs text-muted-foreground">
Conectat de la {new Date(eterraSession.connectedAt).toLocaleTimeString("ro-RO")}
</div>
)}
{eterraSession.connected && eterraSession.activeJobCount > 0 && (
<div className="flex items-center gap-1.5 text-xs">
<span className="w-1.5 h-1.5 rounded-full bg-blue-400 animate-pulse" />
<span>{eterraSession.activeJobCount} {eterraSession.activeJobCount === 1 ? "job activ" : "joburi active"}</span>
</div>
)}
<div className="pt-1">
{eterraSession.connected ? (
<button
onClick={handleEterraDisconnect}
className="w-full text-xs px-3 py-1.5 rounded-md border border-border hover:bg-destructive/10 hover:text-destructive hover:border-destructive/30 transition-colors"
>
Deconecteaza
</button>
) : (
<button
onClick={() => setShowLoginForm((v) => !v)}
className="w-full text-xs px-3 py-1.5 rounded-md border border-border hover:border-primary/50 hover:bg-primary/10 transition-colors"
>
{showLoginForm ? "Anuleaza" : "Conecteaza"}
</button>
)}
</div>
{showLoginForm && !eterraSession.connected && (
<div className="space-y-2 pt-1">
<input
type="text"
value={eterraUser}
onChange={(e) => setEterraUser(e.target.value)}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
placeholder="Utilizator eTerra"
/>
<input
type="password"
value={eterraPwd}
onChange={(e) => setEterraPwd(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleEterraConnect(); }}
className="h-8 w-full rounded-md border border-border bg-background px-2 text-sm"
placeholder="Parola"
/>
<button
onClick={handleEterraConnect}
disabled={eterraConnecting || !eterraUser.trim() || !eterraPwd.trim()}
className="w-full h-8 rounded-md bg-primary text-primary-foreground text-xs hover:bg-primary/90 disabled:opacity-50"
>
{eterraConnecting ? "Se conecteaza..." : "Login"}
</button>
</div>
)}
</div>
</Card>
{/* Live stats cards */}
<StatCard
label="UAT-uri"
value={gisStats?.totalUats}
sub={gisStats?.countiesWithData ? `din ${gisStats.countiesWithData} judete` : undefined}
/>
<StatCard
label="Parcele"
value={gisStats?.totalTerenuri}
sub={gisStats?.totalEnriched ? `${gisStats.totalEnriched.toLocaleString("ro-RO")} enriched` : undefined}
/>
<StatCard
label="Cladiri"
value={gisStats?.totalCladiri}
sub={gisStats?.dbSizeMb ? `DB: ${gisStats.dbSizeMb >= 1024 ? `${(gisStats.dbSizeMb / 1024).toFixed(1)} GB` : `${gisStats.dbSizeMb} MB`}` : undefined}
/>
</div>
{gisStats?.lastSyncAt && (
<div className="text-xs text-muted-foreground text-right -mt-2">
Ultimul sync: {new Date(gisStats.lastSyncAt).toLocaleString("ro-RO")} auto-refresh 30s
</div>
)}
{/* Actions */}
<Card title="Actiuni">
{/* Tile infrastructure actions */}
<div className="space-y-4">
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Tile-uri</h3>
<div className="flex flex-wrap gap-3">
<ActionButton
label="Rebuild PMTiles"
description="Regenereaza tile-urile overview din PostGIS (~45-60 min)"
loading={actionLoading === "rebuild"}
onClick={triggerRebuild}
/>
<ActionButton
label="Warm Cache"
description="Pre-incarca tile-uri frecvente in nginx cache"
loading={actionLoading === "warm-cache"}
onClick={triggerWarmCache}
/>
</div>
</div>
{/* Sync actions */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sincronizare eTerra</h3>
<a
href="/sync-management"
className="text-xs text-primary hover:underline"
>
Gestioneaza reguli sync
</a>
</div>
<div className="flex flex-wrap gap-3">
<SyncTestButton
label="Sync All Romania"
description={`Toate judetele (${gisStats?.countiesWithData ?? "?"} jud, ${gisStats?.totalUats ?? "?"} UAT) — poate dura ore`}
siruta=""
mode="base"
includeNoGeometry={false}
actionKey="sync-all-counties"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
customEndpoint="/api/eterra/sync-all-counties"
/>
<SyncTestButton
label="Refresh ALL UATs"
description={`Delta sync pe toate ${gisStats?.totalUats ?? "?"} UAT-urile din DB`}
siruta=""
mode="base"
includeNoGeometry={false}
actionKey="refresh-all"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
customEndpoint="/api/eterra/refresh-all"
/>
<SyncTestButton
label="Test Delta — Cluj-Napoca"
description="Parcele + cladiri existente, fara magic (54975)"
siruta="54975"
mode="base"
includeNoGeometry={false}
actionKey="delta-cluj-base"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
/>
<SyncTestButton
label="Test Delta — Feleacu"
description="Magic + no-geom, cu enrichment (57582)"
siruta="57582"
mode="magic"
includeNoGeometry={true}
actionKey="delta-feleacu-magic"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
/>
</div>
</div>
{/* County sync */}
<div>
<h3 className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Sync pe judet</h3>
<div className="flex items-end gap-3">
<select
value={selectedCounty}
onChange={(e) => setSelectedCounty(e.target.value)}
className="h-9 w-52 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Alege judet...</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<SyncTestButton
label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"}
description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul"
siruta=""
mode="base"
includeNoGeometry={false}
actionKey="sync-county"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
customEndpoint="/api/eterra/sync-county"
customBody={{ county: selectedCounty }}
disabled={!selectedCounty}
/>
</div>
</div>
</div>
</Card>
{/* Activity Log */}
{logs.length > 0 && (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 bg-muted/50 border-b border-border">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Log activitate</span>
<button onClick={() => setLogs([])} className="text-xs text-muted-foreground hover:text-foreground transition-colors">Sterge</button>
</div>
<div className="max-h-56 overflow-y-auto">
{logs.map((log, i) => (
<div key={i} className="flex items-start gap-2 px-4 py-1.5 text-xs border-b border-border/30 last:border-0">
<span className="text-muted-foreground shrink-0 font-mono">{log.time}</span>
<span className={`shrink-0 ${
log.type === "ok" ? "text-green-400" :
log.type === "error" ? "text-red-400" :
log.type === "wait" ? "text-yellow-400" :
"text-blue-400"
}`}>
{log.type === "ok" ? "[OK]" : log.type === "error" ? "[ERR]" : log.type === "wait" ? "[...]" : "[i]"}
</span>
<span>{log.msg}</span>
</div>
))}
</div>
</div>
)}
{/* Config */}
<Card title="Configuratie">
{data?.config ? (
<div className="space-y-1 text-sm font-mono">
<div><span className="text-muted-foreground">MARTIN_URL:</span> {data.config.martinUrl}</div>
<div><span className="text-muted-foreground">PMTILES_URL:</span> {data.config.pmtilesUrl}</div>
<div><span className="text-muted-foreground">N8N_WEBHOOK:</span> {data.config.n8nWebhook}</div>
</div>
) : <Skeleton />}
</Card>
</div>
);
}
/* ---- Sub-components ---- */
function Card({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rounded-lg border border-border bg-card p-4">
<h2 className="text-sm font-semibold text-muted-foreground mb-3">{title}</h2>
{children}
</div>
);
}
function StatusBadge({ status, label }: { status: "ok" | "error" | "warn"; label: string }) {
const colors = {
ok: "bg-green-500/20 text-green-400",
error: "bg-red-500/20 text-red-400",
warn: "bg-yellow-500/20 text-yellow-400",
};
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium ${colors[status]}`}>
<span className={`w-2 h-2 rounded-full ${status === "ok" ? "bg-green-400" : status === "error" ? "bg-red-400" : "bg-yellow-400"}`} />
{label}
</span>
);
}
function Stat({ label, value }: { label: string; value?: string | number | null }) {
return (
<div>
<div className="text-muted-foreground text-xs">{label}</div>
<div className="font-medium">{value ?? "-"}</div>
</div>
);
}
function Skeleton() {
return <div className="h-16 rounded bg-muted/50 animate-pulse" />;
}
function StatCard({ label, value, sub }: { label: string; value?: number | null; sub?: string }) {
return (
<div className="rounded-lg border border-border bg-card p-4">
<div className="text-xs text-muted-foreground mb-1">{label}</div>
<div className="text-2xl font-bold tabular-nums">
{value != null ? value.toLocaleString("ro-RO") : <span className="text-muted-foreground">--</span>}
</div>
{sub && <div className="text-xs text-muted-foreground mt-1">{sub}</div>}
</div>
);
}
function ActionButton({ label, description, loading, onClick }: {
label: string; description: string; loading: boolean; onClick: () => void;
}) {
return (
<button
onClick={onClick}
disabled={loading}
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
>
<span className="font-medium text-sm">{loading ? "Se ruleaza..." : label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</button>
);
}
function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint, customBody, disabled }: {
label: string; description: string; siruta: string; mode: "base" | "magic";
includeNoGeometry: boolean; actionKey: string; actionLoading: string;
setActionLoading: (v: string) => void;
addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void;
pollRef: React.MutableRefObject<ReturnType<typeof setInterval> | null>;
customEndpoint?: string;
customBody?: Record<string, unknown>;
disabled?: boolean;
}) {
const startTimeRef = useRef<number>(0);
const formatElapsed = () => {
if (!startTimeRef.current) return "";
const s = Math.round((Date.now() - startTimeRef.current) / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
};
return (
<button
onClick={async () => {
setActionLoading(actionKey);
startTimeRef.current = Date.now();
addLog("info", `[${label}] Pornire...`);
try {
const endpoint = customEndpoint ?? "/api/eterra/sync-background";
const body = customEndpoint ? (customBody ?? {}) : { siruta, mode, includeNoGeometry };
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = await res.json() as { jobId?: string; error?: string };
if (!res.ok) {
addLog("error", `[${label}] ${d.error ?? "Eroare start"}`);
setActionLoading(""); return;
}
addLog("ok", `[${label}] Job: ${d.jobId?.slice(0, 8)}`);
const jid = d.jobId;
let lastPhase = "";
if (pollRef.current) clearInterval(pollRef.current);
pollRef.current = setInterval(async () => {
try {
const pr = await fetch(`/api/eterra/progress?jobId=${jid}`);
const pg = await pr.json() as { status?: string; phase?: string; downloaded?: number; total?: number; note?: string; message?: string };
const pct = pg.total ? Math.round(((pg.downloaded ?? 0) / pg.total) * 100) : 0;
const elapsed = formatElapsed();
const phaseChanged = pg.phase !== lastPhase;
if (phaseChanged) lastPhase = pg.phase ?? "";
// Only log phase changes and completion to keep log clean
if (phaseChanged || pg.status === "done" || pg.status === "error") {
const noteStr = pg.note ? `${pg.note}` : "";
addLog(
pg.status === "done" ? "ok" : pg.status === "error" ? "error" : "wait",
`[${label}] ${elapsed} | ${pg.phase ?? "..."} (${pct}%)${noteStr}`,
);
}
if (pg.status === "done" || pg.status === "error") {
const totalTime = formatElapsed();
addLog(pg.status === "done" ? "ok" : "error",
`[${label}] TOTAL: ${totalTime}${pg.message ? " — " + pg.message : ""}`,
);
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
setActionLoading("");
}
} catch { /* continue */ }
}, 3000);
setTimeout(() => {
if (pollRef.current) {
clearInterval(pollRef.current); pollRef.current = null;
addLog("error", `[${label}] Timeout 3h (${formatElapsed()})`);
setActionLoading("");
}
}, 3 * 60 * 60_000);
} catch { addLog("error", `[${label}] Eroare retea`); setActionLoading(""); }
}}
disabled={!!actionLoading || !!disabled}
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
>
<span className="font-medium text-sm">{actionLoading === actionKey ? "Se ruleaza..." : label}</span>
<span className="text-xs text-muted-foreground">{description}</span>
</button>
);
}
+847
View File
@@ -0,0 +1,847 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
/* ─── Types ──────────────────────────────────────────────── */
type SyncRule = {
id: string;
siruta: string | null;
county: string | null;
frequency: string;
syncTerenuri: boolean;
syncCladiri: boolean;
syncNoGeom: boolean;
syncEnrich: boolean;
priority: number;
enabled: boolean;
allowedHoursStart: number | null;
allowedHoursEnd: number | null;
allowedDays: string | null;
lastSyncAt: string | null;
lastSyncStatus: string | null;
lastSyncError: string | null;
nextDueAt: string | null;
label: string | null;
createdAt: string;
// enriched
uatName: string | null;
uatCount: number;
};
type SchedulerStats = {
totalRules: number;
activeRules: number;
dueNow: number;
withErrors: number;
frequencyDistribution: Record<string, number>;
totalCounties: number;
countiesWithRules: number;
};
type CountyOverview = {
county: string;
totalUats: number;
withRules: number;
defaultFreq: string | null;
};
/* ─── Constants ──────────────────────────────────────────── */
const FREQ_LABELS: Record<string, string> = {
"3x-daily": "3x/zi",
daily: "Zilnic",
weekly: "Saptamanal",
monthly: "Lunar",
manual: "Manual",
};
const FREQ_COLORS: Record<string, string> = {
"3x-daily": "bg-red-500/20 text-red-400",
daily: "bg-orange-500/20 text-orange-400",
weekly: "bg-blue-500/20 text-blue-400",
monthly: "bg-gray-500/20 text-gray-400",
manual: "bg-purple-500/20 text-purple-400",
};
/* ─── Page ───────────────────────────────────────────────── */
export default function SyncManagementPage() {
const [rules, setRules] = useState<SyncRule[]>([]);
const [globalDefault, setGlobalDefault] = useState("monthly");
const [stats, setStats] = useState<SchedulerStats | null>(null);
const [counties, setCounties] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [showAddDialog, setShowAddDialog] = useState(false);
const [filterCounty, setFilterCounty] = useState("");
const [filterFreq, setFilterFreq] = useState("");
const fetchRules = useCallback(async () => {
try {
const res = await fetch("/api/eterra/sync-rules");
if (res.ok) {
const d = (await res.json()) as { rules: SyncRule[]; globalDefault: string };
setRules(d.rules);
setGlobalDefault(d.globalDefault);
}
} catch { /* noop */ }
}, []);
const fetchStats = useCallback(async () => {
try {
const res = await fetch("/api/eterra/sync-rules/scheduler");
if (res.ok) {
const d = (await res.json()) as { stats: SchedulerStats };
setStats(d.stats);
}
} catch { /* noop */ }
}, []);
const fetchCounties = useCallback(async () => {
try {
const res = await fetch("/api/eterra/counties");
if (res.ok) {
const d = (await res.json()) as { counties: string[] };
setCounties(d.counties ?? []);
}
} catch { /* noop */ }
}, []);
useEffect(() => {
void Promise.all([fetchRules(), fetchStats(), fetchCounties()]).then(() =>
setLoading(false),
);
}, [fetchRules, fetchStats, fetchCounties]);
// Auto-refresh every 30s
useEffect(() => {
const iv = setInterval(() => {
void fetchRules();
void fetchStats();
}, 30_000);
return () => clearInterval(iv);
}, [fetchRules, fetchStats]);
const toggleEnabled = async (rule: SyncRule) => {
await fetch(`/api/eterra/sync-rules/${rule.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: !rule.enabled }),
});
void fetchRules();
};
const deleteRule = async (rule: SyncRule) => {
await fetch(`/api/eterra/sync-rules/${rule.id}`, { method: "DELETE" });
void fetchRules();
void fetchStats();
};
const updateGlobalDefault = async (freq: string) => {
await fetch("/api/eterra/sync-rules/global-default", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ frequency: freq }),
});
setGlobalDefault(freq);
};
const filteredRules = rules.filter((r) => {
if (filterCounty && r.county !== filterCounty && r.siruta) {
// For UAT rules, need to check if UAT is in filtered county — skip for now, show all UAT rules when county filter is set
return false;
}
if (filterCounty && r.county && r.county !== filterCounty) return false;
if (filterFreq && r.frequency !== filterFreq) return false;
return true;
});
// Build county overview from stats
const countyOverview: CountyOverview[] = counties.map((c) => {
const countyRule = rules.find((r) => r.county === c && !r.siruta);
const uatRules = rules.filter((r) => r.county === null && r.siruta !== null);
return {
county: c,
totalUats: 0, // filled by separate query if needed
withRules: (countyRule ? 1 : 0) + uatRules.length,
defaultFreq: countyRule?.frequency ?? null,
};
});
if (loading) {
return (
<div className="mx-auto max-w-6xl p-6">
<h1 className="text-2xl font-bold mb-4">Sync Management</h1>
<div className="h-64 rounded-lg bg-muted/50 animate-pulse" />
</div>
);
}
return (
<div className="mx-auto max-w-6xl p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Sync Management</h1>
<p className="text-sm text-muted-foreground">
Reguli de sincronizare eTerra {rules.length} reguli configurate
</p>
</div>
<a
href="/monitor"
className="px-4 py-2 rounded border border-border text-sm hover:bg-muted/50 transition-colors"
>
Monitor
</a>
</div>
{/* Global Default */}
<div className="rounded-lg border border-border bg-card p-4 flex items-center justify-between">
<div>
<span className="text-sm font-medium">Frecventa implicita (UAT-uri fara regula)</span>
<p className="text-xs text-muted-foreground mt-0.5">
Se aplica la UAT-urile care nu au regula specifica si nici regula de judet
</p>
</div>
<select
value={globalDefault}
onChange={(e) => void updateGlobalDefault(e.target.value)}
className="h-9 rounded-md border border-border bg-background px-3 text-sm"
>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
<Tabs defaultValue="rules">
<TabsList>
<TabsTrigger value="rules">Reguli ({rules.length})</TabsTrigger>
<TabsTrigger value="status">Status</TabsTrigger>
<TabsTrigger value="counties">Judete ({counties.length})</TabsTrigger>
</TabsList>
{/* ═══ RULES TAB ═══ */}
<TabsContent value="rules" className="space-y-4 mt-4">
{/* Filters + Add button */}
<div className="flex items-center gap-3 flex-wrap">
<select
value={filterCounty}
onChange={(e) => setFilterCounty(e.target.value)}
className="h-9 w-48 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Toate judetele</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<select
value={filterFreq}
onChange={(e) => setFilterFreq(e.target.value)}
className="h-9 w-40 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Toate frecventele</option>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
<div className="ml-auto">
<button
onClick={() => setShowAddDialog(true)}
className="h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90"
>
Adauga regula
</button>
</div>
</div>
{/* Rules table */}
{filteredRules.length === 0 ? (
<div className="rounded-lg border border-border bg-card p-8 text-center text-muted-foreground">
Nicio regula {filterCounty || filterFreq ? "pentru filtrul selectat" : "configurata"}. Apasa &quot;Adauga regula&quot; pentru a incepe.
</div>
) : (
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left py-2.5 px-3 font-medium">Scope</th>
<th className="text-left py-2.5 px-3 font-medium">Frecventa</th>
<th className="text-left py-2.5 px-3 font-medium">Pasi</th>
<th className="text-left py-2.5 px-3 font-medium">Prioritate</th>
<th className="text-left py-2.5 px-3 font-medium">Ultimul sync</th>
<th className="text-left py-2.5 px-3 font-medium">Urmatorul</th>
<th className="text-center py-2.5 px-3 font-medium">Activ</th>
<th className="text-right py-2.5 px-3 font-medium">Actiuni</th>
</tr>
</thead>
<tbody>
{filteredRules.map((r) => (
<RuleRow
key={r.id}
rule={r}
onToggle={() => void toggleEnabled(r)}
onDelete={() => void deleteRule(r)}
/>
))}
</tbody>
</table>
</div>
</div>
)}
</TabsContent>
{/* ═══ STATUS TAB ═══ */}
<TabsContent value="status" className="space-y-4 mt-4">
{stats && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard label="Total reguli" value={stats.totalRules} />
<StatCard label="Active" value={stats.activeRules} />
<StatCard
label="Scadente acum"
value={stats.dueNow}
highlight={stats.dueNow > 0}
/>
<StatCard
label="Cu erori"
value={stats.withErrors}
highlight={stats.withErrors > 0}
error
/>
</div>
<div className="rounded-lg border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
Distributie frecvente
</h3>
<div className="flex flex-wrap gap-3">
{Object.entries(FREQ_LABELS).map(([key, label]) => {
const count = stats.frequencyDistribution[key] ?? 0;
return (
<div
key={key}
className="flex items-center gap-2 px-3 py-2 rounded-md border border-border"
>
<FreqBadge freq={key} />
<span className="text-sm font-medium">{count}</span>
<span className="text-xs text-muted-foreground">{label}</span>
</div>
);
})}
</div>
</div>
<div className="rounded-lg border border-border bg-card p-4">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">
Acoperire
</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Judete cu reguli:</span>{" "}
<span className="font-medium">{stats.countiesWithRules} / {stats.totalCounties}</span>
</div>
<div>
<span className="text-muted-foreground">Default global:</span>{" "}
<FreqBadge freq={globalDefault} />
</div>
</div>
</div>
{/* Overdue rules */}
{stats.dueNow > 0 && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4">
<h3 className="text-sm font-semibold text-yellow-400 mb-2">
Reguli scadente ({stats.dueNow})
</h3>
<p className="text-xs text-muted-foreground">
Scheduler-ul va procesa aceste reguli la urmatorul tick.
(Scheduler-ul unificat va fi activat in Phase 2)
</p>
</div>
)}
</>
)}
</TabsContent>
{/* ═══ COUNTIES TAB ═══ */}
<TabsContent value="counties" className="space-y-4 mt-4">
<p className="text-sm text-muted-foreground">
Seteaza frecventa de sync la nivel de judet. UAT-urile cu regula proprie o vor suprascrie.
</p>
<div className="rounded-lg border border-border bg-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left py-2.5 px-3 font-medium">Judet</th>
<th className="text-left py-2.5 px-3 font-medium">Frecventa curenta</th>
<th className="text-right py-2.5 px-3 font-medium">Seteaza frecventa</th>
</tr>
</thead>
<tbody>
{counties.map((c) => (
<CountyRow
key={c}
county={c}
currentFreq={countyOverview.find((o) => o.county === c)?.defaultFreq ?? null}
globalDefault={globalDefault}
onSetFreq={async (freq) => {
await fetch("/api/eterra/sync-rules/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "set-county-frequency",
county: c,
frequency: freq,
}),
});
void fetchRules();
void fetchStats();
}}
/>
))}
</tbody>
</table>
</div>
</div>
</TabsContent>
</Tabs>
{/* Add Rule Dialog */}
{showAddDialog && (
<AddRuleDialog
counties={counties}
onClose={() => setShowAddDialog(false)}
onCreated={() => {
setShowAddDialog(false);
void fetchRules();
void fetchStats();
}}
/>
)}
</div>
);
}
/* ─── Sub-components ─────────────────────────────────────── */
function FreqBadge({ freq }: { freq: string }) {
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${FREQ_COLORS[freq] ?? "bg-muted text-muted-foreground"}`}>
{FREQ_LABELS[freq] ?? freq}
</span>
);
}
function StatCard({ label, value, highlight, error }: {
label: string; value: number; highlight?: boolean; error?: boolean;
}) {
return (
<div className={`rounded-lg border p-4 ${
highlight
? error ? "border-red-500/30 bg-red-500/5" : "border-yellow-500/30 bg-yellow-500/5"
: "border-border bg-card"
}`}>
<div className="text-xs text-muted-foreground mb-1">{label}</div>
<div className={`text-2xl font-bold tabular-nums ${
highlight ? (error ? "text-red-400" : "text-yellow-400") : ""
}`}>
{value}
</div>
</div>
);
}
function RuleRow({ rule, onToggle, onDelete }: {
rule: SyncRule; onToggle: () => void; onDelete: () => void;
}) {
const scope = rule.siruta
? (rule.uatName ?? rule.siruta)
: rule.county
? `Judet: ${rule.county}`
: "Global";
const scopeSub = rule.siruta
? `SIRUTA ${rule.siruta}`
: rule.uatCount > 0
? `${rule.uatCount} UAT-uri`
: null;
const isOverdue = rule.nextDueAt && new Date(rule.nextDueAt) < new Date();
return (
<tr className={`border-b border-border/50 ${!rule.enabled ? "opacity-50" : ""}`}>
<td className="py-2.5 px-3">
<div className="font-medium">{scope}</div>
{scopeSub && <div className="text-xs text-muted-foreground">{scopeSub}</div>}
{rule.label && <div className="text-xs text-blue-400 mt-0.5">{rule.label}</div>}
</td>
<td className="py-2.5 px-3">
<FreqBadge freq={rule.frequency} />
</td>
<td className="py-2.5 px-3">
<div className="flex gap-1">
{rule.syncTerenuri && <StepIcon label="T" title="Terenuri" />}
{rule.syncCladiri && <StepIcon label="C" title="Cladiri" />}
{rule.syncNoGeom && <StepIcon label="N" title="No-geom" />}
{rule.syncEnrich && <StepIcon label="E" title="Enrichment" />}
</div>
</td>
<td className="py-2.5 px-3 tabular-nums">{rule.priority}</td>
<td className="py-2.5 px-3">
{rule.lastSyncAt ? (
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full shrink-0 ${
rule.lastSyncStatus === "done" ? "bg-green-400" :
rule.lastSyncStatus === "error" ? "bg-red-400" : "bg-gray-400"
}`} />
<span className="text-xs">{relativeTime(rule.lastSyncAt)}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">Niciodata</span>
)}
</td>
<td className="py-2.5 px-3">
{rule.nextDueAt ? (
<span className={`text-xs ${isOverdue ? "text-yellow-400 font-medium" : "text-muted-foreground"}`}>
{isOverdue ? "Scadent" : relativeTime(rule.nextDueAt)}
</span>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
<td className="py-2.5 px-3 text-center">
<button
onClick={onToggle}
className={`w-8 h-5 rounded-full transition-colors relative ${
rule.enabled ? "bg-green-500" : "bg-muted"
}`}
>
<span className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
rule.enabled ? "left-3.5" : "left-0.5"
}`} />
</button>
</td>
<td className="py-2.5 px-3 text-right">
<button
onClick={onDelete}
className="text-xs text-muted-foreground hover:text-red-400 transition-colors"
>
Sterge
</button>
</td>
</tr>
);
}
function StepIcon({ label, title }: { label: string; title: string }) {
return (
<span
title={title}
className="w-5 h-5 rounded text-[10px] font-bold flex items-center justify-center bg-muted text-muted-foreground"
>
{label}
</span>
);
}
function CountyRow({ county, currentFreq, globalDefault, onSetFreq }: {
county: string;
currentFreq: string | null;
globalDefault: string;
onSetFreq: (freq: string) => Promise<void>;
}) {
const [saving, setSaving] = useState(false);
return (
<tr className="border-b border-border/50">
<td className="py-2.5 px-3 font-medium">{county}</td>
<td className="py-2.5 px-3">
{currentFreq ? (
<FreqBadge freq={currentFreq} />
) : (
<span className="text-xs text-muted-foreground">
Implicit ({FREQ_LABELS[globalDefault] ?? globalDefault})
</span>
)}
</td>
<td className="py-2.5 px-3 text-right">
<select
value={currentFreq ?? ""}
disabled={saving}
onChange={async (e) => {
if (!e.target.value) return;
setSaving(true);
await onSetFreq(e.target.value);
setSaving(false);
}}
className="h-8 rounded-md border border-border bg-background px-2 text-xs"
>
<option value="">Alege...</option>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</td>
</tr>
);
}
function AddRuleDialog({ counties, onClose, onCreated }: {
counties: string[];
onClose: () => void;
onCreated: () => void;
}) {
const [ruleType, setRuleType] = useState<"uat" | "county">("county");
const [siruta, setSiruta] = useState("");
const [county, setCounty] = useState("");
const [frequency, setFrequency] = useState("daily");
const [syncEnrich, setSyncEnrich] = useState(false);
const [syncNoGeom, setSyncNoGeom] = useState(false);
const [priority, setPriority] = useState(5);
const [label, setLabel] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
// UAT search — load all once, filter client-side
const [uatSearch, setUatSearch] = useState("");
const [allUats, setAllUats] = useState<Array<{ siruta: string; name: string }>>([]);
const [uatName, setUatName] = useState("");
useEffect(() => {
void (async () => {
try {
const res = await fetch("/api/eterra/uats");
if (res.ok) {
const d = (await res.json()) as { uats?: Array<{ siruta: string; name: string }> };
setAllUats(d.uats ?? []);
}
} catch { /* noop */ }
})();
}, []);
const uatResults = uatSearch.length >= 2
? allUats
.filter((u) => {
const q = uatSearch.toLowerCase();
return u.name.toLowerCase().includes(q) || u.siruta.includes(q);
})
.slice(0, 10)
: [];
const handleSubmit = async () => {
setError("");
setSaving(true);
const body: Record<string, unknown> = {
frequency,
syncEnrich,
syncNoGeom,
priority,
label: label.trim() || null,
};
if (ruleType === "uat") {
if (!siruta) { setError("Selecteaza un UAT"); setSaving(false); return; }
body.siruta = siruta;
} else {
if (!county) { setError("Selecteaza un judet"); setSaving(false); return; }
body.county = county;
}
try {
const res = await fetch("/api/eterra/sync-rules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const d = (await res.json()) as { rule?: SyncRule; error?: string };
if (!res.ok) {
setError(d.error ?? "Eroare");
setSaving(false);
return;
}
onCreated();
} catch {
setError("Eroare retea");
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-card border border-border rounded-lg p-6 w-full max-w-md space-y-4"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold">Adauga regula de sync</h2>
{/* Rule type toggle */}
<div className="flex gap-2">
<button
onClick={() => setRuleType("county")}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
ruleType === "county" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
Judet
</button>
<button
onClick={() => setRuleType("uat")}
className={`flex-1 py-2 rounded-md text-sm font-medium transition-colors ${
ruleType === "uat" ? "bg-primary text-primary-foreground" : "bg-muted"
}`}
>
UAT specific
</button>
</div>
{/* Scope selection */}
{ruleType === "county" ? (
<select
value={county}
onChange={(e) => setCounty(e.target.value)}
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Alege judet...</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
) : (
<div className="relative">
<input
type="text"
value={uatSearch}
onChange={(e) => { setUatSearch(e.target.value); setSiruta(""); setUatName(""); }}
placeholder="Cauta UAT (nume sau SIRUTA)..."
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
/>
{uatName && (
<div className="text-xs text-green-400 mt-1">
Selectat: {uatName} ({siruta})
</div>
)}
{uatResults.length > 0 && !siruta && (
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border rounded-md shadow-lg max-h-40 overflow-y-auto z-10">
{uatResults.map((u) => (
<button
key={u.siruta}
onClick={() => {
setSiruta(u.siruta);
setUatName(u.name);
setUatSearch("");
}}
className="w-full text-left px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
{u.name} <span className="text-muted-foreground">({u.siruta})</span>
</button>
))}
</div>
)}
</div>
)}
{/* Frequency */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Frecventa</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
>
{Object.entries(FREQ_LABELS).map(([k, v]) => (
<option key={k} value={k}>{v}</option>
))}
</select>
</div>
{/* Sync steps */}
<div className="flex gap-4">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={syncEnrich}
onChange={(e) => setSyncEnrich(e.target.checked)}
className="rounded"
/>
Enrichment
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={syncNoGeom}
onChange={(e) => setSyncNoGeom(e.target.checked)}
className="rounded"
/>
No-geom parcels
</label>
</div>
{/* Priority */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Prioritate (1=cea mai mare, 10=cea mai mica)
</label>
<input
type="number"
min={1}
max={10}
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
className="h-9 w-20 rounded-md border border-border bg-background px-3 text-sm"
/>
</div>
{/* Label */}
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="Nota (optional)"
className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm"
/>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex justify-end gap-3 pt-2">
<button
onClick={onClose}
className="px-4 py-2 rounded-md border border-border text-sm hover:bg-muted/50"
>
Anuleaza
</button>
<button
onClick={() => void handleSubmit()}
disabled={saving}
className="px-4 py-2 rounded-md bg-primary text-primary-foreground text-sm hover:bg-primary/90 disabled:opacity-50"
>
{saving ? "Se salveaza..." : "Creeaza"}
</button>
</div>
</div>
</div>
);
}
/* ─── Helpers ────────────────────────────────────────────── */
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const abs = Math.abs(diff);
const future = diff < 0;
const s = Math.floor(abs / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
let str: string;
if (d > 0) str = `${d}z`;
else if (h > 0) str = `${h}h`;
else if (m > 0) str = `${m}m`;
else str = `${s}s`;
return future ? `in ${str}` : `acum ${str}`;
}
+623
View File
@@ -0,0 +1,623 @@
"use client";
import { useState, useEffect, useCallback, useDeferredValue, useRef } from "react";
import {
Loader2,
RefreshCw,
Plus,
Trash2,
RotateCcw,
Moon,
CheckCircle2,
XCircle,
Clock,
MapPin,
Search,
AlertTriangle,
WifiOff,
Activity,
Play,
Download,
} from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import { cn } from "@/shared/lib/utils";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
type StepStatus = "pending" | "done" | "error";
type CityState = {
siruta: string;
name: string;
county: string;
priority: number;
steps: Record<StepName, StepStatus>;
lastActivity?: string;
errorMessage?: string;
dbStats?: {
terenuri: number;
cladiri: number;
total: number;
enriched: number;
};
};
type QueueState = {
cities: CityState[];
lastSessionDate?: string;
totalSessions: number;
completedCycles: number;
};
type SyncStatus = "running" | "error" | "waiting" | "idle";
type CurrentActivity = {
city: string;
step: string;
startedAt: string;
} | null;
const STEPS: StepName[] = [
"sync_terenuri",
"sync_cladiri",
"import_nogeom",
"enrich",
];
const STEP_LABELS: Record<StepName, string> = {
sync_terenuri: "Terenuri",
sync_cladiri: "Cladiri",
import_nogeom: "No-geom",
enrich: "Enrichment",
};
/** Auto-poll intervals */
const POLL_ACTIVE_MS = 15_000; // 15s when running
const POLL_IDLE_MS = 60_000; // 60s when idle
/* ------------------------------------------------------------------ */
/* Page */
/* ------------------------------------------------------------------ */
export default function WeekendDeepSyncPage() {
const [state, setState] = useState<QueueState | null>(null);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
// Live status
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
const [currentActivity, setCurrentActivity] = useState<CurrentActivity>(null);
const [inWeekendWindow, setInWeekendWindow] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
// UAT autocomplete for adding cities
type UatEntry = { siruta: string; name: string; county?: string };
const [uatData, setUatData] = useState<UatEntry[]>([]);
const [uatQuery, setUatQuery] = useState("");
const [uatResults, setUatResults] = useState<UatEntry[]>([]);
const [showUatResults, setShowUatResults] = useState(false);
const uatRef = useRef<HTMLDivElement>(null);
const fetchState = useCallback(async () => {
try {
const res = await fetch("/api/eterra/weekend-sync");
if (!res.ok) {
setFetchError(`Server: ${res.status} ${res.statusText}`);
setLoading(false);
return;
}
const data = (await res.json()) as {
state: QueueState | null;
syncStatus?: SyncStatus;
currentActivity?: CurrentActivity;
inWeekendWindow?: boolean;
};
setState(data.state);
setSyncStatus(data.syncStatus ?? "idle");
setCurrentActivity(data.currentActivity ?? null);
setInWeekendWindow(data.inWeekendWindow ?? false);
setFetchError(null);
setLastRefresh(new Date());
} catch (err) {
const msg = err instanceof Error ? err.message : "Conexiune esuata";
setFetchError(msg);
}
setLoading(false);
}, []);
// Initial load + UAT list
useEffect(() => {
void fetchState();
fetch("/api/eterra/uats")
.then((r) => r.json())
.then((data: { uats?: UatEntry[] }) => {
if (data.uats) setUatData(data.uats);
})
.catch(() => {
fetch("/uat.json")
.then((r) => r.json())
.then((fallback: UatEntry[]) => setUatData(fallback))
.catch(() => {});
});
}, [fetchState]);
// Auto-poll: 15s when running, 60s otherwise
useEffect(() => {
const interval = syncStatus === "running" ? POLL_ACTIVE_MS : POLL_IDLE_MS;
const timer = setInterval(() => void fetchState(), interval);
return () => clearInterval(timer);
}, [fetchState, syncStatus]);
// UAT autocomplete filter
const normalizeText = (text: string) =>
text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim();
const deferredUatQuery = useDeferredValue(uatQuery);
useEffect(() => {
const raw = deferredUatQuery.trim();
if (raw.length < 2) { setUatResults([]); return; }
const isDigit = /^\d+$/.test(raw);
const query = normalizeText(raw);
const nameMatches: UatEntry[] = [];
const countyOnly: UatEntry[] = [];
for (const item of uatData) {
// Skip cities already in queue
if (state?.cities.some((c) => c.siruta === item.siruta)) continue;
if (isDigit) {
if (item.siruta.startsWith(raw)) nameMatches.push(item);
} else {
if (normalizeText(item.name).includes(query)) nameMatches.push(item);
else if (item.county && normalizeText(item.county).includes(query))
countyOnly.push(item);
}
}
setUatResults([...nameMatches, ...countyOnly].slice(0, 10));
}, [deferredUatQuery, uatData, state?.cities]);
const doAction = async (body: Record<string, unknown>) => {
setActionLoading(true);
try {
const res = await fetch("/api/eterra/weekend-sync", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
setFetchError(data.error ?? `Eroare: ${res.status}`);
} else {
setFetchError(null);
}
await fetchState();
} catch (err) {
const msg = err instanceof Error ? err.message : "Actiune esuata";
setFetchError(msg);
}
setActionLoading(false);
};
const handleAddUat = async (uat: UatEntry) => {
await doAction({
action: "add",
siruta: uat.siruta,
name: uat.name,
county: uat.county ?? "",
priority: 3,
});
setUatQuery("");
setUatResults([]);
setShowUatResults(false);
};
if (loading) {
return (
<div className="mx-auto max-w-4xl py-12 text-center text-muted-foreground">
<Loader2 className="h-8 w-8 mx-auto mb-3 animate-spin opacity-40" />
<p>Se incarca...</p>
</div>
);
}
const cities = state?.cities ?? [];
const totalSteps = cities.length * STEPS.length;
const doneSteps = cities.reduce(
(sum, c) => sum + STEPS.filter((s) => c.steps[s] === "done").length,
0,
);
const progressPct = totalSteps > 0 ? Math.round((doneSteps / totalSteps) * 100) : 0;
return (
<div className="mx-auto max-w-4xl space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<Moon className="h-6 w-6 text-indigo-500" />
Weekend Deep Sync
</h1>
<p className="text-muted-foreground text-sm">
Sincronizare Magic completa pentru municipii mari Vin/Sam/Dum
23:00-04:00
</p>
</div>
<div className="flex items-center gap-2">
{lastRefresh && (
<span className="text-[10px] text-muted-foreground">
{lastRefresh.toLocaleTimeString("ro-RO", { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
</span>
)}
{syncStatus !== "running" && (
<>
<Button
variant="outline"
size="sm"
disabled={actionLoading}
onClick={() => {
if (window.confirm("Descarca terenuri + cladiri pentru orasele pending?"))
void doAction({ action: "trigger", onlySteps: ["sync_terenuri", "sync_cladiri"] });
}}
>
<Download className="h-4 w-4 mr-1" />
Descarca parcele
</Button>
<Button
variant="outline"
size="sm"
className="text-indigo-600 border-indigo-300 hover:bg-indigo-50 dark:text-indigo-400 dark:border-indigo-700 dark:hover:bg-indigo-950/30"
disabled={actionLoading}
onClick={() => {
if (window.confirm("Pornesti sincronizarea completa? Va procesa toti pasii pending."))
void doAction({ action: "trigger" });
}}
>
<Play className="h-4 w-4 mr-1" />
Sync complet
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => void fetchState()}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
Reincarca
</Button>
</div>
</div>
{/* Connection error banner */}
{fetchError && (
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
<WifiOff className="h-4 w-4 shrink-0" />
<span>{fetchError}</span>
</div>
)}
{/* Live status banner */}
{syncStatus === "running" && (
<div className="flex items-center gap-2 rounded-md border border-indigo-200 bg-indigo-50 px-4 py-2.5 text-sm text-indigo-700 dark:border-indigo-800 dark:bg-indigo-950/30 dark:text-indigo-400">
<Activity className="h-4 w-4 shrink-0 animate-pulse" />
<span className="font-medium">Sincronizarea ruleaza</span>
{currentActivity && (
<span>
{currentActivity.city} / {STEP_LABELS[currentActivity.step as StepName] ?? currentActivity.step}
</span>
)}
<Loader2 className="h-3.5 w-3.5 ml-auto animate-spin opacity-50" />
</div>
)}
{syncStatus === "error" && (
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50 px-4 py-2.5 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-400">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span className="font-medium">Erori in ultimul ciclu</span>
<span>
{cities.filter((c) => STEPS.some((s) => c.steps[s] === "error")).map((c) => c.name).join(", ")}
</span>
</div>
)}
{syncStatus === "waiting" && !fetchError && (
<div className="flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50 px-4 py-2.5 text-sm text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-400">
<Clock className="h-4 w-4 shrink-0" />
<span>Fereastra weekend activa se asteapta urmatorul slot de procesare</span>
</div>
)}
{/* Stats bar */}
<Card>
<CardContent className="py-3 px-4">
<div className="flex items-center gap-4 flex-wrap text-sm">
<span>
<span className="font-semibold">{cities.length}</span> orase in
coada
</span>
<span>
Progres ciclu:{" "}
<span className="font-semibold">{doneSteps}/{totalSteps}</span>{" "}
pasi ({progressPct}%)
</span>
{state?.totalSessions != null && state.totalSessions > 0 && (
<span className="text-muted-foreground">
{state.totalSessions} sesiuni | {state.completedCycles ?? 0}{" "}
cicluri complete
</span>
)}
{state?.lastSessionDate && (
<span className="text-muted-foreground">
Ultima sesiune: {state.lastSessionDate}
</span>
)}
</div>
{totalSteps > 0 && (
<div className="h-2 w-full rounded-full bg-muted mt-2">
<div
className="h-2 rounded-full bg-indigo-500 transition-all duration-300"
style={{ width: `${Math.max(1, progressPct)}%` }}
/>
</div>
)}
</CardContent>
</Card>
{/* City cards */}
<div className="space-y-3">
{cities
.sort((a, b) => a.priority - b.priority)
.map((city) => {
const doneCount = STEPS.filter(
(s) => city.steps[s] === "done",
).length;
const hasError = STEPS.some((s) => city.steps[s] === "error");
const allDone = doneCount === STEPS.length;
const isActive = currentActivity?.city === city.name;
return (
<Card
key={city.siruta}
className={cn(
"transition-colors",
isActive && "border-indigo-300 ring-1 ring-indigo-200 dark:border-indigo-700 dark:ring-indigo-800",
allDone && !isActive && "border-emerald-200 dark:border-emerald-800",
hasError && !isActive && "border-rose-200 dark:border-rose-800",
)}
>
<CardContent className="py-3 px-4 space-y-2">
{/* City header */}
<div className="flex items-center gap-2 flex-wrap">
<MapPin className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold">{city.name}</span>
{city.county && (
<span className="text-xs text-muted-foreground">
jud. {city.county}
</span>
)}
<Badge
variant="outline"
className="text-[10px] font-mono"
>
{city.siruta}
</Badge>
<Badge
variant="outline"
className="text-[10px]"
>
P{city.priority}
</Badge>
{/* Status icon */}
{allDone ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500 ml-auto" />
) : hasError ? (
<XCircle className="h-4 w-4 text-rose-500 ml-auto" />
) : doneCount > 0 ? (
<Clock className="h-4 w-4 text-amber-500 ml-auto" />
) : null}
{/* Actions */}
<div className="flex gap-1 ml-auto">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px]"
disabled={actionLoading}
onClick={() =>
void doAction({
action: "reset",
siruta: city.siruta,
})
}
title="Reseteaza progresul"
>
<RotateCcw className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] text-destructive"
disabled={actionLoading}
onClick={() => {
if (
window.confirm(
`Stergi ${city.name} din coada?`,
)
)
void doAction({
action: "remove",
siruta: city.siruta,
});
}}
title="Sterge din coada"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* Steps progress */}
<div className="flex gap-1.5">
{STEPS.map((step) => {
const status = city.steps[step];
const isRunning = isActive && currentActivity?.step === step;
return (
<div
key={step}
className={cn(
"flex-1 rounded-md border px-2 py-1.5 text-center text-[11px] transition-colors",
isRunning &&
"bg-indigo-50 border-indigo-300 text-indigo-700 dark:bg-indigo-950/30 dark:border-indigo-700 dark:text-indigo-400 animate-pulse",
!isRunning && status === "done" &&
"bg-emerald-50 border-emerald-200 text-emerald-700 dark:bg-emerald-950/30 dark:border-emerald-800 dark:text-emerald-400",
!isRunning && status === "error" &&
"bg-rose-50 border-rose-200 text-rose-700 dark:bg-rose-950/30 dark:border-rose-800 dark:text-rose-400",
!isRunning && status === "pending" &&
"bg-muted/30 border-muted text-muted-foreground",
)}
>
{isRunning && <Loader2 className="h-3 w-3 inline mr-1 animate-spin" />}
{STEP_LABELS[step]}
</div>
);
})}
</div>
{/* DB stats + error */}
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
{city.dbStats && city.dbStats.total > 0 && (
<>
<span>
DB: {city.dbStats.terenuri.toLocaleString("ro")} ter.
+ {city.dbStats.cladiri.toLocaleString("ro")} clad.
</span>
{city.dbStats.enriched > 0 && (
<span className="text-teal-600 dark:text-teal-400">
{city.dbStats.enriched.toLocaleString("ro")}{" "}
enriched
</span>
)}
</>
)}
{city.lastActivity && (
<span>
Ultima activitate:{" "}
{new Date(city.lastActivity).toLocaleString("ro-RO", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
{city.errorMessage && (
<span className="text-rose-500 truncate max-w-[300px]">
{city.errorMessage}
</span>
)}
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Add city — UAT autocomplete */}
<Card>
<CardContent className="py-4">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Plus className="h-4 w-4" />
Adauga oras in coada
</h3>
<div className="relative" ref={uatRef}>
<div className="relative">
<Search className="absolute left-2.5 top-2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Cauta UAT — scrie nume sau cod SIRUTA..."
value={uatQuery}
onChange={(e) => {
setUatQuery(e.target.value);
setShowUatResults(true);
}}
onFocus={() => setShowUatResults(true)}
onBlur={() => setTimeout(() => setShowUatResults(false), 150)}
className="pl-9 h-9"
autoComplete="off"
/>
</div>
{showUatResults && uatResults.length > 0 && (
<div className="absolute z-50 mt-1 w-full rounded-md border bg-popover shadow-lg max-h-60 overflow-auto">
{uatResults.map((item) => (
<button
key={item.siruta}
type="button"
className="flex w-full items-center justify-between px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
onMouseDown={(e) => {
e.preventDefault();
void handleAddUat(item);
}}
>
<span className="flex items-center gap-1.5">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium">{item.name}</span>
<span className="text-muted-foreground">
({item.siruta})
</span>
{item.county && (
<span className="text-muted-foreground">
jud. {item.county}
</span>
)}
</span>
<Plus className="h-3.5 w-3.5 text-muted-foreground" />
</button>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Reset all button */}
{cities.length > 0 && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="text-xs"
disabled={actionLoading}
onClick={() => {
if (
window.confirm(
"Resetezi progresul pentru TOATE orasele? Se va reporni ciclul de la zero.",
)
)
void doAction({ action: "reset_all" });
}}
>
<RotateCcw className="h-3 w-3 mr-1" />
Reseteaza tot
</Button>
</div>
)}
{/* Info footer */}
<div className="text-xs text-muted-foreground space-y-1 pb-4">
<p>
Sincronizarea ruleaza automat Vineri, Sambata si Duminica noaptea
(23:00-04:00). Procesarea e intercalata intre orase si se reia de
unde a ramas. Pagina se actualizeaza automat la fiecare {syncStatus === "running" ? "15" : "60"} secunde.
</p>
<p>
Prioritate: P1 = primele procesate, P2 = urmatoarele, P3 = adaugate
manual. In cadrul aceleiasi prioritati, ordinea e aleatorie.
</p>
</div>
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
import { NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
import {
getLayerFreshness,
isFresh,
} from "@/modules/parcel-sync/services/enrich-service";
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 300; // 5 min max — N8N handles overall timeout
const prisma = new PrismaClient();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
type UatRefreshResult = {
siruta: string;
uatName: string;
action: "synced" | "fresh" | "error";
reason?: string;
terenuri?: { new: number; removed: number };
cladiri?: { new: number; removed: number };
durationMs?: number;
};
/**
* POST /api/eterra/auto-refresh
*
* Server-to-server endpoint called by N8N cron to keep DB data fresh.
* Auth: Authorization: Bearer <NOTIFICATION_CRON_SECRET>
*
* Query params:
* ?maxUats=5 — max UATs to process per run (default 5, max 10)
* ?maxAgeHours=168 — freshness threshold in hours (default 168 = 7 days)
* ?forceFullSync=true — force full re-download (for weekly deep sync)
* ?includeEnrichment=true — re-enrich UATs with partial enrichment
*/
export async function POST(request: Request) {
// ── Auth ──
const secret = process.env.NOTIFICATION_CRON_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "NOTIFICATION_CRON_SECRET not configured" },
{ status: 500 },
);
}
const authHeader = request.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
if (token !== secret) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ── Parse params ──
const url = new URL(request.url);
const maxUats = Math.min(
Number(url.searchParams.get("maxUats") ?? "5") || 5,
10,
);
const maxAgeHours =
Number(url.searchParams.get("maxAgeHours") ?? "168") || 168;
const forceFullSync = url.searchParams.get("forceFullSync") === "true";
const includeEnrichment =
url.searchParams.get("includeEnrichment") === "true";
// ── Credentials ──
const username = process.env.ETERRA_USERNAME;
const password = process.env.ETERRA_PASSWORD;
if (!username || !password) {
return NextResponse.json(
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD not configured" },
{ status: 500 },
);
}
// ── Health check ──
const health = await checkEterraHealthNow();
if (!health.available) {
return NextResponse.json({
processed: 0,
skipped: 0,
errors: 0,
duration: "0s",
message: `eTerra indisponibil: ${health.message ?? "maintenance"}`,
details: [],
});
}
// ── Find UATs with data in DB ──
const uatGroups = await prisma.gisFeature.groupBy({
by: ["siruta"],
_count: { id: true },
});
// Resolve UAT names
const sirutas = uatGroups.map((g) => g.siruta);
const uatRecords = await prisma.gisUat.findMany({
where: { siruta: { in: sirutas } },
select: { siruta: true, name: true },
});
const nameMap = new Map(uatRecords.map((u) => [u.siruta, u.name]));
// ── Check freshness per UAT ──
type UatCandidate = {
siruta: string;
uatName: string;
featureCount: number;
terenuriStale: boolean;
cladiriStale: boolean;
enrichedCount: number;
totalCount: number;
};
const stale: UatCandidate[] = [];
const fresh: string[] = [];
for (const group of uatGroups) {
const sir = group.siruta;
const [tStatus, cStatus] = await Promise.all([
getLayerFreshness(sir, "TERENURI_ACTIVE"),
getLayerFreshness(sir, "CLADIRI_ACTIVE"),
]);
const tFresh = isFresh(tStatus.lastSynced, maxAgeHours);
const cFresh = isFresh(cStatus.lastSynced, maxAgeHours);
if (forceFullSync || !tFresh || !cFresh) {
stale.push({
siruta: sir,
uatName: nameMap.get(sir) ?? sir,
featureCount: group._count.id,
terenuriStale: !tFresh || forceFullSync,
cladiriStale: !cFresh || forceFullSync,
enrichedCount: tStatus.enrichedCount,
totalCount: tStatus.featureCount + cStatus.featureCount,
});
} else {
fresh.push(sir);
}
}
// Shuffle stale UATs so we don't always process the same ones first
for (let i = stale.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[stale[i]!, stale[j]!] = [stale[j]!, stale[i]!];
}
const toProcess = stale.slice(0, maxUats);
const startTime = Date.now();
const details: UatRefreshResult[] = [];
let errorCount = 0;
// ── Process stale UATs ──
for (let idx = 0; idx < toProcess.length; idx++) {
const uat = toProcess[idx]!;
// Random delay between UATs (30-120s) to spread load
if (idx > 0) {
const delay = 30_000 + Math.random() * 90_000;
await sleep(delay);
}
const uatStart = Date.now();
console.log(
`[auto-refresh] Processing UAT ${uat.siruta} (${uat.uatName})...`,
);
try {
let terenuriResult = { newFeatures: 0, removedFeatures: 0 };
let cladiriResult = { newFeatures: 0, removedFeatures: 0 };
if (uat.terenuriStale) {
const res = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
uatName: uat.uatName,
forceFullSync,
});
terenuriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
}
if (uat.cladiriStale) {
const res = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
uatName: uat.uatName,
forceFullSync,
});
cladiriResult = { newFeatures: res.newFeatures, removedFeatures: res.removedFeatures };
}
// Optional: re-enrich if partial enrichment
if (includeEnrichment && uat.enrichedCount < uat.totalCount) {
try {
const { EterraClient } = await import(
"@/modules/parcel-sync/services/eterra-client"
);
const { enrichFeatures } = await import(
"@/modules/parcel-sync/services/enrich-service"
);
const enrichClient = await EterraClient.create(username, password);
await enrichFeatures(enrichClient, uat.siruta);
} catch (enrichErr) {
console.warn(
`[auto-refresh] Enrichment failed for ${uat.siruta}:`,
enrichErr instanceof Error ? enrichErr.message : enrichErr,
);
}
}
const durationMs = Date.now() - uatStart;
console.log(
`[auto-refresh] UAT ${uat.siruta}: terenuri +${terenuriResult.newFeatures}/-${terenuriResult.removedFeatures}, cladiri +${cladiriResult.newFeatures}/-${cladiriResult.removedFeatures} (${(durationMs / 1000).toFixed(1)}s)`,
);
details.push({
siruta: uat.siruta,
uatName: uat.uatName,
action: "synced",
terenuri: { new: terenuriResult.newFeatures, removed: terenuriResult.removedFeatures },
cladiri: { new: cladiriResult.newFeatures, removed: cladiriResult.removedFeatures },
durationMs,
});
} catch (error) {
errorCount++;
const msg = error instanceof Error ? error.message : "Unknown error";
console.error(`[auto-refresh] Error on UAT ${uat.siruta}: ${msg}`);
details.push({
siruta: uat.siruta,
uatName: uat.uatName,
action: "error",
reason: msg,
durationMs: Date.now() - uatStart,
});
}
}
const totalDuration = Date.now() - startTime;
const durationStr =
totalDuration > 60_000
? `${Math.floor(totalDuration / 60_000)}m ${Math.round((totalDuration % 60_000) / 1000)}s`
: `${Math.round(totalDuration / 1000)}s`;
console.log(
`[auto-refresh] Completed ${toProcess.length}/${stale.length} UATs, ${errorCount} errors (${durationStr})`,
);
return NextResponse.json({
processed: toProcess.length,
skipped: fresh.length,
staleTotal: stale.length,
errors: errorCount,
duration: durationStr,
details,
});
}
+26
View File
@@ -0,0 +1,26 @@
/**
* GET /api/eterra/counties
*
* Returns distinct county names from GisUat, sorted alphabetically.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const rows = await prisma.gisUat.findMany({
where: { county: { not: null } },
select: { county: true },
distinct: ["county"],
orderBy: { county: "asc" },
});
const counties = rows.map((r) => r.county).filter(Boolean) as string[];
return NextResponse.json({ counties });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare la interogare judete";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+187
View File
@@ -0,0 +1,187 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
/**
* GET /api/eterra/debug-fields?siruta=161829&cadRef=77102
*
* Diagnostic endpoint — shows all available fields from eTerra + local DB
* for a specific parcel and its buildings.
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const siruta = url.searchParams.get("siruta") ?? "161829";
const cadRef = url.searchParams.get("cadRef") ?? "77102";
const username = process.env.ETERRA_USERNAME;
const password = process.env.ETERRA_PASSWORD;
if (!username || !password) {
return NextResponse.json({ error: "ETERRA creds missing" }, { status: 500 });
}
const result: Record<string, unknown> = {
query: { siruta, cadRef },
timestamp: new Date().toISOString(),
};
try {
const client = await EterraClient.create(username, password);
// 1. GIS layer: TERENURI_ACTIVE — raw attributes
const terenuri = await client.listLayerByWhere(
{ id: "TERENURI_ACTIVE", name: "TERENURI_ACTIVE", endpoint: "aut" },
`ADMIN_UNIT_ID=${siruta} AND IS_ACTIVE=1 AND NATIONAL_CADASTRAL_REFERENCE='${cadRef}'`,
{ limit: 1, outFields: "*" },
);
const parcelAttrs = terenuri[0]?.attributes ?? null;
result.gis_parcela = {
found: !!parcelAttrs,
fields: parcelAttrs
? Object.entries(parcelAttrs)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => ({ field: k, value: v, type: typeof v }))
: [],
};
// 2. GIS layer: CLADIRI_ACTIVE — buildings on this parcel
const cladiri = await client.listLayerByWhere(
{ id: "CLADIRI_ACTIVE", name: "CLADIRI_ACTIVE", endpoint: "aut" },
`ADMIN_UNIT_ID=${siruta} AND IS_ACTIVE=1 AND NATIONAL_CADASTRAL_REFERENCE LIKE '${cadRef}-%'`,
{ limit: 20, outFields: "*" },
);
result.gis_cladiri = {
count: cladiri.length,
buildings: cladiri.map((c) => {
const a = c.attributes;
return {
cadastralRef: a.NATIONAL_CADASTRAL_REFERENCE,
fields: Object.entries(a)
.sort(([x], [y]) => x.localeCompare(y))
.filter(([, v]) => v != null && v !== "" && v !== 0)
.map(([k, v]) => ({ field: k, value: v, type: typeof v })),
};
}),
};
// 3. Immovable details (enrichment source)
const immId = parcelAttrs?.IMMOVABLE_ID;
const wsId = parcelAttrs?.WORKSPACE_ID;
if (immId && wsId) {
try {
const details = await client.fetchImmovableParcelDetails(
wsId as string | number,
immId as string | number,
);
result.immovable_parcel_details = {
count: details.length,
items: details,
};
} catch (e) {
result.immovable_parcel_details = {
error: e instanceof Error ? e.message : String(e),
};
}
// 4. Immovable list entry (address source)
try {
const listResponse = await client.fetchImmovableListByAdminUnit(
wsId as number,
siruta,
0,
5,
true,
);
const items = (listResponse?.content ?? []) as Record<string, unknown>[];
// Find our specific immovable
const match = items.find(
(item) => String(item.immovablePk) === String(immId) ||
String(item.identifierDetails ?? "").includes(cadRef),
);
result.immovable_list_entry = {
totalInUat: listResponse?.totalElements ?? "?",
matchFound: !!match,
entry: match ?? null,
note: "Acest obiect contine campul immovableAddresses cu adresa completa",
};
} catch (e) {
result.immovable_list_entry = {
error: e instanceof Error ? e.message : String(e),
};
}
// 5. Documentation data (owner source)
try {
const docResponse = await client.fetchDocumentationData(
wsId as number,
[String(immId)],
);
const immovables = docResponse?.immovables ?? [];
const regs = docResponse?.partTwoRegs ?? [];
result.documentation_data = {
immovablesCount: immovables.length,
immovables: immovables.slice(0, 3),
registrationsCount: regs.length,
registrations: regs.slice(0, 10),
note: "partTwoRegs contine proprietarii (nodeType=P, nodeStatus=-1=radiat)",
};
} catch (e) {
result.documentation_data = {
error: e instanceof Error ? e.message : String(e),
};
}
}
// 6. Local DB data (what we have stored)
const dbParcel = await prisma.gisFeature.findFirst({
where: { layerId: "TERENURI_ACTIVE", siruta, cadastralRef: cadRef },
select: {
objectId: true,
cadastralRef: true,
areaValue: true,
isActive: true,
enrichment: true,
enrichedAt: true,
geometrySource: true,
},
});
const dbBuildings = await prisma.gisFeature.findMany({
where: {
layerId: "CLADIRI_ACTIVE",
siruta,
cadastralRef: { startsWith: `${cadRef}-` },
},
select: {
objectId: true,
cadastralRef: true,
areaValue: true,
attributes: true,
},
});
result.local_db = {
parcel: dbParcel
? {
objectId: dbParcel.objectId,
cadastralRef: dbParcel.cadastralRef,
areaValue: dbParcel.areaValue,
enrichedAt: dbParcel.enrichedAt,
geometrySource: dbParcel.geometrySource,
enrichment: dbParcel.enrichment,
}
: null,
buildings: dbBuildings.map((b) => ({
objectId: b.objectId,
cadastralRef: b.cadastralRef,
areaValue: b.areaValue,
is_legal: (b.attributes as Record<string, unknown>)?.IS_LEGAL,
})),
};
} catch (e) {
result.error = e instanceof Error ? e.message : String(e);
}
return NextResponse.json(result, {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
+36
View File
@@ -250,6 +250,20 @@ export async function POST(req: Request) {
pushProgress();
updatePhaseProgress(2, 2);
}
// Sync admin layers (lightweight, non-fatal)
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
try {
await syncLayer(
validated.username,
validated.password,
validated.siruta,
adminLayer,
{ jobId, isSubStep: true },
);
} catch {
// admin layers are best-effort
}
}
finishPhase();
/* ══════════════════════════════════════════════════════════ */
@@ -548,6 +562,19 @@ export async function POST(req: Request) {
zip.file("terenuri.gpkg", terenuriGpkg);
zip.file("cladiri.gpkg", cladiriGpkg);
// DXF versions (non-fatal)
try {
const { gpkgToDxf } = await import(
"@/modules/parcel-sync/services/gpkg-export"
);
const tDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
if (tDxf) zip.file("terenuri.dxf", tDxf);
const cDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
if (cDxf) zip.file("cladiri.dxf", cDxf);
} catch {
// DXF conversion not available — skip silently
}
// ── Comprehensive quality analysis ──
const withGeomRecords = dbTerenuri.filter(
(r) =>
@@ -671,6 +698,15 @@ export async function POST(req: Request) {
if (validated.mode === "magic" && magicGpkg && csvContent) {
zip.file("terenuri_magic.gpkg", magicGpkg);
try {
const { gpkgToDxf } = await import(
"@/modules/parcel-sync/services/gpkg-export"
);
const mDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
if (mDxf) zip.file("terenuri_magic.dxf", mDxf);
} catch {
// DXF conversion not available
}
zip.file("terenuri_complet.csv", csvContent);
report.magic = {
csvRows: csvContent.split("\n").length - 1,
+11
View File
@@ -182,6 +182,15 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
zip.file("terenuri.gpkg", terenuriGpkg);
zip.file("cladiri.gpkg", cladiriGpkg);
// DXF versions (non-fatal — ogr2ogr may not be available)
const { gpkgToDxf } = await import(
"@/modules/parcel-sync/services/gpkg-export"
);
const terenuriDxf = await gpkgToDxf(terenuriGpkg, "TERENURI_ACTIVE");
if (terenuriDxf) zip.file("terenuri.dxf", terenuriDxf);
const cladiriDxf = await gpkgToDxf(cladiriGpkg, "CLADIRI_ACTIVE");
if (cladiriDxf) zip.file("cladiri.dxf", cladiriDxf);
if (mode === "magic") {
// ── Magic: enrichment-merged GPKG + CSV + quality report ──
const headers = [
@@ -295,6 +304,8 @@ async function buildFullZip(siruta: string, mode: "base" | "magic") {
});
zip.file("terenuri_magic.gpkg", magicGpkg);
const magicDxf = await gpkgToDxf(magicGpkg, "TERENURI_MAGIC");
if (magicDxf) zip.file("terenuri_magic.dxf", magicDxf);
zip.file("terenuri_complet.csv", csvRows.join("\n"));
// ── Quality analysis ──
+153
View File
@@ -0,0 +1,153 @@
/**
* POST /api/eterra/refresh-all
*
* Runs delta sync on ALL UATs in DB sequentially.
* UATs with >30% enrichment → magic mode (sync + enrichment).
* UATs with ≤30% enrichment → base mode (sync only).
*
* Returns immediately with jobId — progress via /api/eterra/progress.
*/
import { PrismaClient } from "@prisma/client";
import {
setProgress,
clearProgress,
type SyncProgress,
} from "@/modules/parcel-sync/services/progress-store";
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const prisma = new PrismaClient();
export async function POST() {
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
return Response.json(
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD not configured" },
{ status: 500 },
);
}
const jobId = crypto.randomUUID();
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "running",
phase: "Pregătire refresh complet",
});
void runRefreshAll(jobId, username, password);
return Response.json({ jobId, message: "Refresh complet pornit" }, { status: 202 });
}
async function runRefreshAll(jobId: string, username: string, password: string) {
const push = (p: Partial<SyncProgress>) =>
setProgress({ jobId, downloaded: 0, total: 100, status: "running", ...p } as SyncProgress);
try {
// Health check
const health = await checkEterraHealthNow();
if (!health.available) {
setProgress({ jobId, downloaded: 0, total: 100, status: "error", phase: "eTerra indisponibil", message: health.message ?? "maintenance" });
setTimeout(() => clearProgress(jobId), 3_600_000);
return;
}
// Find all UATs with features + enrichment ratio
const uats = await prisma.$queryRawUnsafe<
Array<{ siruta: string; name: string | null; total: number; enriched: number }>
>(
`SELECT f.siruta, u.name, COUNT(*)::int as total,
COUNT(*) FILTER (WHERE f."enrichedAt" IS NOT NULL)::int as enriched
FROM "GisFeature" f LEFT JOIN "GisUat" u ON f.siruta = u.siruta
WHERE f."layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND f."objectId" > 0
GROUP BY f.siruta, u.name ORDER BY total DESC`,
);
if (uats.length === 0) {
setProgress({ jobId, downloaded: 100, total: 100, status: "done", phase: "Niciun UAT in DB" });
setTimeout(() => clearProgress(jobId), 3_600_000);
return;
}
const results: Array<{ siruta: string; name: string; mode: string; duration: number; note: string }> = [];
let errors = 0;
for (let i = 0; i < uats.length; i++) {
const uat = uats[i]!;
const uatName = uat.name ?? uat.siruta;
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
const isMagic = ratio > 0.3;
const mode = isMagic ? "magic" : "base";
const pct = Math.round(((i) / uats.length) * 100);
push({
downloaded: pct,
total: 100,
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
note: results.length > 0 ? `Ultimul: ${results[results.length - 1]!.name}${results[results.length - 1]!.note}` : undefined,
});
const uatStart = Date.now();
try {
// Sync TERENURI + CLADIRI (quick-count + VALID_FROM delta)
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName });
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName });
let enrichNote = "";
if (isMagic) {
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
const eRes = await enrichFeatures(client, uat.siruta);
enrichNote = eRes.status === "done"
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
: ` | enrich err: ${eRes.error}`;
}
const dur = Math.round((Date.now() - uatStart) / 1000);
const parts = [
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
: "T:ok",
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
: "C:ok",
];
const note = `${parts.join(", ")}${enrichNote} (${dur}s)`;
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
console.log(`[refresh-all] ${i + 1}/${uats.length} ${uatName}: ${note}`);
} catch (err) {
errors++;
const dur = Math.round((Date.now() - uatStart) / 1000);
const msg = err instanceof Error ? err.message : "Unknown";
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note: `ERR: ${msg}` });
console.error(`[refresh-all] ${uatName}: ${msg}`);
}
}
const totalDur = results.reduce((s, r) => s + r.duration, 0);
const summary = `${uats.length} UATs, ${errors} erori, ${totalDur}s total`;
setProgress({
jobId,
downloaded: 100,
total: 100,
status: "done",
phase: "Refresh complet finalizat",
message: summary,
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
});
console.log(`[refresh-all] Done: ${summary}`);
setTimeout(() => clearProgress(jobId), 6 * 3_600_000);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown";
setProgress({ jobId, downloaded: 0, total: 100, status: "error", phase: "Eroare", message: msg });
setTimeout(() => clearProgress(jobId), 3_600_000);
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* GET /api/eterra/stats
*
* Lightweight endpoint for the monitor page — returns aggregate counts
* suitable for polling every 30s without heavy DB load.
*
* Response:
* {
* totalUats: number,
* totalFeatures: number,
* totalTerenuri: number,
* totalCladiri: number,
* totalEnriched: number,
* totalNoGeom: number,
* countiesWithData: number,
* lastSyncAt: string | null,
* dbSizeMb: number | null,
* }
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const [
totalUats,
totalFeatures,
totalTerenuri,
totalCladiri,
totalEnriched,
totalNoGeom,
countyAgg,
lastSync,
dbSize,
] = await Promise.all([
prisma.gisUat.count(),
prisma.gisFeature.count({ where: { objectId: { gt: 0 } } }),
prisma.gisFeature.count({ where: { layerId: "TERENURI_ACTIVE", objectId: { gt: 0 } } }),
prisma.gisFeature.count({ where: { layerId: "CLADIRI_ACTIVE", objectId: { gt: 0 } } }),
prisma.gisFeature.count({ where: { enrichedAt: { not: null } } }),
prisma.gisFeature.count({ where: { geometrySource: "NO_GEOMETRY" } }),
prisma.gisUat.groupBy({
by: ["county"],
where: { county: { not: null } },
_count: true,
}),
prisma.gisSyncRun.findFirst({
where: { status: "done" },
orderBy: { completedAt: "desc" },
select: { completedAt: true },
}),
prisma.$queryRaw<Array<{ size: string }>>`
SELECT pg_size_pretty(pg_database_size(current_database())) as size
`,
]);
// Parse DB size to MB
const sizeStr = dbSize[0]?.size ?? "";
let dbSizeMb: number | null = null;
const mbMatch = sizeStr.match(/([\d.]+)\s*(MB|GB|TB)/i);
if (mbMatch) {
const val = parseFloat(mbMatch[1]!);
const unit = mbMatch[2]!.toUpperCase();
dbSizeMb = unit === "GB" ? val * 1024 : unit === "TB" ? val * 1024 * 1024 : val;
}
return NextResponse.json({
totalUats,
totalFeatures,
totalTerenuri,
totalCladiri,
totalEnriched,
totalNoGeom,
countiesWithData: countyAgg.length,
lastSyncAt: lastSync?.completedAt?.toISOString() ?? null,
dbSizeMb: dbSizeMb ? Math.round(dbSizeMb) : null,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,307 @@
/**
* POST /api/eterra/sync-all-counties
*
* Starts a background sync for ALL counties in the database (entire Romania).
* Iterates counties sequentially, running county-sync logic for each.
* Returns immediately with jobId — progress via /api/eterra/progress.
*
* Body: {} (no params needed)
*/
import { prisma } from "@/core/storage/prisma";
import {
setProgress,
clearProgress,
type SyncProgress,
} from "@/modules/parcel-sync/services/progress-store";
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
import { createAppNotification } from "@/core/notifications/app-notifications";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* Concurrency guard — blocks both this and single county sync */
const g = globalThis as {
__countySyncRunning?: string;
__allCountiesSyncRunning?: boolean;
};
export async function POST() {
const session = getSessionCredentials();
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
if (!username || !password) {
return Response.json(
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
{ status: 401 },
);
}
if (g.__allCountiesSyncRunning) {
return Response.json(
{ error: "Sync All Romania deja in curs" },
{ status: 409 },
);
}
if (g.__countySyncRunning) {
return Response.json(
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
{ status: 409 },
);
}
const jobId = crypto.randomUUID();
g.__allCountiesSyncRunning = true;
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "running",
phase: "Pregatire sync Romania...",
});
void runAllCountiesSync(jobId, username, password);
return Response.json(
{ jobId, message: "Sync All Romania pornit" },
{ status: 202 },
);
}
async function runAllCountiesSync(
jobId: string,
username: string,
password: string,
) {
const push = (p: Partial<SyncProgress>) =>
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "running",
...p,
} as SyncProgress);
try {
// Health check
const health = await checkEterraHealthNow();
if (!health.available) {
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "error",
phase: "eTerra indisponibil",
message: health.message ?? "maintenance",
});
g.__allCountiesSyncRunning = false;
setTimeout(() => clearProgress(jobId), 3_600_000);
return;
}
// Get all distinct counties, ordered alphabetically
const countyRows = await prisma.gisUat.groupBy({
by: ["county"],
where: { county: { not: null } },
_count: true,
orderBy: { county: "asc" },
});
const counties = countyRows
.map((r) => r.county)
.filter((c): c is string => c != null);
if (counties.length === 0) {
setProgress({
jobId,
downloaded: 100,
total: 100,
status: "done",
phase: "Niciun judet gasit in DB",
});
g.__allCountiesSyncRunning = false;
setTimeout(() => clearProgress(jobId), 3_600_000);
return;
}
push({ phase: `0/${counties.length} judete — pornire...` });
const countyResults: Array<{
county: string;
uatCount: number;
errors: number;
duration: number;
}> = [];
let totalErrors = 0;
let totalUats = 0;
for (let ci = 0; ci < counties.length; ci++) {
const county = counties[ci]!;
g.__countySyncRunning = county;
// Get UATs for this county
const uats = await prisma.$queryRawUnsafe<
Array<{
siruta: string;
name: string | null;
total: number;
enriched: number;
}>
>(
`SELECT u.siruta, u.name,
COALESCE(f.total, 0)::int as total,
COALESCE(f.enriched, 0)::int as enriched
FROM "GisUat" u
LEFT JOIN (
SELECT siruta, COUNT(*)::int as total,
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
FROM "GisFeature"
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
GROUP BY siruta
) f ON u.siruta = f.siruta
WHERE u.county = $1
ORDER BY COALESCE(f.total, 0) DESC`,
county,
);
if (uats.length === 0) {
countyResults.push({ county, uatCount: 0, errors: 0, duration: 0 });
continue;
}
const countyStart = Date.now();
let countyErrors = 0;
for (let i = 0; i < uats.length; i++) {
const uat = uats[i]!;
const uatName = uat.name ?? uat.siruta;
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
const isMagic = ratio > 0.3;
const mode = isMagic ? "magic" : "base";
// Progress: county level + UAT level — update before starting UAT
const countyPct = ci / counties.length;
const uatPct = i / uats.length;
const overallPct = Math.round((countyPct + uatPct / counties.length) * 100);
push({
downloaded: overallPct,
total: 100,
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} (${mode})`,
note: countyResults.length > 0
? `Ultimul judet: ${countyResults[countyResults.length - 1]!.county} (${countyResults[countyResults.length - 1]!.uatCount} UAT, ${countyResults[countyResults.length - 1]!.errors} err)`
: undefined,
});
try {
await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName, jobId, isSubStep: true });
await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName, jobId, isSubStep: true });
// LIMITE_INTRAV_DYNAMIC — best effort
try {
await syncLayer(username, password, uat.siruta, "LIMITE_INTRAV_DYNAMIC", { uatName, jobId, isSubStep: true });
} catch { /* skip */ }
// Enrichment for magic mode
if (isMagic) {
try {
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
await enrichFeatures(client, uat.siruta);
} catch {
// Enrichment failure is non-fatal
}
}
} catch (err) {
countyErrors++;
const msg = err instanceof Error ? err.message : "Unknown";
console.error(`[sync-all] ${county}/${uatName}: ${msg}`);
}
// Update progress AFTER UAT completion
const completedUatPct = (i + 1) / uats.length;
const completedOverallPct = Math.round((countyPct + completedUatPct / counties.length) * 100);
push({
downloaded: completedOverallPct,
total: 100,
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} finalizat`,
});
}
const dur = Math.round((Date.now() - countyStart) / 1000);
countyResults.push({ county, uatCount: uats.length, errors: countyErrors, duration: dur });
totalErrors += countyErrors;
totalUats += uats.length;
console.log(
`[sync-all] ${ci + 1}/${counties.length} ${county}: ${uats.length} UAT, ${countyErrors} err, ${dur}s`,
);
}
const totalDur = countyResults.reduce((s, r) => s + r.duration, 0);
const summary = `${counties.length} judete, ${totalUats} UAT-uri, ${totalErrors} erori, ${formatDuration(totalDur)}`;
setProgress({
jobId,
downloaded: 100,
total: 100,
status: totalErrors > 0 && totalErrors === totalUats ? "error" : "done",
phase: "Sync Romania finalizat",
message: summary,
});
await createAppNotification({
type: totalErrors > 0 ? "sync-error" : "sync-complete",
title: totalErrors > 0
? `Sync Romania: ${totalErrors} erori din ${totalUats} UAT-uri`
: `Sync Romania: ${totalUats} UAT-uri in ${counties.length} judete`,
message: summary,
metadata: { jobId, counties: counties.length, totalUats, totalErrors, totalDuration: totalDur },
});
console.log(`[sync-all] Done: ${summary}`);
// Trigger PMTiles rebuild after full Romania sync
await firePmtilesRebuild("all-counties-sync-complete", {
counties: counties.length,
totalUats,
totalErrors,
});
setTimeout(() => clearProgress(jobId), 12 * 3_600_000);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown";
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "error",
phase: "Eroare",
message: msg,
});
await createAppNotification({
type: "sync-error",
title: "Sync Romania: eroare generala",
message: msg,
metadata: { jobId },
});
setTimeout(() => clearProgress(jobId), 3_600_000);
} finally {
g.__allCountiesSyncRunning = false;
g.__countySyncRunning = undefined;
}
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h${String(m).padStart(2, "0")}m`;
}
+86 -60
View File
@@ -187,80 +187,106 @@ async function runBackground(params: {
getLayerFreshness(siruta, "CLADIRI_ACTIVE"),
]);
const terenuriNeedsSync =
forceSync ||
!isFresh(terenuriStatus.lastSynced) ||
terenuriStatus.featureCount === 0;
const cladiriNeedsSync =
forceSync ||
!isFresh(cladiriStatus.lastSynced) ||
cladiriStatus.featureCount === 0;
const terenuriNeedsFullSync =
forceSync || terenuriStatus.featureCount === 0;
const cladiriNeedsFullSync =
forceSync || cladiriStatus.featureCount === 0;
if (terenuriNeedsSync) {
phase = "Sincronizare terenuri";
push({});
const r = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
forceFullSync: forceSync,
jobId,
isSubStep: true,
});
if (r.status === "error")
throw new Error(r.error ?? "Sync terenuri failed");
}
// Always call syncLayer — it handles quick-count + VALID_FROM delta internally.
// Only force full download when no local data or explicit forceSync.
phase = "Sincronizare terenuri";
push({});
const terenuriResult = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
forceFullSync: terenuriNeedsFullSync,
jobId,
isSubStep: true,
});
if (terenuriResult.status === "error")
throw new Error(terenuriResult.error ?? "Sync terenuri failed");
updateOverall(0.5);
if (cladiriNeedsSync) {
phase = "Sincronizare clădiri";
push({});
const r = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
forceFullSync: forceSync,
jobId,
isSubStep: true,
});
if (r.status === "error")
throw new Error(r.error ?? "Sync clădiri failed");
}
// Sync intravilan limits (always, lightweight layer)
phase = "Sincronizare limite intravilan";
phase = "Sincronizare clădiri";
push({});
try {
await syncLayer(username, password, siruta, "LIMITE_INTRAV_DYNAMIC", {
forceFullSync: forceSync,
jobId,
isSubStep: true,
});
} catch {
// Non-critical — don't fail the whole job
note = "Avertisment: limite intravilan nu s-au sincronizat";
const cladiriResult = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
forceFullSync: cladiriNeedsFullSync,
jobId,
isSubStep: true,
});
if (cladiriResult.status === "error")
throw new Error(cladiriResult.error ?? "Sync clădiri failed");
// Sync admin layers — skip if synced within 24h
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
const adminStatus = await getLayerFreshness(siruta, adminLayer);
if (!forceSync && isFresh(adminStatus.lastSynced, 24)) continue;
phase = `Sincronizare ${adminLayer === "LIMITE_UAT" ? "limite UAT" : "limite intravilan"}`;
push({});
try {
await syncLayer(username, password, siruta, adminLayer, {
forceFullSync: forceSync,
jobId,
isSubStep: true,
});
} catch {
note = `Avertisment: ${adminLayer} nu s-a sincronizat`;
push({});
}
}
if (!terenuriNeedsSync && !cladiriNeedsSync) {
note = "Date proaspete — sync skip";
}
const syncSummary = [
terenuriResult.newFeatures > 0 ? `${terenuriResult.newFeatures} terenuri noi` : null,
terenuriResult.validFromUpdated ? `${terenuriResult.validFromUpdated} terenuri actualizate` : null,
cladiriResult.newFeatures > 0 ? `${cladiriResult.newFeatures} cladiri noi` : null,
cladiriResult.validFromUpdated ? `${cladiriResult.validFromUpdated} cladiri actualizate` : null,
].filter(Boolean);
note = syncSummary.length > 0 ? syncSummary.join(", ") : "Fără schimbări";
finishPhase();
/* ── Phase 2: No-geometry import (optional) ──────── */
if (hasNoGeom && weights.noGeom > 0) {
setPhase("Import parcele fără geometrie", weights.noGeom);
const noGeomClient = await EterraClient.create(username, password, {
timeoutMs: 120_000,
});
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
onProgress: (done, tot, ph) => {
phase = ph;
push({});
},
});
if (res.status === "error") {
note = `Avertisment no-geom: ${res.error}`;
setPhase("Verificare parcele fără geometrie", weights.noGeom);
// Skip no-geom import if recently done (within 48h) and not forced
const { PrismaClient } = await import("@prisma/client");
const _prisma = new PrismaClient();
let skipNoGeom = false;
try {
const recentNoGeom = await _prisma.gisFeature.findFirst({
where: {
layerId: "TERENURI_ACTIVE",
siruta,
geometrySource: "NO_GEOMETRY",
updatedAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) },
},
select: { id: true },
});
skipNoGeom = !forceSync && recentNoGeom != null;
} catch { /* proceed with import */ }
await _prisma.$disconnect();
if (skipNoGeom) {
note = "Parcele fără geometrie — actualizate recent, skip";
push({});
} else {
const cleanNote =
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
note = `${res.imported} parcele noi importate${cleanNote}`;
phase = "Import parcele fără geometrie";
push({});
const noGeomClient = await EterraClient.create(username, password, {
timeoutMs: 120_000,
});
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
onProgress: (done, tot, ph) => {
phase = ph;
push({});
},
});
if (res.status === "error") {
note = `Avertisment no-geom: ${res.error}`;
push({});
} else {
const cleanNote =
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
note = `${res.imported} parcele noi importate${cleanNote}`;
push({});
}
}
finishPhase();
}
+334
View File
@@ -0,0 +1,334 @@
/**
* POST /api/eterra/sync-county
*
* Starts a background sync for all UATs in a given county.
* Syncs TERENURI_ACTIVE, CLADIRI_ACTIVE, and LIMITE_INTRAV_DYNAMIC.
* UATs with >30% enrichment → magic mode (sync + enrichment).
*
* Body: { county: string }
* Returns immediately with jobId — progress via /api/eterra/progress.
*/
import { prisma } from "@/core/storage/prisma";
import {
setProgress,
clearProgress,
type SyncProgress,
} from "@/modules/parcel-sync/services/progress-store";
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
import { createAppNotification } from "@/core/notifications/app-notifications";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* Concurrency guard */
const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
export async function POST(req: Request) {
let body: { county?: string };
try {
body = (await req.json()) as { county?: string };
} catch {
return Response.json({ error: "Body invalid" }, { status: 400 });
}
const session = getSessionCredentials();
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
if (!username || !password) {
return Response.json(
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
{ status: 401 },
);
}
const county = body.county?.trim();
if (!county) {
return Response.json({ error: "Judetul lipseste" }, { status: 400 });
}
if (g.__allCountiesSyncRunning) {
return Response.json(
{ error: "Sync All Romania in curs — asteapta sa se termine" },
{ status: 409 },
);
}
if (g.__countySyncRunning) {
return Response.json(
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
{ status: 409 },
);
}
const jobId = crypto.randomUUID();
g.__countySyncRunning = county;
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "running",
phase: `Pregatire sync ${county}`,
});
void runCountySync(jobId, county, username, password);
return Response.json(
{ jobId, message: `Sync judet ${county} pornit` },
{ status: 202 },
);
}
async function runCountySync(
jobId: string,
county: string,
username: string,
password: string,
) {
const push = (p: Partial<SyncProgress>) =>
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "running",
...p,
} as SyncProgress);
try {
// Health check
const health = await checkEterraHealthNow();
if (!health.available) {
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "error",
phase: "eTerra indisponibil",
message: health.message ?? "maintenance",
});
await createAppNotification({
type: "sync-error",
title: `Sync ${county}: eTerra indisponibil`,
message: health.message ?? "Serviciul eTerra este in mentenanta",
metadata: { county, jobId },
});
g.__countySyncRunning = undefined;
setTimeout(() => clearProgress(jobId), 3_600_000);
return;
}
// Find all UATs in this county with feature stats
const uats = await prisma.$queryRawUnsafe<
Array<{
siruta: string;
name: string | null;
total: number;
enriched: number;
}>
>(
`SELECT u.siruta, u.name,
COALESCE(f.total, 0)::int as total,
COALESCE(f.enriched, 0)::int as enriched
FROM "GisUat" u
LEFT JOIN (
SELECT siruta, COUNT(*)::int as total,
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
FROM "GisFeature"
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
GROUP BY siruta
) f ON u.siruta = f.siruta
WHERE u.county = $1
ORDER BY COALESCE(f.total, 0) DESC`,
county,
);
if (uats.length === 0) {
setProgress({
jobId,
downloaded: 100,
total: 100,
status: "done",
phase: `Niciun UAT gasit in ${county}`,
});
g.__countySyncRunning = undefined;
setTimeout(() => clearProgress(jobId), 3_600_000);
return;
}
const results: Array<{
siruta: string;
name: string;
mode: string;
duration: number;
note: string;
}> = [];
let errors = 0;
let totalNewFeatures = 0;
for (let i = 0; i < uats.length; i++) {
const uat = uats[i]!;
const uatName = uat.name ?? uat.siruta;
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
const isMagic = ratio > 0.3;
const mode = isMagic ? "magic" : "base";
const pct = Math.round((i / uats.length) * 100);
push({
downloaded: pct,
total: 100,
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
note:
results.length > 0
? `Ultimul: ${results[results.length - 1]!.name}${results[results.length - 1]!.note}`
: undefined,
});
const uatStart = Date.now();
try {
// Sync TERENURI + CLADIRI — pass jobId for sub-progress
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
uatName, jobId, isSubStep: true,
});
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
uatName, jobId, isSubStep: true,
});
// Sync ADMINISTRATIV (intravilan) — wrapped in try/catch since it needs UAT geometry
let adminNote = "";
try {
const aRes = await syncLayer(
username,
password,
uat.siruta,
"LIMITE_INTRAV_DYNAMIC",
{ uatName, jobId, isSubStep: true },
);
if (aRes.newFeatures > 0) {
adminNote = ` | A:+${aRes.newFeatures}`;
}
} catch {
adminNote = " | A:skip";
}
// Enrichment for magic mode
let enrichNote = "";
if (isMagic) {
const client = await EterraClient.create(username, password, {
timeoutMs: 120_000,
});
const eRes = await enrichFeatures(client, uat.siruta);
enrichNote =
eRes.status === "done"
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
: ` | enrich err: ${eRes.error}`;
}
const dur = Math.round((Date.now() - uatStart) / 1000);
const parts = [
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
: "T:ok",
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
: "C:ok",
];
totalNewFeatures += tRes.newFeatures + cRes.newFeatures;
const note = `${parts.join(", ")}${adminNote}${enrichNote} (${dur}s)`;
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
// Update progress AFTER UAT completion (so % reflects completed work)
const completedPct = Math.round(((i + 1) / uats.length) * 100);
push({
downloaded: completedPct,
total: 100,
phase: `[${i + 1}/${uats.length}] ${uatName} finalizat`,
note: `${note}`,
});
console.log(`[sync-county:${county}] ${i + 1}/${uats.length} ${uatName}: ${note}`);
} catch (err) {
errors++;
const dur = Math.round((Date.now() - uatStart) / 1000);
const msg = err instanceof Error ? err.message : "Unknown";
results.push({
siruta: uat.siruta,
name: uatName,
mode,
duration: dur,
note: `ERR: ${msg}`,
});
// Still update progress after error
const completedPct = Math.round(((i + 1) / uats.length) * 100);
push({
downloaded: completedPct,
total: 100,
phase: `[${i + 1}/${uats.length}] ${uatName} — eroare`,
});
console.error(`[sync-county:${county}] ${uatName}: ${msg}`);
}
}
const totalDur = results.reduce((s, r) => s + r.duration, 0);
const summary = `${uats.length} UAT-uri, ${errors} erori, ${totalDur}s total`;
setProgress({
jobId,
downloaded: 100,
total: 100,
status: errors > 0 && errors === uats.length ? "error" : "done",
phase: `Sync ${county} finalizat`,
message: summary,
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
});
await createAppNotification({
type: errors > 0 ? "sync-error" : "sync-complete",
title:
errors > 0
? `Sync ${county}: ${errors} erori din ${uats.length} UAT-uri`
: `Sync ${county}: ${uats.length} UAT-uri sincronizate`,
message: summary,
metadata: { county, jobId, uatCount: uats.length, errors, totalDuration: totalDur },
});
console.log(`[sync-county:${county}] Done: ${summary}`);
// Trigger PMTiles rebuild if new features were synced
if (totalNewFeatures > 0) {
await firePmtilesRebuild("county-sync-complete", {
county,
uatCount: uats.length,
newFeatures: totalNewFeatures,
errors,
});
}
setTimeout(() => clearProgress(jobId), 6 * 3_600_000);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown";
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "error",
phase: "Eroare",
message: msg,
});
await createAppNotification({
type: "sync-error",
title: `Sync ${county}: eroare generala`,
message: msg,
metadata: { county, jobId },
});
setTimeout(() => clearProgress(jobId), 3_600_000);
} finally {
g.__countySyncRunning = undefined;
}
}
@@ -0,0 +1,95 @@
/**
* PATCH /api/eterra/sync-rules/[id] — Update a sync rule
* DELETE /api/eterra/sync-rules/[id] — Delete a sync rule
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
if (frequency === "manual") return null;
const base = lastSyncAt ?? new Date();
const ms: Record<string, number> = {
"3x-daily": 8 * 3600_000,
daily: 24 * 3600_000,
weekly: 7 * 24 * 3600_000,
monthly: 30 * 24 * 3600_000,
};
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const existing = await prisma.gisSyncRule.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Regula nu exista" }, { status: 404 });
}
const body = (await req.json()) as Record<string, unknown>;
// Validate frequency if provided
if (body.frequency && !VALID_FREQUENCIES.includes(body.frequency as string)) {
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
}
// Build update data — only include provided fields
const data: Record<string, unknown> = {};
const fields = [
"frequency", "syncTerenuri", "syncCladiri", "syncNoGeom", "syncEnrich",
"priority", "enabled", "allowedHoursStart", "allowedHoursEnd",
"allowedDays", "label",
];
for (const f of fields) {
if (f in body) data[f] = body[f];
}
// Recompute nextDueAt if frequency changed
if (body.frequency) {
data.nextDueAt = computeNextDue(
body.frequency as string,
existing.lastSyncAt,
);
}
// If enabled changed to true and no nextDueAt, compute it
if (body.enabled === true && !existing.nextDueAt && !data.nextDueAt) {
const freq = (body.frequency as string) ?? existing.frequency;
data.nextDueAt = computeNextDue(freq, existing.lastSyncAt);
}
const updated = await prisma.gisSyncRule.update({
where: { id },
data,
});
return NextResponse.json({ rule: updated });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
await prisma.gisSyncRule.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* POST /api/eterra/sync-rules/bulk — Bulk operations on sync rules
*
* Actions:
* - set-county-frequency: Create or update a county-level rule
* - enable/disable: Toggle multiple rules by IDs
* - delete: Delete multiple rules by IDs
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
if (frequency === "manual") return null;
const base = lastSyncAt ?? new Date();
const ms: Record<string, number> = {
"3x-daily": 8 * 3600_000,
daily: 24 * 3600_000,
weekly: 7 * 24 * 3600_000,
monthly: 30 * 24 * 3600_000,
};
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
}
type BulkBody = {
action: string;
county?: string;
frequency?: string;
syncEnrich?: boolean;
syncNoGeom?: boolean;
ruleIds?: string[];
};
export async function POST(req: Request) {
try {
const body = (await req.json()) as BulkBody;
switch (body.action) {
case "set-county-frequency": {
if (!body.county || !body.frequency) {
return NextResponse.json({ error: "county si frequency obligatorii" }, { status: 400 });
}
if (!VALID_FREQUENCIES.includes(body.frequency)) {
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
}
// Upsert county-level rule
const existing = await prisma.gisSyncRule.findFirst({
where: { county: body.county, siruta: null },
});
const rule = existing
? await prisma.gisSyncRule.update({
where: { id: existing.id },
data: {
frequency: body.frequency,
syncEnrich: body.syncEnrich ?? existing.syncEnrich,
syncNoGeom: body.syncNoGeom ?? existing.syncNoGeom,
nextDueAt: computeNextDue(body.frequency, existing.lastSyncAt),
},
})
: await prisma.gisSyncRule.create({
data: {
county: body.county,
frequency: body.frequency,
syncEnrich: body.syncEnrich ?? false,
syncNoGeom: body.syncNoGeom ?? false,
nextDueAt: computeNextDue(body.frequency, null),
},
});
return NextResponse.json({ rule, action: "set-county-frequency" });
}
case "enable":
case "disable": {
if (!body.ruleIds?.length) {
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
}
const result = await prisma.gisSyncRule.updateMany({
where: { id: { in: body.ruleIds } },
data: { enabled: body.action === "enable" },
});
return NextResponse.json({ updated: result.count, action: body.action });
}
case "delete": {
if (!body.ruleIds?.length) {
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
}
const result = await prisma.gisSyncRule.deleteMany({
where: { id: { in: body.ruleIds } },
});
return NextResponse.json({ deleted: result.count, action: "delete" });
}
default:
return NextResponse.json({ error: `Actiune necunoscuta: ${body.action}` }, { status: 400 });
}
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
/**
* GET /api/eterra/sync-rules/global-default — Get global default frequency
* PATCH /api/eterra/sync-rules/global-default — Set global default frequency
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const NAMESPACE = "sync-management";
const KEY = "global-default";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
export async function GET() {
try {
const row = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
});
const val = row?.value as { frequency?: string } | null;
return NextResponse.json({ frequency: val?.frequency ?? "monthly" });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PATCH(req: Request) {
try {
const body = (await req.json()) as { frequency?: string };
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency)) {
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
}
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
update: { value: { frequency: body.frequency } },
create: { namespace: NAMESPACE, key: KEY, value: { frequency: body.frequency } },
});
return NextResponse.json({ frequency: body.frequency });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+171
View File
@@ -0,0 +1,171 @@
/**
* GET /api/eterra/sync-rules — List all sync rules, enriched with UAT/county names
* POST /api/eterra/sync-rules — Create a new sync rule
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"] as const;
/** Compute nextDueAt from lastSyncAt + frequency interval */
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
if (frequency === "manual") return null;
const base = lastSyncAt ?? new Date();
const ms = {
"3x-daily": 8 * 3600_000,
daily: 24 * 3600_000,
weekly: 7 * 24 * 3600_000,
monthly: 30 * 24 * 3600_000,
}[frequency];
if (!ms) return null;
return new Date(base.getTime() + ms);
}
export async function GET() {
try {
const rules = await prisma.gisSyncRule.findMany({
orderBy: [{ priority: "asc" }, { createdAt: "desc" }],
});
// Enrich with UAT names for UAT-specific rules
const sirutas = rules
.map((r) => r.siruta)
.filter((s): s is string => s != null);
const uatMap = new Map<string, string>();
if (sirutas.length > 0) {
const uats = await prisma.gisUat.findMany({
where: { siruta: { in: sirutas } },
select: { siruta: true, name: true },
});
for (const u of uats) uatMap.set(u.siruta, u.name);
}
// For county rules, get UAT count per county
const counties = rules
.map((r) => r.county)
.filter((c): c is string => c != null);
const countyCountMap = new Map<string, number>();
if (counties.length > 0) {
const counts = await prisma.gisUat.groupBy({
by: ["county"],
where: { county: { in: counties } },
_count: true,
});
for (const c of counts) {
if (c.county) countyCountMap.set(c.county, c._count);
}
}
const enriched = rules.map((r) => ({
...r,
uatName: r.siruta ? (uatMap.get(r.siruta) ?? null) : null,
uatCount: r.county ? (countyCountMap.get(r.county) ?? 0) : r.siruta ? 1 : 0,
}));
// Get global default
const globalDefault = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: "sync-management", key: "global-default" } },
});
return NextResponse.json({
rules: enriched,
globalDefault: (globalDefault?.value as { frequency?: string })?.frequency ?? "monthly",
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
siruta?: string;
county?: string;
frequency?: string;
syncTerenuri?: boolean;
syncCladiri?: boolean;
syncNoGeom?: boolean;
syncEnrich?: boolean;
priority?: number;
enabled?: boolean;
allowedHoursStart?: number | null;
allowedHoursEnd?: number | null;
allowedDays?: string | null;
label?: string | null;
};
if (!body.siruta && !body.county) {
return NextResponse.json({ error: "Trebuie specificat siruta sau judetul" }, { status: 400 });
}
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency as typeof VALID_FREQUENCIES[number])) {
return NextResponse.json(
{ error: `Frecventa invalida. Valori permise: ${VALID_FREQUENCIES.join(", ")}` },
{ status: 400 },
);
}
// Validate siruta exists
if (body.siruta) {
const uat = await prisma.gisUat.findUnique({ where: { siruta: body.siruta } });
if (!uat) {
return NextResponse.json({ error: `UAT ${body.siruta} nu exista` }, { status: 404 });
}
}
// Validate county has UATs
if (body.county && !body.siruta) {
const count = await prisma.gisUat.count({ where: { county: body.county } });
if (count === 0) {
return NextResponse.json({ error: `Niciun UAT in judetul ${body.county}` }, { status: 404 });
}
}
// Check for existing rule with same scope
const existing = await prisma.gisSyncRule.findFirst({
where: {
siruta: body.siruta ?? null,
county: body.siruta ? null : (body.county ?? null),
},
});
if (existing) {
return NextResponse.json(
{ error: "Exista deja o regula pentru acest scope", existingId: existing.id },
{ status: 409 },
);
}
const nextDueAt = computeNextDue(body.frequency, null);
const rule = await prisma.gisSyncRule.create({
data: {
siruta: body.siruta ?? null,
county: body.siruta ? null : (body.county ?? null),
frequency: body.frequency,
syncTerenuri: body.syncTerenuri ?? true,
syncCladiri: body.syncCladiri ?? true,
syncNoGeom: body.syncNoGeom ?? false,
syncEnrich: body.syncEnrich ?? false,
priority: body.priority ?? 5,
enabled: body.enabled ?? true,
allowedHoursStart: body.allowedHoursStart ?? null,
allowedHoursEnd: body.allowedHoursEnd ?? null,
allowedDays: body.allowedDays ?? null,
label: body.label ?? null,
nextDueAt,
},
});
return NextResponse.json({ rule }, { status: 201 });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,72 @@
/**
* GET /api/eterra/sync-rules/scheduler — Scheduler status
*
* Returns current scheduler state from KeyValueStore + computed stats.
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
// Get scheduler state from KV (will be populated by the scheduler in Phase 2)
const kvState = await prisma.keyValueStore.findUnique({
where: {
namespace_key: { namespace: "sync-management", key: "scheduler-state" },
},
});
// Compute rule stats
const [totalRules, activeRules, dueNow, withErrors] = await Promise.all([
prisma.gisSyncRule.count(),
prisma.gisSyncRule.count({ where: { enabled: true } }),
prisma.gisSyncRule.count({
where: { enabled: true, nextDueAt: { lte: new Date() } },
}),
prisma.gisSyncRule.count({
where: { lastSyncStatus: "error" },
}),
]);
// Frequency distribution
const freqDist = await prisma.gisSyncRule.groupBy({
by: ["frequency"],
where: { enabled: true },
_count: true,
});
// County coverage
const totalCounties = await prisma.gisUat.groupBy({
by: ["county"],
where: { county: { not: null } },
_count: true,
});
const countiesWithRules = await prisma.gisSyncRule.groupBy({
by: ["county"],
where: { county: { not: null } },
_count: true,
});
return NextResponse.json({
scheduler: kvState?.value ?? { status: "not-started" },
stats: {
totalRules,
activeRules,
dueNow,
withErrors,
frequencyDistribution: Object.fromEntries(
freqDist.map((f) => [f.frequency, f._count]),
),
totalCounties: totalCounties.length,
countiesWithRules: countiesWithRules.length,
},
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+246
View File
@@ -0,0 +1,246 @@
import { NextResponse } from "next/server";
import { PrismaClient, Prisma } from "@prisma/client";
import {
isWeekendWindow,
getWeekendSyncActivity,
triggerForceSync,
} from "@/modules/parcel-sync/services/weekend-deep-sync";
const prisma = new PrismaClient();
const g = globalThis as { __parcelSyncRunning?: boolean };
const KV_NAMESPACE = "parcel-sync-weekend";
const KV_KEY = "queue-state";
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
type StepStatus = "pending" | "done" | "error";
type CityState = {
siruta: string;
name: string;
county: string;
priority: number;
steps: Record<StepName, StepStatus>;
lastActivity?: string;
errorMessage?: string;
};
type WeekendSyncState = {
cities: CityState[];
lastSessionDate?: string;
totalSessions: number;
completedCycles: number;
};
const FRESH_STEPS: Record<StepName, StepStatus> = {
sync_terenuri: "pending",
sync_cladiri: "pending",
import_nogeom: "pending",
enrich: "pending",
};
const DEFAULT_CITIES: Omit<CityState, "steps">[] = [
{ siruta: "54975", name: "Cluj-Napoca", county: "Cluj", priority: 1 },
{ siruta: "32394", name: "Bistri\u021Ba", county: "Bistri\u021Ba-N\u0103s\u0103ud", priority: 1 },
{ siruta: "114319", name: "T\u00E2rgu Mure\u0219", county: "Mure\u0219", priority: 2 },
{ siruta: "139704", name: "Zal\u0103u", county: "S\u0103laj", priority: 2 },
{ siruta: "26564", name: "Oradea", county: "Bihor", priority: 2 },
{ siruta: "9262", name: "Arad", county: "Arad", priority: 2 },
{ siruta: "155243", name: "Timi\u0219oara", county: "Timi\u0219", priority: 2 },
{ siruta: "143450", name: "Sibiu", county: "Sibiu", priority: 2 },
{ siruta: "40198", name: "Bra\u0219ov", county: "Bra\u0219ov", priority: 2 },
];
/** Initialize state with default cities if not present in DB */
async function getOrCreateState(): Promise<WeekendSyncState> {
const row = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
});
if (row?.value && typeof row.value === "object") {
return row.value as unknown as WeekendSyncState;
}
// First access — initialize with defaults
const state: WeekendSyncState = {
cities: DEFAULT_CITIES.map((c) => ({ ...c, steps: { ...FRESH_STEPS } })),
totalSessions: 0,
completedCycles: 0,
};
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
update: { value: state as unknown as Prisma.InputJsonValue },
create: {
namespace: KV_NAMESPACE,
key: KV_KEY,
value: state as unknown as Prisma.InputJsonValue,
},
});
return state;
}
/**
* GET /api/eterra/weekend-sync
* Returns the current queue state.
*/
export async function GET() {
// Auth handled by middleware (route is not excluded)
const state = await getOrCreateState();
const sirutas = state.cities.map((c) => c.siruta);
const counts = await prisma.gisFeature.groupBy({
by: ["siruta", "layerId"],
where: { siruta: { in: sirutas } },
_count: { id: true },
});
const enrichedCounts = await prisma.gisFeature.groupBy({
by: ["siruta"],
where: { siruta: { in: sirutas }, enrichedAt: { not: null } },
_count: { id: true },
});
const enrichedMap = new Map(enrichedCounts.map((e) => [e.siruta, e._count.id]));
type CityStats = {
terenuri: number;
cladiri: number;
total: number;
enriched: number;
};
const statsMap = new Map<string, CityStats>();
for (const c of counts) {
const existing = statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 };
existing.total += c._count.id;
if (c.layerId === "TERENURI_ACTIVE") existing.terenuri = c._count.id;
if (c.layerId === "CLADIRI_ACTIVE") existing.cladiri = c._count.id;
existing.enriched = enrichedMap.get(c.siruta) ?? 0;
statsMap.set(c.siruta, existing);
}
const citiesWithStats = state.cities.map((c) => ({
...c,
dbStats: statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 },
}));
// Determine live sync status
const running = !!g.__parcelSyncRunning;
const activity = getWeekendSyncActivity();
const inWindow = isWeekendWindow();
const hasErrors = state.cities.some((c) =>
(Object.values(c.steps) as StepStatus[]).some((s) => s === "error"),
);
type SyncStatus = "running" | "error" | "waiting" | "idle";
let syncStatus: SyncStatus = "idle";
if (running) syncStatus = "running";
else if (hasErrors) syncStatus = "error";
else if (inWindow) syncStatus = "waiting";
return NextResponse.json({
state: { ...state, cities: citiesWithStats },
syncStatus,
currentActivity: activity,
inWeekendWindow: inWindow,
});
}
/**
* POST /api/eterra/weekend-sync
* Modify the queue: add/remove cities, reset steps, change priority.
*/
export async function POST(request: Request) {
// Auth handled by middleware (route is not excluded)
const body = (await request.json()) as {
action: "add" | "remove" | "reset" | "reset_all" | "set_priority" | "trigger";
siruta?: string;
name?: string;
county?: string;
priority?: number;
onlySteps?: string[];
};
// Trigger is handled separately — starts sync immediately
if (body.action === "trigger") {
const validSteps = ["sync_terenuri", "sync_cladiri", "import_nogeom", "enrich"] as const;
const onlySteps = body.onlySteps?.filter((s): s is (typeof validSteps)[number] =>
(validSteps as readonly string[]).includes(s),
);
const result = await triggerForceSync(
onlySteps && onlySteps.length > 0 ? { onlySteps } : undefined,
);
if (!result.started) {
return NextResponse.json(
{ error: result.reason },
{ status: 409 },
);
}
return NextResponse.json({ ok: true, message: "Sincronizare pornita" });
}
const state = await getOrCreateState();
switch (body.action) {
case "add": {
if (!body.siruta || !body.name) {
return NextResponse.json(
{ error: "siruta si name sunt obligatorii" },
{ status: 400 },
);
}
if (state.cities.some((c) => c.siruta === body.siruta)) {
return NextResponse.json(
{ error: `${body.name} (${body.siruta}) e deja in coada` },
{ status: 409 },
);
}
state.cities.push({
siruta: body.siruta,
name: body.name,
county: body.county ?? "",
priority: body.priority ?? 3,
steps: { ...FRESH_STEPS },
});
break;
}
case "remove": {
state.cities = state.cities.filter((c) => c.siruta !== body.siruta);
break;
}
case "reset": {
const city = state.cities.find((c) => c.siruta === body.siruta);
if (city) {
city.steps = { ...FRESH_STEPS };
city.errorMessage = undefined;
}
break;
}
case "reset_all": {
for (const city of state.cities) {
city.steps = { ...FRESH_STEPS };
city.errorMessage = undefined;
}
state.completedCycles = 0;
break;
}
case "set_priority": {
const city = state.cities.find((c) => c.siruta === body.siruta);
if (city && body.priority != null) {
city.priority = body.priority;
}
break;
}
}
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
update: { value: state as unknown as Prisma.InputJsonValue },
create: {
namespace: KV_NAMESPACE,
key: KV_KEY,
value: state as unknown as Prisma.InputJsonValue,
},
});
return NextResponse.json({ ok: true, cities: state.cities.length });
}
+30 -2
View File
@@ -75,6 +75,34 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Parcela negasita in registrul eTerra" }, { status: 404 });
}
// Building cross-ref: check CLADIRI_ACTIVE in local DB for this parcel
let hasBuilding = 0;
let buildLegal = 0;
const baseCad = cadRef.includes("-") ? cadRef.split("-")[0]! : cadRef;
if (baseCad) {
const cladiri = await prisma.gisFeature.findMany({
where: {
layerId: "CLADIRI_ACTIVE",
siruta: feature.siruta,
OR: [
{ cadastralRef: { startsWith: baseCad + "-" } },
{ cadastralRef: baseCad },
],
},
select: { attributes: true },
});
for (const c of cladiri) {
const attrs = c.attributes as Record<string, unknown>;
hasBuilding = 1;
if (
Number(attrs.IS_LEGAL ?? 0) === 1 ||
String(attrs.IS_LEGAL ?? "").toLowerCase() === "true"
) {
buildLegal = 1;
}
}
}
// Convert to enrichment format (same as enrichFeatures uses)
const enrichment = {
NR_CAD: match.nrCad || cadRef,
@@ -89,8 +117,8 @@ export async function POST(req: Request) {
SOLICITANT: match.solicitant || "",
INTRAVILAN: match.intravilan || "",
CATEGORIE_FOLOSINTA: match.categorieFolosinta || "",
HAS_BUILDING: 0,
BUILD_LEGAL: 0,
HAS_BUILDING: hasBuilding,
BUILD_LEGAL: buildLegal,
};
// Persist
+235
View File
@@ -0,0 +1,235 @@
/**
* GET /api/geoportal/monitor — tile infrastructure status
* POST /api/geoportal/monitor — trigger actions (rebuild, warm-cache)
*/
import { NextRequest, NextResponse } from "next/server";
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const TILE_CACHE_INTERNAL = "http://tile-cache:80";
const MARTIN_INTERNAL = "http://martin:3000";
const PMTILES_URL = process.env.NEXT_PUBLIC_PMTILES_URL || "";
// Server-side fetch needs absolute URL — resolve relative paths through tile-cache
const PMTILES_FETCH_URL = PMTILES_URL.startsWith("/")
? `${TILE_CACHE_INTERNAL}${PMTILES_URL.replace(/^\/tiles/, "")}`
: PMTILES_URL;
const N8N_WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || "";
type NginxStatus = {
activeConnections: number;
accepts: number;
handled: number;
requests: number;
reading: number;
writing: number;
waiting: number;
};
function parseNginxStatus(text: string): NginxStatus {
const lines = text.trim().split("\n");
const active = parseInt(lines[0]?.match(/\d+/)?.[0] ?? "0", 10);
const counts = lines[2]?.trim().split(/\s+/).map(Number) ?? [0, 0, 0];
const rw = lines[3]?.match(/\d+/g)?.map(Number) ?? [0, 0, 0];
return {
activeConnections: active,
accepts: counts[0] ?? 0,
handled: counts[1] ?? 0,
requests: counts[2] ?? 0,
reading: rw[0] ?? 0,
writing: rw[1] ?? 0,
waiting: rw[2] ?? 0,
};
}
async function fetchWithTimeout(url: string, timeoutMs = 5000): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { signal: controller.signal, cache: "no-store" });
} finally {
clearTimeout(timer);
}
}
// Sample tile coordinates for cache testing (Romania, z8)
const SAMPLE_TILES = [
{ z: 8, x: 143, y: 91, source: "gis_uats_z8" },
{ z: 17, x: 73640, y: 47720, source: "gis_terenuri" },
];
export async function GET() {
const result: Record<string, unknown> = {
timestamp: new Date().toISOString(),
};
// 1. Nginx status
try {
const res = await fetchWithTimeout(`${TILE_CACHE_INTERNAL}/status`);
if (res.ok) {
result.nginx = parseNginxStatus(await res.text());
} else {
result.nginx = { error: `HTTP ${res.status}` };
}
} catch {
result.nginx = { error: "tile-cache unreachable" };
}
// 2. Martin catalog
try {
const res = await fetchWithTimeout(`${MARTIN_INTERNAL}/catalog`);
if (res.ok) {
const catalog = await res.json() as { tiles?: Record<string, unknown> };
const sources = Object.keys(catalog.tiles ?? {});
result.martin = { status: "ok", sources, sourceCount: sources.length };
} else {
result.martin = { error: `HTTP ${res.status}` };
}
} catch {
result.martin = { error: "martin unreachable" };
}
// 3. PMTiles info
if (PMTILES_URL) {
try {
const res = await fetchWithTimeout(PMTILES_FETCH_URL, 3000);
result.pmtiles = {
url: PMTILES_URL,
status: res.ok ? "ok" : `HTTP ${res.status}`,
size: res.headers.get("content-length")
? `${(parseInt(res.headers.get("content-length") ?? "0", 10) / 1024 / 1024).toFixed(1)} MB`
: "unknown",
lastModified: res.headers.get("last-modified") ?? "unknown",
};
} catch {
result.pmtiles = { url: PMTILES_URL, error: "unreachable" };
}
} else {
result.pmtiles = { status: "not configured" };
}
// 4. Cache test — request sample tiles and check X-Cache-Status
const cacheTests: Record<string, string>[] = [];
for (const tile of SAMPLE_TILES) {
try {
const url = `${TILE_CACHE_INTERNAL}/${tile.source}/${tile.z}/${tile.x}/${tile.y}`;
const res = await fetchWithTimeout(url, 10000);
cacheTests.push({
tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
status: `${res.status}`,
cache: res.headers.get("x-cache-status") ?? "unknown",
});
} catch {
cacheTests.push({
tile: `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
status: "error",
cache: "unreachable",
});
}
}
result.cacheTests = cacheTests;
// 5. Config summary
result.config = {
martinUrl: process.env.NEXT_PUBLIC_MARTIN_URL ?? "(not set)",
pmtilesUrl: PMTILES_URL || "(not set)",
n8nWebhook: N8N_WEBHOOK_URL ? "configured" : "not set",
};
return NextResponse.json(result);
}
async function getPmtilesInfo(): Promise<{ size: string; lastModified: string } | null> {
if (!PMTILES_URL) return null;
try {
const res = await fetchWithTimeout(PMTILES_FETCH_URL, 3000);
return {
size: res.headers.get("content-length")
? `${(parseInt(res.headers.get("content-length") ?? "0", 10) / 1024 / 1024).toFixed(1)} MB`
: "unknown",
lastModified: res.headers.get("last-modified") ?? "unknown",
};
} catch {
return null;
}
}
export async function POST(request: NextRequest) {
const body = await request.json() as { action?: string };
const action = body.action;
if (action === "rebuild") {
// Get current PMTiles state before rebuild
const before = await getPmtilesInfo();
const result = await firePmtilesRebuild("manual-rebuild");
if (!result.ok) {
return NextResponse.json(
{ error: "Webhook PMTiles indisponibil — verifica N8N_WEBHOOK_URL si serviciul pmtiles-webhook" },
{ status: 500 },
);
}
return NextResponse.json({
ok: true,
action: "rebuild",
alreadyRunning: result.alreadyRunning ?? false,
previousPmtiles: before,
message: result.alreadyRunning
? "Rebuild PMTiles deja in curs. Urmareste PMTiles last-modified."
: "Rebuild PMTiles pornit. Dureaza ~8 min. Urmareste PMTiles last-modified.",
});
}
if (action === "check-rebuild") {
// Check if PMTiles was updated since a given timestamp
const previousLastModified = (body as { previousLastModified?: string }).previousLastModified;
const current = await getPmtilesInfo();
const changed = !!current && !!previousLastModified && current.lastModified !== previousLastModified;
return NextResponse.json({
ok: true,
action: "check-rebuild",
current,
changed,
message: changed
? `Rebuild finalizat! PMTiles actualizat: ${current?.size}, ${current?.lastModified}`
: "Rebuild in curs...",
});
}
if (action === "warm-cache") {
const sources = ["gis_terenuri", "gis_cladiri"];
let total = 0;
let hits = 0;
let misses = 0;
let errors = 0;
const promises: Promise<void>[] = [];
for (const source of sources) {
for (let x = 9200; x <= 9210; x++) {
for (let y = 5960; y <= 5970; y++) {
total++;
promises.push(
fetchWithTimeout(`${TILE_CACHE_INTERNAL}/${source}/14/${x}/${y}`, 30000)
.then((res) => {
const cache = res.headers.get("x-cache-status") ?? "";
if (cache === "HIT") hits++;
else misses++;
})
.catch(() => { errors++; }),
);
}
}
}
await Promise.all(promises);
return NextResponse.json({
ok: true,
action: "warm-cache",
total,
hits,
misses,
errors,
message: `${total} tile-uri procesate: ${hits} HIT, ${misses} MISS (nou incarcate), ${errors} erori`,
});
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
+39 -30
View File
@@ -67,25 +67,28 @@ export async function GET(req: Request) {
// Search by cadastral reference
const parcels = await prisma.$queryRaw`
SELECT
id,
"cadastralRef",
"areaValue",
siruta,
enrichment,
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
FROM "GisFeature"
WHERE geom IS NOT NULL
AND "layerId" LIKE 'TERENURI%'
AND ("cadastralRef" ILIKE ${pattern}
OR enrichment::text ILIKE ${`%"NR_CAD":"${q}%`})
ORDER BY "cadastralRef"
f.id,
f."cadastralRef",
f."areaValue",
f.siruta,
f.enrichment,
u.name as uat_name,
ST_X(ST_Centroid(ST_Transform(f.geom, 4326))) as lng,
ST_Y(ST_Centroid(ST_Transform(f.geom, 4326))) as lat
FROM "GisFeature" f
LEFT JOIN "GisUat" u ON u.siruta = f.siruta
WHERE f.geom IS NOT NULL
AND f."layerId" LIKE 'TERENURI%'
AND (f."cadastralRef" ILIKE ${pattern}
OR f.enrichment::text ILIKE ${`%"NR_CAD":"${q}%`})
ORDER BY f."cadastralRef"
LIMIT ${limit}
` as Array<{
id: string;
cadastralRef: string | null;
areaValue: number | null;
siruta: string;
uat_name: string | null;
enrichment: Record<string, unknown> | null;
lng: number;
lat: number;
@@ -94,11 +97,12 @@ export async function GET(req: Request) {
for (const p of parcels) {
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
const area = p.areaValue ? `${Math.round(p.areaValue)} mp` : "";
const uatLabel = p.uat_name ?? `SIRUTA ${p.siruta}`;
results.push({
id: `parcel-${p.id}`,
type: "parcel",
label: `Parcela ${nrCad}`,
sublabel: [area, `SIRUTA ${p.siruta}`].filter(Boolean).join(" | "),
label: `Parcela ${nrCad}${uatLabel}`,
sublabel: [area, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
});
}
@@ -106,25 +110,28 @@ export async function GET(req: Request) {
// Search by owner name in enrichment JSON
const parcels = await prisma.$queryRaw`
SELECT
id,
"cadastralRef",
"areaValue",
siruta,
enrichment,
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
FROM "GisFeature"
WHERE geom IS NOT NULL
AND "layerId" LIKE 'TERENURI%'
AND enrichment IS NOT NULL
AND enrichment::text ILIKE ${pattern}
ORDER BY "cadastralRef"
f.id,
f."cadastralRef",
f."areaValue",
f.siruta,
f.enrichment,
u.name as uat_name,
ST_X(ST_Centroid(ST_Transform(f.geom, 4326))) as lng,
ST_Y(ST_Centroid(ST_Transform(f.geom, 4326))) as lat
FROM "GisFeature" f
LEFT JOIN "GisUat" u ON u.siruta = f.siruta
WHERE f.geom IS NOT NULL
AND f."layerId" LIKE 'TERENURI%'
AND f.enrichment IS NOT NULL
AND f.enrichment::text ILIKE ${pattern}
ORDER BY f."cadastralRef"
LIMIT ${limit}
` as Array<{
id: string;
cadastralRef: string | null;
areaValue: number | null;
siruta: string;
uat_name: string | null;
enrichment: Record<string, unknown> | null;
lng: number;
lat: number;
@@ -133,11 +140,13 @@ export async function GET(req: Request) {
for (const p of parcels) {
const nrCad = (p.enrichment?.NR_CAD as string) ?? p.cadastralRef ?? "?";
const owner = (p.enrichment?.PROPRIETARI as string) ?? "";
const uatLabel = p.uat_name ?? `SIRUTA ${p.siruta}`;
const ownerShort = owner.length > 60 ? owner.slice(0, 60) + "..." : owner;
results.push({
id: `parcel-${p.id}`,
type: "parcel",
label: `Parcela ${nrCad}`,
sublabel: owner.length > 60 ? owner.slice(0, 60) + "..." : owner,
label: `Parcela ${nrCad}${uatLabel}`,
sublabel: [ownerShort, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
});
}
+56
View File
@@ -0,0 +1,56 @@
/**
* GET /api/notifications/app — list recent + unread count
* PATCH /api/notifications/app — mark read / mark all read
*
* Body for PATCH:
* { action: "mark-read", id: string }
* { action: "mark-all-read" }
*/
import { NextResponse } from "next/server";
import {
getAppNotifications,
getUnreadCount,
markAsRead,
markAllAsRead,
} from "@/core/notifications/app-notifications";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "30", 10), 100);
const [notifications, unreadCount] = await Promise.all([
getAppNotifications(limit),
getUnreadCount(),
]);
return NextResponse.json({ notifications, unreadCount });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare notificari";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PATCH(req: Request) {
try {
const body = (await req.json()) as { action: string; id?: string };
if (body.action === "mark-read" && body.id) {
await markAsRead(body.id);
return NextResponse.json({ ok: true });
}
if (body.action === "mark-all-read") {
await markAllAsRead();
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Actiune necunoscuta" }, { status: 400 });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare notificari";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+141
View File
@@ -0,0 +1,141 @@
/**
* In-app notification service.
*
* Stores lightweight notifications in KeyValueStore (namespace "app-notifications").
* Used for sync completion alerts, errors, etc.
*/
import { prisma } from "@/core/storage/prisma";
import type { Prisma } from "@prisma/client";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type AppNotificationType = "sync-complete" | "sync-error";
export interface AppNotification {
id: string;
type: AppNotificationType;
title: string;
message: string;
createdAt: string;
readAt: string | null;
metadata?: Record<string, unknown>;
}
const NAMESPACE = "app-notifications";
const MAX_AGE_DAYS = 30;
/* ------------------------------------------------------------------ */
/* Create */
/* ------------------------------------------------------------------ */
export async function createAppNotification(
input: Omit<AppNotification, "id" | "createdAt" | "readAt">,
): Promise<AppNotification> {
const notification: AppNotification = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
readAt: null,
...input,
};
await prisma.keyValueStore.create({
data: {
namespace: NAMESPACE,
key: notification.id,
value: notification as unknown as Prisma.InputJsonValue,
},
});
return notification;
}
/* ------------------------------------------------------------------ */
/* Read */
/* ------------------------------------------------------------------ */
export async function getAppNotifications(limit = 30): Promise<AppNotification[]> {
const rows = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
orderBy: { createdAt: "desc" },
take: limit,
});
const cutoff = Date.now() - MAX_AGE_DAYS * 86_400_000;
const notifications: AppNotification[] = [];
const staleIds: string[] = [];
for (const row of rows) {
const n = row.value as unknown as AppNotification;
if (new Date(n.createdAt).getTime() < cutoff) {
staleIds.push(row.id);
} else {
notifications.push(n);
}
}
// Lazy cleanup of old notifications
if (staleIds.length > 0) {
void prisma.keyValueStore.deleteMany({
where: { id: { in: staleIds } },
});
}
return notifications;
}
export async function getUnreadCount(): Promise<number> {
const rows = await prisma.$queryRaw<Array<{ count: number }>>`
SELECT COUNT(*)::int as count
FROM "KeyValueStore"
WHERE namespace = ${NAMESPACE}
AND value->>'readAt' IS NULL
`;
return rows[0]?.count ?? 0;
}
/* ------------------------------------------------------------------ */
/* Update */
/* ------------------------------------------------------------------ */
export async function markAsRead(id: string): Promise<void> {
const row = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: id } },
});
if (!row) return;
const n = row.value as unknown as AppNotification;
n.readAt = new Date().toISOString();
await prisma.keyValueStore.update({
where: { namespace_key: { namespace: NAMESPACE, key: id } },
data: { value: n as unknown as Prisma.InputJsonValue },
});
}
export async function markAllAsRead(): Promise<void> {
const rows = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
});
const now = new Date().toISOString();
const updates = rows
.filter((r) => {
const n = r.value as unknown as AppNotification;
return n.readAt === null;
})
.map((r) => {
const n = r.value as unknown as AppNotification;
n.readAt = now;
return prisma.keyValueStore.update({
where: { namespace_key: { namespace: NAMESPACE, key: r.key } },
data: { value: n as unknown as Prisma.InputJsonValue },
});
});
if (updates.length > 0) {
await prisma.$transaction(updates);
}
}
+9
View File
@@ -15,3 +15,12 @@ export {
getAllPreferences,
runDigest,
} from "./notification-service";
export {
createAppNotification,
getAppNotifications,
getUnreadCount,
markAsRead,
markAllAsRead,
type AppNotification,
type AppNotificationType,
} from "./app-notifications";
+12
View File
@@ -0,0 +1,12 @@
/**
* Next.js instrumentation hook — runs once at server startup.
* Used to initialize background schedulers.
*/
export async function register() {
// Only run on the server (not during build or in edge runtime)
if (process.env.NEXT_RUNTIME === "nodejs") {
// ParcelSync auto-refresh scheduler DISABLED during GIS DB overhaul.
// Re-enable by uncommenting the import below once the new schema is stable.
// await import("@/modules/parcel-sync/services/auto-refresh-scheduler");
}
}
+1 -1
View File
@@ -58,6 +58,6 @@ export const config = {
* - /favicon.ico, /robots.txt, /sitemap.xml
* - Files with extensions (images, fonts, etc.)
*/
"/((?!api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
"/((?!api/auth|api/notifications/digest|api/eterra/auto-refresh|api/compress-pdf|api/address-book|api/projects|auth/signin|_next|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\..*).*)",
],
};
@@ -32,8 +32,6 @@ export function GeoportalModule() {
const [clickedFeature, setClickedFeature] = useState<ClickedFeature | null>(null);
const [selectionMode, setSelectionMode] = useState<SelectionMode>("off");
const [selectedFeatures, setSelectedFeatures] = useState<SelectedFeature[]>([]);
const [flyTarget, setFlyTarget] = useState<{ center: [number, number]; zoom?: number } | undefined>();
const handleFeatureClick = useCallback((feature: ClickedFeature | null) => {
// null = clicked on empty space, close panel
if (!feature || !feature.properties) {
@@ -45,7 +43,7 @@ export function GeoportalModule() {
const handleSearchResult = useCallback((result: SearchResult) => {
if (result.coordinates) {
setFlyTarget({ center: result.coordinates, zoom: result.type === "uat" ? 12 : 17 });
mapHandleRef.current?.flyTo(result.coordinates, result.type === "uat" ? 12 : 17);
}
}, []);
@@ -67,8 +65,6 @@ export function GeoportalModule() {
onFeatureClick={handleFeatureClick}
onSelectionChange={setSelectedFeatures}
layerVisibility={layerVisibility}
center={flyTarget?.center}
zoom={flyTarget?.zoom}
/>
{/* Setup banner (auto-hides when ready) */}
+155 -57
View File
@@ -2,6 +2,7 @@
import { useRef, useEffect, useState, useCallback, useImperativeHandle, forwardRef } from "react";
import maplibregl from "maplibre-gl";
import { Protocol as PmtilesProtocol } from "pmtiles";
import { cn } from "@/shared/lib/utils";
/* Ensure MapLibre CSS is loaded — static import fails with next/dynamic + standalone */
@@ -15,6 +16,12 @@ if (typeof document !== "undefined") {
document.head.appendChild(link);
}
}
/* Register PMTiles protocol globally (once) for pmtiles:// source URLs */
if (typeof window !== "undefined") {
const pmtilesProto = new PmtilesProtocol();
maplibregl.addProtocol("pmtiles", pmtilesProto.tile);
}
import type { BasemapId, ClickedFeature, LayerVisibility, SelectedFeature } from "../types";
/* ------------------------------------------------------------------ */
@@ -28,6 +35,7 @@ export type SelectionType = "off" | "click" | "rect" | "freehand";
/* ------------------------------------------------------------------ */
const DEFAULT_MARTIN_URL = process.env.NEXT_PUBLIC_MARTIN_URL || "/tiles";
const DEFAULT_PMTILES_URL = process.env.NEXT_PUBLIC_PMTILES_URL || "";
const DEFAULT_CENTER: [number, number] = [23.8, 46.1];
const DEFAULT_ZOOM = 7;
@@ -58,6 +66,7 @@ const LAYER_IDS = {
terenuriLabel: "l-terenuri-label",
cladiriFill: "l-cladiri-fill",
cladiriLine: "l-cladiri-line",
cladiriLabel: "l-cladiri-label",
selectionFill: "l-selection-fill",
selectionLine: "l-selection-line",
drawPolygonFill: "l-draw-polygon-fill",
@@ -319,8 +328,8 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
LAYER_IDS.uatsZ12Fill, LAYER_IDS.uatsZ12Line, LAYER_IDS.uatsZ12Label,
],
administrativ: [LAYER_IDS.adminLineOuter, LAYER_IDS.adminLineInner],
terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine, LAYER_IDS.terenuriLabel],
cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine],
terenuri: [LAYER_IDS.terenuriFill, LAYER_IDS.terenuriLine, LAYER_IDS.terenuriLabel, "l-terenuri-pm-fill", "l-terenuri-pm-line", "l-terenuri-pm-label"],
cladiri: [LAYER_IDS.cladiriFill, LAYER_IDS.cladiriLine, LAYER_IDS.cladiriLabel, "l-cladiri-pm-fill", "l-cladiri-pm-line"],
};
for (const [group, layerIds] of Object.entries(mapping)) {
const visible = vis[group] !== false;
@@ -383,67 +392,160 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
}
}
// === UAT z0-5: very coarse — lines only ===
map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 });
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
// === UAT sources: PMTiles (if configured) or Martin fallback ===
const pmtilesUrl = DEFAULT_PMTILES_URL;
const usePmtiles = !!pmtilesUrl;
// === UAT z5-8: coarse ===
map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 });
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
if (usePmtiles) {
// Single PMTiles source contains all UAT + administrativ layers (z0-z14)
const PM_SRC = "overview-pmtiles";
map.addSource(PM_SRC, { type: "vector", url: `pmtiles://${pmtilesUrl}` });
// === UAT z8-12: moderate ===
map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 });
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 1 } });
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// z0-5: lines only
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
// === UAT z12+: full detail (no simplification) ===
map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 });
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 2 } });
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// z5-8
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
// === Intravilan — double line (black outer + orange inner), no fill, z13+ ===
map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 });
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#000000", "line-width": 3 } });
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#f97316", "line-width": 1.5 } });
// z8-12
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 1 } });
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: PM_SRC, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// === Terenuri (parcels) — no simplification ===
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 18 });
map.addLayer({ id: LAYER_IDS.terenuriFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });
map.addLayer({ id: LAYER_IDS.terenuriLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
paint: { "line-color": "#15803d", "line-width": 0.8 } });
// Parcel cadastral number label
map.addLayer({ id: LAYER_IDS.terenuriLabel, type: "symbol", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 16,
// z12+: full detail from PMTiles
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: PM_SRC, "source-layer": SOURCES.uatsZ12, minzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 2 } });
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: PM_SRC, "source-layer": SOURCES.uatsZ12, minzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// Intravilan from PMTiles
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: PM_SRC, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#000000", "line-width": 3 } });
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: PM_SRC, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#f97316", "line-width": 1.5 } });
} else {
// Fallback: Martin tile sources (existing behavior)
// z0-5: very coarse — lines only
map.addSource(SOURCES.uatsZ0, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ0}/{z}/{x}/{y}`], minzoom: 0, maxzoom: 5 });
map.addLayer({ id: LAYER_IDS.uatsZ0Line, type: "line", source: SOURCES.uatsZ0, "source-layer": SOURCES.uatsZ0, maxzoom: 5,
paint: { "line-color": "#7c3aed", "line-width": 0.3 } });
// z5-8: coarse
map.addSource(SOURCES.uatsZ5, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ5}/{z}/{x}/{y}`], minzoom: 5, maxzoom: 8 });
map.addLayer({ id: LAYER_IDS.uatsZ5Line, type: "line", source: SOURCES.uatsZ5, "source-layer": SOURCES.uatsZ5, minzoom: 5, maxzoom: 8,
paint: { "line-color": "#7c3aed", "line-width": 0.6 } });
// z8-12: moderate
map.addSource(SOURCES.uatsZ8, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ8}/{z}/{x}/{y}`], minzoom: 8, maxzoom: 12 });
map.addLayer({ id: LAYER_IDS.uatsZ8Line, type: "line", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 8, maxzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 1 } });
map.addLayer({ id: LAYER_IDS.uatsZ8Label, type: "symbol", source: SOURCES.uatsZ8, "source-layer": SOURCES.uatsZ8, minzoom: 9, maxzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 10, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// z12+: full detail (no simplification)
map.addSource(SOURCES.uatsZ12, { type: "vector", tiles: [`${m}/${SOURCES.uatsZ12}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 16 });
map.addLayer({ id: LAYER_IDS.uatsZ12Line, type: "line", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
paint: { "line-color": "#7c3aed", "line-width": 2 } });
map.addLayer({ id: LAYER_IDS.uatsZ12Label, type: "symbol", source: SOURCES.uatsZ12, "source-layer": SOURCES.uatsZ12, minzoom: 12,
layout: { "text-field": ["coalesce", ["get", "name"], ""], "text-font": ["Noto Sans Regular"], "text-size": 13, "text-anchor": "center", "text-allow-overlap": false },
paint: { "text-color": "#5b21b6", "text-halo-color": "#fff", "text-halo-width": 1.5 } });
// Intravilan — double line (black outer + orange inner), no fill, z13+
map.addSource(SOURCES.administrativ, { type: "vector", tiles: [`${m}/${SOURCES.administrativ}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 16 });
map.addLayer({ id: LAYER_IDS.adminLineOuter, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#000000", "line-width": 3 } });
map.addLayer({ id: LAYER_IDS.adminLineInner, type: "line", source: SOURCES.administrativ, "source-layer": SOURCES.administrativ, minzoom: 13,
paint: { "line-color": "#f97316", "line-width": 1.5 } });
}
// === Terenuri (parcels) ===
if (usePmtiles) {
// PMTiles serves ALL zoom levels (z13-z18) — zero PostGIS load
map.addLayer({ id: "l-terenuri-pm-fill", type: "fill", source: "overview-pmtiles", "source-layer": SOURCES.terenuri, minzoom: 13,
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });
map.addLayer({ id: "l-terenuri-pm-line", type: "line", source: "overview-pmtiles", "source-layer": SOURCES.terenuri, minzoom: 13,
paint: { "line-color": "#15803d", "line-width": 0.8 } });
map.addLayer({ id: "l-terenuri-pm-label", type: "symbol", source: "overview-pmtiles", "source-layer": SOURCES.terenuri, minzoom: 16,
layout: {
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
"text-font": ["Noto Sans Regular"],
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
"text-max-width": 8,
},
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
// Martin source registered but unused (selection uses PMTiles source now)
// Kept as fallback reference — no tile requests since no layers target it
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 17, maxzoom: 18 });
} else {
map.addSource(SOURCES.terenuri, { type: "vector", tiles: [`${m}/${SOURCES.terenuri}/{z}/{x}/{y}`], minzoom: 10, maxzoom: 18 });
map.addLayer({ id: LAYER_IDS.terenuriFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
paint: { "fill-color": "#22c55e", "fill-opacity": 0.15 } });
map.addLayer({ id: LAYER_IDS.terenuriLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
paint: { "line-color": "#15803d", "line-width": 0.8 } });
map.addLayer({ id: LAYER_IDS.terenuriLabel, type: "symbol", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 16,
layout: {
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
"text-font": ["Noto Sans Regular"],
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
"text-max-width": 8,
},
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
}
// === Cladiri (buildings) ===
if (usePmtiles) {
// PMTiles serves ALL zoom levels (z14-z18) — zero PostGIS load
map.addLayer({ id: "l-cladiri-pm-fill", type: "fill", source: "overview-pmtiles", "source-layer": SOURCES.cladiri, minzoom: 14,
paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
map.addLayer({ id: "l-cladiri-pm-line", type: "line", source: "overview-pmtiles", "source-layer": SOURCES.cladiri, minzoom: 14,
paint: { "line-color": "#1e3a5f", "line-width": 0.6 } });
map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 17, maxzoom: 18 });
} else {
map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 18 });
map.addLayer({ id: LAYER_IDS.cladiriFill, type: "fill", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
map.addLayer({ id: LAYER_IDS.cladiriLine, type: "line", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
paint: { "line-color": "#1e3a5f", "line-width": 0.6 } });
}
// Building body labels — extract suffix after last dash (e.g. "291479-C1" → "C1")
map.addLayer({ id: LAYER_IDS.cladiriLabel, type: "symbol",
source: usePmtiles ? "overview-pmtiles" : SOURCES.cladiri,
"source-layer": SOURCES.cladiri, minzoom: 16,
layout: {
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
"text-field": [
"case",
["has", "cadastral_ref"],
["let", "ref", ["get", "cadastral_ref"],
["let", "dashIdx", ["index-of", "-", ["var", "ref"]],
["case",
[">=", ["var", "dashIdx"], 0],
["slice", ["var", "ref"], ["+", ["var", "dashIdx"], 1]],
["var", "ref"],
],
],
],
"",
],
"text-font": ["Noto Sans Regular"],
"text-size": 10, "text-anchor": "center", "text-allow-overlap": false,
"text-max-width": 8,
"text-size": 9, "text-anchor": "center", "text-allow-overlap": false,
"text-max-width": 6,
},
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
// === Cladiri (buildings) — no simplification ===
map.addSource(SOURCES.cladiri, { type: "vector", tiles: [`${m}/${SOURCES.cladiri}/{z}/{x}/{y}`], minzoom: 12, maxzoom: 18 });
map.addLayer({ id: LAYER_IDS.cladiriFill, type: "fill", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
paint: { "fill-color": "#3b82f6", "fill-opacity": 0.5 } });
map.addLayer({ id: LAYER_IDS.cladiriLine, type: "line", source: SOURCES.cladiri, "source-layer": SOURCES.cladiri, minzoom: 14,
paint: { "line-color": "#1e3a5f", "line-width": 0.6 } });
paint: { "text-color": "#1e3a5f", "text-halo-color": "#fff", "text-halo-width": 1 } });
// === Selection highlight ===
map.addLayer({ id: LAYER_IDS.selectionFill, type: "fill", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
// Use PMTiles source when available (has data at z13+), Martin only has z17+
const selectionSrc = usePmtiles ? "overview-pmtiles" : SOURCES.terenuri;
map.addLayer({ id: LAYER_IDS.selectionFill, type: "fill", source: selectionSrc, "source-layer": SOURCES.terenuri, minzoom: 13,
filter: ["==", "object_id", "__NONE__"],
paint: { "fill-color": "#f59e0b", "fill-opacity": 0.5 } });
map.addLayer({ id: LAYER_IDS.selectionLine, type: "line", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 13,
map.addLayer({ id: LAYER_IDS.selectionLine, type: "line", source: selectionSrc, "source-layer": SOURCES.terenuri, minzoom: 13,
filter: ["==", "object_id", "__NONE__"],
paint: { "line-color": "#d97706", "line-width": 2.5 } });
@@ -472,8 +574,10 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
});
/* ---- Click handler — NO popup, only callback ---- */
// Include both Martin and PMTiles fill layers — filter() skips non-existent ones
const clickableLayers = [
LAYER_IDS.terenuriFill, LAYER_IDS.cladiriFill,
"l-terenuri-pm-fill", "l-cladiri-pm-fill",
];
map.on("click", (e) => {
@@ -741,12 +845,6 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resolvedMartinUrl, basemap]);
/* ---- Sync center/zoom prop changes (from search flyTo) ---- */
useEffect(() => {
if (!mapReady || !mapRef.current || !center) return;
mapRef.current.flyTo({ center, zoom: zoom ?? mapRef.current.getZoom(), duration: 1500 });
}, [center, zoom, mapReady]);
/* ---- Disable interactions when in drawing modes ---- */
useEffect(() => {
const map = mapRef.current;
@@ -106,7 +106,7 @@ export function SearchBar({ onResultSelect, className }: SearchBarProps) {
if (results.length > 0) setOpen(true);
}}
onKeyDown={handleKeyDown}
className="pl-8 pr-8 h-8 text-sm bg-background/95 backdrop-blur-sm"
className="pl-8 pr-8 h-8 text-sm bg-background backdrop-blur-sm text-foreground"
/>
{loading && (
<Loader2 className="absolute right-8 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin text-muted-foreground" />
@@ -15,7 +15,10 @@ import {
Clock,
ArrowDownToLine,
AlertTriangle,
Moon,
Activity,
} from "lucide-react";
import Link from "next/link";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
@@ -141,6 +144,23 @@ export function ExportTab({
const dbTotalFeatures = dbLayersSummary.reduce((sum, l) => sum + l.count, 0);
// Primary layers synced by background jobs — these determine freshness
const PRIMARY_LAYERS = ["TERENURI_ACTIVE", "CLADIRI_ACTIVE", "LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"];
const primaryLayers = dbLayersSummary.filter((l) =>
PRIMARY_LAYERS.includes(l.id),
);
const hasData = dbTotalFeatures > 0;
const canExportLocal = hasData;
const oldestSyncDate = primaryLayers.reduce(
(oldest, l) => {
if (!l.lastSynced) return oldest;
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
return oldest;
},
null as Date | null,
);
const progressPct =
exportProgress?.total && exportProgress.total > 0
? Math.round((exportProgress.downloaded / exportProgress.total) * 100)
@@ -604,40 +624,48 @@ export function ExportTab({
layere
</span>
{(() => {
const freshCount = dbLayersSummary.filter(
(l) => l.isFresh,
).length;
const staleCount = dbLayersSummary.length - freshCount;
const oldestSync = dbLayersSummary.reduce(
(oldest, l) => {
if (!l.lastSynced) return oldest;
if (!oldest || l.lastSynced < oldest) return l.lastSynced;
return oldest;
const staleLayers = primaryLayers.filter((l) => !l.isFresh);
const freshLayers = primaryLayers.filter((l) => l.isFresh);
const newestSync = primaryLayers.reduce(
(newest, l) => {
if (!l.lastSynced) return newest;
if (!newest || l.lastSynced > newest) return l.lastSynced;
return newest;
},
null as Date | null,
);
// Tooltip: list which layers are stale/fresh with dates
const staleTooltip = staleLayers.length > 0
? `Vechi: ${staleLayers.map((l) => `${l.label} (${l.lastSynced ? relativeTime(l.lastSynced) : "nesincronizat"})`).join(", ")}`
: "";
const freshTooltip = freshLayers.length > 0
? `Proaspete: ${freshLayers.map((l) => `${l.label} (${l.lastSynced ? relativeTime(l.lastSynced) : ""})`).join(", ")}`
: "";
const fullTooltip = [staleTooltip, freshTooltip].filter(Boolean).join("\n");
return (
<>
{staleCount === 0 ? (
{staleLayers.length === 0 && primaryLayers.length > 0 ? (
<Badge
variant="outline"
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800"
className="text-emerald-600 border-emerald-200 dark:text-emerald-400 dark:border-emerald-800 cursor-default"
title={fullTooltip}
>
<CheckCircle2 className="h-3 w-3 mr-1" />
Proaspete
</Badge>
) : (
) : staleLayers.length > 0 ? (
<Badge
variant="outline"
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800"
className="text-amber-600 border-amber-200 dark:text-amber-400 dark:border-amber-800 cursor-default"
title={fullTooltip}
>
<Clock className="h-3 w-3 mr-1" />
{staleCount} vechi
{staleLayers.length} vechi
</Badge>
)}
{oldestSync && (
) : null}
{newestSync && (
<span className="text-xs text-muted-foreground">
Ultima sincronizare: {relativeTime(oldestSync)}
Ultima sincronizare: {relativeTime(newestSync)}
</span>
)}
</>
@@ -649,52 +677,117 @@ export function ExportTab({
)}
{/* Hero buttons */}
{sirutaValid && session.connected ? (
<div className="grid gap-3 sm:grid-cols-2">
<Button
size="lg"
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
disabled={exporting}
onClick={() => void handleExportBundle("base")}
>
{exporting && exportProgress?.phase !== "Detalii parcele" ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<FileDown className="mr-2 h-5 w-5" />
)}
<div className="text-left">
<div className="font-semibold">
Descarcă Terenuri și Clădiri
</div>
<div className="text-xs opacity-70 font-normal">
Sync + GPKG (din cache dacă e proaspăt)
</div>
</div>
</Button>
{sirutaValid && (session.connected || canExportLocal) ? (
<div className="space-y-2">
{(() => {
// Build tooltip with layer details for hero buttons
const layerLines = dbLayersSummary
.filter((l) => l.count > 0)
.sort((a, b) => b.count - a.count)
.map(
(l) =>
`${l.label}: ${l.count.toLocaleString("ro")} entitati${l.lastSynced ? ` (sync ${relativeTime(l.lastSynced)})` : ""}`,
);
const enriched = dbLayersSummary.reduce(
(sum, l) => {
const enrichCount =
syncRuns.find(
(r) => r.layerId === l.id && r.status === "done",
)?.totalLocal ?? 0;
return sum + enrichCount;
},
0,
);
const baseTooltip = layerLines.length > 0
? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF per layer`
: "Nicio data in DB";
const magicTooltip = layerLines.length > 0
? `ZIP contine:\n• ${layerLines.join("\n• ")}\n\nFormate: GPKG + DXF + CSV complet\n+ Raport calitate enrichment`
: "Nicio data in DB";
<Button
size="lg"
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
disabled={exporting}
onClick={() => void handleExportBundle("magic")}
>
{exporting && exportProgress?.phase === "Detalii parcele" ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Sparkles className="mr-2 h-5 w-5" />
)}
<div className="text-left">
<div className="font-semibold">Magic</div>
<div className="text-xs opacity-70 font-normal">
Sync + îmbogățire (CF, proprietari, adresă) + GPKG + CSV
return (
<div className="grid gap-3 sm:grid-cols-2">
<Button
size="lg"
className="h-auto py-4 text-base bg-zinc-900 hover:bg-zinc-800 text-white dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
disabled={exporting || downloadingFromDb}
title={baseTooltip}
onClick={() =>
canExportLocal
? void handleDownloadFromDb("base")
: void handleExportBundle("base")
}
>
{(exporting || downloadingFromDb) &&
exportProgress?.phase !== "Detalii parcele" ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : canExportLocal ? (
<Database className="mr-2 h-5 w-5" />
) : (
<FileDown className="mr-2 h-5 w-5" />
)}
<div className="text-left">
<div className="font-semibold">
Descarcă Terenuri și Clădiri
</div>
<div className="text-xs opacity-70 font-normal">
{canExportLocal
? `Din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
: hasData
? "Sync incremental + GPKG + DXF"
: "Sync complet + GPKG + DXF"}
</div>
</div>
</Button>
<Button
size="lg"
className="h-auto py-4 text-base bg-teal-700 hover:bg-teal-600 text-white dark:bg-teal-600 dark:hover:bg-teal-500"
disabled={exporting || downloadingFromDb}
title={magicTooltip}
onClick={() =>
canExportLocal
? void handleDownloadFromDb("magic")
: void handleExportBundle("magic")
}
>
{(exporting || downloadingFromDb) &&
exportProgress?.phase === "Detalii parcele" ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Sparkles className="mr-2 h-5 w-5" />
)}
<div className="text-left">
<div className="font-semibold">Magic</div>
<div className="text-xs opacity-70 font-normal">
{canExportLocal
? `GPKG + DXF + CSV din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
: "Sync + îmbogățire + GPKG + DXF + CSV"}
</div>
</div>
</Button>
</div>
);
})()}
{canExportLocal && session.connected && (
<div className="text-center">
<button
type="button"
className="text-xs text-muted-foreground hover:text-foreground underline transition-colors"
disabled={exporting}
onClick={() => void handleExportBundle("base")}
>
<RefreshCw className="inline h-3 w-3 mr-1 -mt-0.5" />
Re-sincronizează de pe eTerra
</button>
</div>
</Button>
)}
</div>
) : (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{!session.connected ? (
{!session.connected && !canExportLocal ? (
<>
<Wifi className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Conectează-te la eTerra pentru a activa exportul.</p>
@@ -878,7 +971,7 @@ export function ExportTab({
</li>
)}
<li>
\u00cembogățire CF, proprietari, adrese {" "}
Îmbogățire CF, proprietari, adrese {" "}
<span className="font-medium text-teal-600 dark:text-teal-400">
{(() => {
// What will be in DB after sync + optional no-geom import:
@@ -1072,7 +1165,7 @@ export function ExportTab({
{noGeomScan.qualityBreakdown.empty > 0
? `Din ${noGeomScan.noGeomCount.toLocaleString("ro-RO")} fără geometrie, ~${noGeomScan.qualityBreakdown.useful.toLocaleString("ro-RO")} vor fi importate (imobile electronice cu CF). ${noGeomScan.qualityBreakdown.empty.toLocaleString("ro-RO")} vor fi filtrate (fără carte funciară, inactive sau fără date).`
: "Vor fi importate în DB și incluse în CSV + Magic GPKG (coloana HAS_GEOMETRY=0/1)."}{" "}
\u00cen GPKG de bază apar doar cele cu geometrie.
În GPKG de bază apar doar cele cu geometrie.
</p>
)}
{workflowPreview}
@@ -1222,54 +1315,6 @@ export function ExportTab({
</div>
)}
{/* Row 3: Download from DB buttons */}
{dbTotalFeatures > 0 && (
<div className="grid gap-2 sm:grid-cols-2">
<Button
variant="outline"
size="sm"
className="h-auto py-2.5 justify-start"
disabled={downloadingFromDb}
onClick={() => void handleDownloadFromDb("base")}
>
{downloadingFromDb ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Database className="mr-2 h-4 w-4" />
)}
<div className="text-left">
<div className="text-xs font-semibold">
Descarcă din DB Bază
</div>
<div className="text-[10px] opacity-60 font-normal">
GPKG terenuri + clădiri (instant, fără eTerra)
</div>
</div>
</Button>
<Button
variant="outline"
size="sm"
className="h-auto py-2.5 justify-start border-teal-200 dark:border-teal-800 hover:bg-teal-50 dark:hover:bg-teal-950/30"
disabled={downloadingFromDb}
onClick={() => void handleDownloadFromDb("magic")}
>
{downloadingFromDb ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin text-teal-600" />
) : (
<Sparkles className="mr-2 h-4 w-4 text-teal-600" />
)}
<div className="text-left">
<div className="text-xs font-semibold">
Descarcă din DB Magic
</div>
<div className="text-[10px] opacity-60 font-normal">
GPKG + CSV + raport calitate (instant)
</div>
</div>
</Button>
</div>
)}
{!session.connected && dbTotalFeatures === 0 && (
<p className="text-xs text-muted-foreground ml-6">
Conectează-te la eTerra pentru a porni sincronizarea fundal,
@@ -1280,6 +1325,33 @@ export function ExportTab({
</Card>
)}
{/* Weekend Deep Sync + Monitor hints */}
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Moon className="h-3.5 w-3.5 shrink-0" />
<span>
Municipii mari cu Magic complet?{" "}
<Link
href="/wds"
className="underline hover:text-foreground transition-colors"
>
Weekend Deep Sync
</Link>
{" "} sincronizare automata Vin/Sam/Dum noaptea.
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Activity className="h-3.5 w-3.5 shrink-0" />
<span>
Rebuild PMTiles si status servicii?{" "}
<Link
href="/monitor"
className="underline hover:text-foreground transition-colors"
>
Monitor
</Link>
</span>
</div>
{/* Background sync progress */}
{bgJobId && bgProgress && bgProgress.status !== "unknown" && (
<Card
@@ -1406,9 +1478,11 @@ export function ExportTab({
setBgJobId(null);
setBgProgress(null);
setBgPhaseTrail([]);
onSyncRefresh();
onDbRefresh();
}}
>
\u00cenchide
Închide
</Button>
</div>
)}
@@ -363,7 +363,67 @@ export function MapTab({ siruta, sirutaValid, sessionConnected, syncLocalCount,
}
}
// Buildings: keep base layer visible with siruta filter (already applied above)
// ── Enrichment overlay for BUILDINGS ──
if (!map.getSource("gis_cladiri_status")) {
map.addSource("gis_cladiri_status", {
type: "vector",
tiles: [`${martinBase}/gis_cladiri_status/{z}/{x}/{y}`],
minzoom: 14,
maxzoom: 18,
});
// Data-driven fill: red = no legal docs, blue = has legal docs
map.addLayer(
{
id: "l-ps-cladiri-fill",
type: "fill",
source: "gis_cladiri_status",
"source-layer": "gis_cladiri_status",
minzoom: 14,
filter,
paint: {
"fill-color": [
"case",
["==", ["get", "build_legal"], 1],
"#3b82f6", // blue: legal docs OK
"#ef4444", // red: no legal docs
],
"fill-opacity": 0.55,
},
},
"l-terenuri-fill",
);
map.addLayer(
{
id: "l-ps-cladiri-line",
type: "line",
source: "gis_cladiri_status",
"source-layer": "gis_cladiri_status",
minzoom: 14,
filter,
paint: {
"line-color": [
"case",
["==", ["get", "build_legal"], 1],
"#1e40af", // dark blue: legal
"#b91c1c", // dark red: no legal
],
"line-width": 1.5,
},
},
"l-terenuri-fill",
);
} else {
try {
if (map.getLayer("l-ps-cladiri-fill"))
map.setFilter("l-ps-cladiri-fill", filter);
if (map.getLayer("l-ps-cladiri-line"))
map.setFilter("l-ps-cladiri-line", filter);
} catch {
/* noop */
}
}
}, [mapReady, siruta, sirutaValid]);
/* ── Boundary cross-check: load mismatched parcels ─────────── */
@@ -0,0 +1,232 @@
/**
* Self-contained auto-refresh scheduler for ParcelSync.
*
* Runs inside the existing Node.js process no external dependencies.
* Checks every 30 minutes; during the night window (15 AM) it picks
* stale UATs one at a time with random delays between them.
*
* Activated by importing this module (side-effect). The globalThis guard
* ensures only one scheduler runs per process, surviving HMR in dev.
*/
import { PrismaClient } from "@prisma/client";
import { syncLayer } from "./sync-service";
import { getLayerFreshness, isFresh } from "./enrich-service";
import { isEterraAvailable } from "./eterra-health";
import { isWeekendWindow, runWeekendDeepSync } from "./weekend-deep-sync";
const prisma = new PrismaClient();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ */
/* Configuration */
/* ------------------------------------------------------------------ */
/** Night window: only run between these hours (server local time) */
const NIGHT_START_HOUR = 1;
const NIGHT_END_HOUR = 5;
/** How often to check if we should run (ms) */
const CHECK_INTERVAL_MS = 30 * 60_000; // 30 minutes
/** Delay between UATs: 310s (delta sync is fast) */
const MIN_DELAY_MS = 3_000;
const MAX_DELAY_MS = 10_000;
/** Enrichment ratio threshold — UATs with >30% enriched get magic mode */
const MAGIC_THRESHOLD = 0.3;
/* ------------------------------------------------------------------ */
/* Singleton guard */
/* ------------------------------------------------------------------ */
const g = globalThis as {
__autoRefreshTimer?: ReturnType<typeof setInterval>;
__parcelSyncRunning?: boolean; // single flag for all sync modes
__autoRefreshLastRun?: string; // ISO date of last completed run
};
/* ------------------------------------------------------------------ */
/* Core logic */
/* ------------------------------------------------------------------ */
async function runAutoRefresh() {
// Prevent concurrent runs (shared with weekend sync)
if (g.__parcelSyncRunning) return;
const hour = new Date().getHours();
const dayOfWeek = new Date().getDay(); // 0=Sun, 6=Sat
const isWeekday = dayOfWeek >= 1 && dayOfWeek <= 5;
if (!isWeekday || hour < NIGHT_START_HOUR || hour >= NIGHT_END_HOUR) return;
// Only run once per night (check date)
const today = new Date().toISOString().slice(0, 10);
if (g.__autoRefreshLastRun === today) return;
const username = process.env.ETERRA_USERNAME;
const password = process.env.ETERRA_PASSWORD;
if (!username || !password) return;
if (!isEterraAvailable()) {
console.log("[auto-refresh] eTerra indisponibil, skip.");
return;
}
g.__parcelSyncRunning = true;
console.log("[auto-refresh] Pornire delta refresh nocturn (toate UAT-urile)...");
try {
// Find all UATs with features + enrichment ratio
const uats = await prisma.$queryRawUnsafe<
Array<{ siruta: string; name: string | null; total: number; enriched: number }>
>(
`SELECT f.siruta, u.name, COUNT(*)::int as total,
COUNT(*) FILTER (WHERE f."enrichedAt" IS NOT NULL)::int as enriched
FROM "GisFeature" f LEFT JOIN "GisUat" u ON f.siruta = u.siruta
WHERE f."layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND f."objectId" > 0
GROUP BY f.siruta, u.name ORDER BY total DESC`,
);
if (uats.length === 0) {
console.log("[auto-refresh] Niciun UAT in DB, skip.");
g.__autoRefreshLastRun = today;
g.__parcelSyncRunning = false;
return;
}
console.log(`[auto-refresh] ${uats.length} UAT-uri de procesat.`);
let processed = 0;
let errors = 0;
for (let i = 0; i < uats.length; i++) {
const uat = uats[i]!;
const uatName = uat.name ?? uat.siruta;
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
const isMagic = ratio > MAGIC_THRESHOLD;
// Small delay between UATs
if (i > 0) {
const delay = MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
await sleep(delay);
}
// Check we're still in the night window
if (new Date().getHours() >= NIGHT_END_HOUR) {
console.log(`[auto-refresh] Fereastra nocturna s-a inchis la ${i}/${uats.length} UATs.`);
break;
}
if (!isEterraAvailable()) {
console.log("[auto-refresh] eTerra a devenit indisponibil, opresc.");
break;
}
const start = Date.now();
try {
// Delta sync: quick-count + VALID_FROM for TERENURI + CLADIRI
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName });
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName });
let enrichNote = "";
if (isMagic) {
const { EterraClient } = await import("./eterra-client");
const { enrichFeatures } = await import("./enrich-service");
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
const eRes = await enrichFeatures(client, uat.siruta);
enrichNote = eRes.status === "done"
? ` | enrich:${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
: ` | enrich ERR:${eRes.error}`;
}
const dur = ((Date.now() - start) / 1000).toFixed(1);
const tNote = tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
: "T:ok";
const cNote = cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
: "C:ok";
console.log(
`[auto-refresh] [${i + 1}/${uats.length}] ${uatName} (${isMagic ? "magic" : "base"}): ${tNote}, ${cNote}${enrichNote} (${dur}s)`,
);
processed++;
} catch (err) {
errors++;
const msg = err instanceof Error ? err.message : String(err);
console.error(`[auto-refresh] [${i + 1}/${uats.length}] ${uatName}: ERR ${msg}`);
}
}
g.__autoRefreshLastRun = today;
console.log(`[auto-refresh] Finalizat: ${processed}/${uats.length} UATs, ${errors} erori.`);
// Trigger PMTiles rebuild
const { firePmtilesRebuild } = await import("./pmtiles-webhook");
await firePmtilesRebuild("auto-refresh-complete", { uatCount: processed, errors });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[auto-refresh] Eroare generala: ${msg}`);
} finally {
g.__parcelSyncRunning = false;
}
}
/* ------------------------------------------------------------------ */
/* Weekend deep sync wrapper */
/* ------------------------------------------------------------------ */
async function runWeekendCheck() {
if (g.__parcelSyncRunning) return;
if (!isWeekendWindow()) return;
g.__parcelSyncRunning = true;
try {
await runWeekendDeepSync();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[weekend-sync] Eroare: ${msg}`);
} finally {
g.__parcelSyncRunning = false;
}
}
/* ------------------------------------------------------------------ */
/* Start scheduler (once per process) */
/* ------------------------------------------------------------------ */
if (!g.__autoRefreshTimer) {
g.__autoRefreshTimer = setInterval(() => {
// Weekend nights (Fri/Sat/Sun 23-04): deep sync for large cities
// Weekday nights (1-5 AM): incremental refresh for existing data
if (isWeekendWindow()) {
void runWeekendCheck();
} else {
void runAutoRefresh();
}
}, CHECK_INTERVAL_MS);
// Also check once shortly after startup (60s delay to let everything init)
setTimeout(() => {
if (isWeekendWindow()) {
void runWeekendCheck();
} else {
void runAutoRefresh();
}
}, 60_000);
const now = new Date();
console.log(
`[auto-refresh] Scheduler pornit — verificare la fiecare ${CHECK_INTERVAL_MS / 60_000} min`,
);
console.log(
`[auto-refresh] Server time: ${now.toLocaleString("ro-RO")} (TZ=${process.env.TZ ?? "system"}, offset=${now.getTimezoneOffset()}min)`,
);
console.log(
`[auto-refresh] Luni-Vineri ${NIGHT_START_HOUR}:00${NIGHT_END_HOUR}:00: delta sync ALL UATs (quick-count + VALID_FROM + rolling doc)`,
);
console.log(
`[auto-refresh] Weekend Vin/Sam/Dum 23:0004:00: deep sync municipii (forceFullSync)`,
);
console.log(
`[auto-refresh] ETERRA creds: ${process.env.ETERRA_USERNAME ? "OK" : "MISSING"}`,
);
}
@@ -99,10 +99,21 @@ const formatAddress = (item?: any) => {
const address = item?.immovableAddresses?.[0]?.address ?? null;
if (!address) return "-";
const parts: string[] = [];
if (address.addressDescription) parts.push(address.addressDescription);
if (address.street) parts.push(`Str. ${address.street}`);
if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
if (address.locality?.name) parts.push(address.locality.name);
if (address.addressDescription) parts.push(String(address.addressDescription));
// street can be a string or an object { name: "..." }
const streetName =
typeof address.street === "string"
? address.street
: address.street?.name ?? null;
if (streetName) parts.push(`Str. ${streetName}`);
if (address.streetNumber) parts.push(`Nr. ${address.streetNumber}`);
else if (address.buildingNo) parts.push(`Nr. ${address.buildingNo}`);
// locality can be a string or an object { name: "..." }
const localityName =
typeof address.locality === "string"
? address.locality
: address.locality?.name ?? null;
if (localityName) parts.push(localityName);
return parts.length ? parts.join(", ") : "-";
};
@@ -124,6 +135,23 @@ export type FeatureEnrichment = {
CATEGORIE_FOLOSINTA: string;
HAS_BUILDING: number;
BUILD_LEGAL: number;
// Extended fields (extracted from existing API calls, zero overhead)
/** "Intabulare, drept de PROPRIETATE, dobandit prin..." */
TIP_INSCRIERE?: string;
/** "hotarare judecatoreasca nr..." / "contract vanzare cumparare nr..." */
ACT_PROPRIETATE?: string;
/** "1/1" or fractional */
COTA_PROPRIETATE?: string;
/** Date of registration application (ISO) */
DATA_CERERE?: string;
/** Number of building bodies on this parcel */
NR_CORPURI?: number;
/** Comma-separated list: "C1:352mp, C2:248mp, C3:104mp" */
CORPURI_DETALII?: string;
/** 1 if condominium, 0 otherwise */
IS_CONDOMINIUM?: number;
/** Date parcel was created in eTerra (ISO) */
DATA_CREARE?: string;
};
/**
@@ -153,6 +181,242 @@ export async function enrichFeatures(
};
try {
// ── Quick delta check: skip ALL eTerra API calls if every feature is enriched & fresh ──
const _thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const [_totalCount, _unenrichedCount] = await Promise.all([
prisma.gisFeature.count({
where: { layerId: "TERENURI_ACTIVE", siruta },
}),
prisma.gisFeature.count({
where: {
layerId: "TERENURI_ACTIVE",
siruta,
OR: [
{ enrichedAt: null },
{ enrichedAt: { lt: _thirtyDaysAgo } },
],
},
}),
]);
if (_totalCount > 0 && _unenrichedCount === 0) {
// ── Rolling doc check: probe oldest-enriched features for new applications ──
// VALID_FROM doesn't track documentation changes (ownership, CF).
// Check 200 oldest-enriched parcels' documentation for recent activity.
// If any have new registrations since enrichedAt → mark for re-enrichment.
const ROLLING_BATCH = 200;
const oldestEnriched = await prisma.gisFeature.findMany({
where: {
layerId: "TERENURI_ACTIVE",
siruta,
enrichedAt: { not: null },
objectId: { gt: 0 },
},
select: {
id: true,
objectId: true,
attributes: true,
cadastralRef: true,
enrichedAt: true,
enrichment: true,
},
orderBy: { enrichedAt: "asc" },
take: ROLLING_BATCH,
});
if (oldestEnriched.length > 0) {
options?.onProgress?.(0, _totalCount, "Verificare documentație recentă");
// Resolve workspace PK for doc fetch
let rollingWsPk: number | null = null;
for (const f of oldestEnriched) {
const ws = (f.attributes as Record<string, unknown>).WORKSPACE_ID;
if (ws != null) {
const n = Number(ws);
if (Number.isFinite(n) && n > 0) { rollingWsPk = n; break; }
}
}
if (!rollingWsPk) {
try {
const row = await prisma.gisUat.findUnique({
where: { siruta },
select: { workspacePk: true },
});
if (row?.workspacePk && row.workspacePk > 0)
rollingWsPk = row.workspacePk;
} catch { /* ignore */ }
}
let rollingUpdated = 0;
if (rollingWsPk) {
// Collect immovable PKs for the batch + map immPk → feature data
const rollingPks: string[] = [];
const enrichedAtMap = new Map<string, Date>();
const immPkToFeatures = new Map<
string,
Array<{ id: string; enrichment: Record<string, unknown> | null }>
>();
for (const f of oldestEnriched) {
const a = f.attributes as Record<string, unknown>;
const immId = normalizeId(a.IMMOVABLE_ID);
if (immId && f.enrichedAt) {
rollingPks.push(immId);
enrichedAtMap.set(immId, f.enrichedAt);
const existing = immPkToFeatures.get(immId) ?? [];
existing.push({
id: f.id,
enrichment: (f as { enrichment?: Record<string, unknown> | null })
.enrichment ?? null,
});
immPkToFeatures.set(immId, existing);
}
}
// Fetch documentation in batches of 50 — detect AND resolve changes in-place
const DOC_BATCH = 50;
for (let i = 0; i < rollingPks.length; i += DOC_BATCH) {
const batch = rollingPks.slice(i, i + DOC_BATCH);
try {
const docResp = await client.fetchDocumentationData(
rollingWsPk,
batch,
);
const regs: Array<{
landbookIE?: number;
nodeType?: string;
nodeName?: string;
nodeStatus?: number;
application?: { appDate?: number };
}> = docResp?.partTwoRegs ?? [];
const docImmovables: Array<{
immovablePk?: number;
landbookIE?: number;
}> = docResp?.immovables ?? [];
// Map landbookIE → immovablePk
const lbToImm = new Map<string, string>();
for (const di of docImmovables) {
if (di.landbookIE && di.immovablePk)
lbToImm.set(
String(di.landbookIE),
normalizeId(di.immovablePk),
);
}
// Collect max appDate + owner names per immovablePk
const immToMaxApp = new Map<string, number>();
const ownersByImm = new Map<string, string[]>();
for (const reg of regs) {
const lb = reg.landbookIE ? String(reg.landbookIE) : "";
const immPk = lb ? lbToImm.get(lb) : undefined;
if (!immPk) continue;
const appDate = reg.application?.appDate;
if (typeof appDate === "number" && appDate > 0) {
const c = immToMaxApp.get(immPk) ?? 0;
if (appDate > c) immToMaxApp.set(immPk, appDate);
}
// Collect current owner names (nodeType=P, not radiated)
if (
String(reg.nodeType ?? "").toUpperCase() === "P" &&
reg.nodeName &&
(reg.nodeStatus ?? 0) >= 0
) {
const owners = ownersByImm.get(immPk) ?? [];
const name = String(reg.nodeName).trim();
if (name && !owners.includes(name)) owners.push(name);
ownersByImm.set(immPk, owners);
}
}
// Update features where appDate > enrichedAt — merge into existing enrichment
const now = new Date();
for (const [immPk, maxApp] of immToMaxApp) {
const enrichedAt = enrichedAtMap.get(immPk);
if (!enrichedAt || maxApp <= enrichedAt.getTime()) continue;
const features = immPkToFeatures.get(immPk) ?? [];
const owners = ownersByImm.get(immPk) ?? [];
const ownerStr = owners.join("; ") || "-";
const appDateIso = new Date(maxApp)
.toISOString()
.slice(0, 10);
for (const feat of features) {
// Merge: keep existing enrichment, update doc-based fields
const existing = feat.enrichment ?? {};
const merged = {
...existing,
PROPRIETARI: ownerStr,
DATA_CERERE: appDateIso,
};
await prisma.gisFeature.update({
where: { id: feat.id },
data: {
enrichment:
merged as unknown as Prisma.InputJsonValue,
enrichedAt: now,
},
});
rollingUpdated++;
}
}
// Touch enrichedAt on checked features (even if unchanged) to rotate the batch
const checkedIds = batch
.flatMap((pk) => (immPkToFeatures.get(pk) ?? []).map((f) => f.id));
if (checkedIds.length > 0) {
await prisma.gisFeature.updateMany({
where: { id: { in: checkedIds }, enrichedAt: { not: null } },
data: { enrichedAt: now },
});
}
} catch (err) {
console.warn(
`[enrich] Rolling doc check batch failed:`,
err instanceof Error ? err.message : err,
);
}
}
}
// Always return early — rolling check is self-contained
const rollingNote = rollingUpdated > 0
? `Rolling: ${rollingUpdated} parcele actualizate`
: "Date deja complete";
console.log(
`[enrich] siruta=${siruta}: ${rollingNote} (checked ${oldestEnriched.length})`,
);
options?.onProgress?.(
_totalCount,
_totalCount,
`Îmbogățire — ${rollingNote}`,
);
return {
siruta,
enrichedCount: _totalCount,
totalFeatures: _totalCount,
unenrichedCount: 0,
buildingCrossRefs: rollingUpdated,
status: "done",
};
} else {
// No enriched features to check — early bailout
options?.onProgress?.(
_totalCount,
_totalCount,
"Îmbogățire — date deja complete",
);
return {
siruta,
enrichedCount: _totalCount,
totalFeatures: _totalCount,
unenrichedCount: 0,
buildingCrossRefs: 0,
status: "done",
};
}
}
console.log(
`[enrich] siruta=${siruta}: ${_unenrichedCount}/${_totalCount} features need enrichment`,
);
// Load terenuri and cladiri from DB
const terenuri = await prisma.gisFeature.findMany({
where: { layerId: "TERENURI_ACTIVE", siruta },
@@ -226,9 +490,14 @@ export async function enrichFeatures(
/* ignore */
}
}
// If still null, enrichment will fail gracefully with empty lists
const workspacePkForApi = resolvedWsPk ?? 65;
console.log(`[enrich] siruta=${siruta} workspacePk=${workspacePkForApi}`);
if (!resolvedWsPk) {
console.warn(
`[enrich] siruta=${siruta}: workspace nu s-a rezolvat, folosesc fallback PK=${workspacePkForApi}`,
);
} else {
console.log(`[enrich] siruta=${siruta} workspacePk=${workspacePkForApi}`);
}
push({
phase: "Pregătire îmbogățire",
@@ -282,6 +551,10 @@ export async function enrichFeatures(
if (baseRef) add(baseRef);
}
console.log(
`[enrich] siruta=${siruta}: ${terenuri.length} terenuri, ${cladiri.length} cladiri in DB, ${buildingMap.size} chei in buildingMap`,
);
// ── Fetch immovable list from eTerra ──
push({ phase: "Descărcare listă imobile", downloaded: 0 });
const immovableListById = new Map<string, any>();
@@ -334,10 +607,62 @@ export async function enrichFeatures(
listPage += 1;
}
// ── Fetch documentation/owner data ──
if (immovableListById.size === 0) {
console.warn(
`[enrich] siruta=${siruta}: lista de imobile e GOALĂ (workspace=${workspacePkForApi}). ` +
`Enrichment va continua dar toate parcelele vor avea date goale. ` +
`Verifică workspace-ul corect pentru acest UAT.`,
);
} else {
console.log(
`[enrich] siruta=${siruta}: ${immovableListById.size} imobile găsite`,
);
}
// ── Targeted doc fetch: only for features that need enrichment ──
// Pre-filter: which immovable PKs actually need documentation?
const allImmPks = Array.from(immovableListById.keys());
const neededDocPks = new Set<string>();
for (const f of terenuri) {
if (f.enrichedAt != null) {
const ej = f.enrichment as Record<string, unknown> | null;
const _core = [
"NR_CAD", "NR_CF", "PROPRIETARI", "PROPRIETARI_VECHI",
"ADRESA", "CATEGORIE_FOLOSINTA", "HAS_BUILDING",
];
const ok =
ej != null &&
_core.every((k) => k in ej && ej[k] !== undefined) &&
["NR_CF", "PROPRIETARI", "ADRESA", "CATEGORIE_FOLOSINTA"].some(
(k) => ej[k] !== "-" && ej[k] !== "",
) &&
!Object.values(ej).some(
(v) => typeof v === "string" && v.includes("[object Object]"),
) &&
Date.now() - new Date(f.enrichedAt).getTime() <=
30 * 24 * 60 * 60 * 1000;
if (ok) continue; // Already complete — skip doc fetch for this one
}
const fa = f.attributes as Record<string, unknown>;
const fImmKey = normalizeId(fa.IMMOVABLE_ID);
const fCadKey = normalizeCadRef(f.cadastralRef ?? "");
const fItem =
(fImmKey ? immovableListById.get(fImmKey) : undefined) ??
(fCadKey ? immovableListByCad.get(fCadKey) : undefined);
if (fItem?.immovablePk)
neededDocPks.add(normalizeId(fItem.immovablePk));
}
// Use targeted set if we identified specific PKs, otherwise fall back to all
const immovableIds =
neededDocPks.size > 0 ? [...neededDocPks] : allImmPks;
console.log(
`[enrich] siruta=${siruta}: doc fetch for ${immovableIds.length}/${allImmPks.length} immovables (${neededDocPks.size > 0 ? "targeted" : "full"})`,
);
push({ phase: "Descărcare documentații CF" });
const docByImmovable = new Map<string, any>();
const immovableIds = Array.from(immovableListById.keys());
// Store raw registrations per landbookIE for extended enrichment fields
const regsByLandbook = new Map<string, any[]>();
const docBatchSize = 50;
for (let i = 0; i < immovableIds.length; i += docBatchSize) {
const batch = immovableIds.slice(i, i + docBatchSize);
@@ -353,6 +678,13 @@ export async function enrichFeatures(
const nodeMap = new Map<number, any>();
for (const reg of regs) {
if (reg?.nodeId != null) nodeMap.set(Number(reg.nodeId), reg);
// Store all registrations by landbookIE for extended enrichment
if (reg?.landbookIE) {
const lbKey = String(reg.landbookIE);
const existing = regsByLandbook.get(lbKey) ?? [];
existing.push(reg);
regsByLandbook.set(lbKey, existing);
}
}
// Check if an entry or any ancestor "I" inscription is radiated
const isRadiated = (entry: any, depth = 0): boolean => {
@@ -392,22 +724,48 @@ export async function enrichFeatures(
const attrs = feature.attributes as Record<string, unknown>;
// Skip features with complete enrichment (resume after crash/interruption).
// Re-enrich if enrichment schema is incomplete (e.g., missing PROPRIETARI_VECHI
// added in a later version).
// Re-enrich if: schema incomplete, values are all "-" (empty), or older than 30 days.
if (feature.enrichedAt != null) {
const enrichJson = feature.enrichment as Record<string, unknown> | null;
const isComplete =
// Structural check: all 7 core fields must exist
const coreFields = [
"NR_CAD",
"NR_CF",
"PROPRIETARI",
"PROPRIETARI_VECHI",
"ADRESA",
"CATEGORIE_FOLOSINTA",
"HAS_BUILDING",
];
const structurallyComplete =
enrichJson != null &&
[
"NR_CAD",
"NR_CF",
"PROPRIETARI",
"PROPRIETARI_VECHI",
"ADRESA",
"CATEGORIE_FOLOSINTA",
"HAS_BUILDING",
].every((k) => k in enrichJson && enrichJson[k] !== undefined);
if (isComplete) {
coreFields.every((k) => k in enrichJson && enrichJson[k] !== undefined);
// Value check: at least some fields must have real data (not just "-")
// A feature with ALL text fields === "-" is considered empty and needs re-enrichment
const valueFields = ["NR_CF", "PROPRIETARI", "ADRESA", "CATEGORIE_FOLOSINTA"];
const hasRealValues =
enrichJson != null &&
valueFields.some(
(k) =>
k in enrichJson &&
enrichJson[k] !== undefined &&
enrichJson[k] !== "-" &&
enrichJson[k] !== "",
);
// Corruption check: re-enrich if any field contains "[object Object]"
const hasCorruptedValues =
enrichJson != null &&
Object.values(enrichJson).some(
(v) => typeof v === "string" && v.includes("[object Object]"),
);
// Age check: re-enrich if older than 30 days (catches eTerra updates)
const ageMs = Date.now() - new Date(feature.enrichedAt).getTime();
const isTooOld = ageMs > 30 * 24 * 60 * 60 * 1000;
if (structurallyComplete && hasRealValues && !isTooOld && !hasCorruptedValues) {
enrichedCount += 1;
if (index % 50 === 0) {
options?.onProgress?.(
@@ -418,9 +776,12 @@ export async function enrichFeatures(
}
continue;
}
// Stale enrichment — will be re-enriched below
// Incomplete, empty, or stale — will be re-enriched below
}
// Per-feature try-catch: one feature failing should not abort the whole UAT
try {
const immovableId = attrs.IMMOVABLE_ID ?? "";
const workspaceId = attrs.WORKSPACE_ID ?? "";
const applicationId = (attrs.APPLICATION_ID as number) ?? null;
@@ -474,13 +835,17 @@ export async function enrichFeatures(
const folKey = `${workspaceId}:${immovableId}:${appId}`;
let fol = folCache.get(folKey);
if (!fol) {
fol = await throttled(() =>
client.fetchParcelFolosinte(
workspaceId as string | number,
immovableId as string | number,
appId,
),
);
try {
fol = await throttled(() =>
client.fetchParcelFolosinte(
workspaceId as string | number,
immovableId as string | number,
appId,
),
);
} catch {
fol = [];
}
folCache.set(folKey, fol);
}
if (fol && fol.length > 0) {
@@ -576,6 +941,59 @@ export async function enrichFeatures(
: null);
}
// Extended fields — extracted from existing data, zero extra API calls
let tipInscriere = "";
let actProprietate = "";
let cotaProprietate = "";
let dataCerere = "";
// Extract registration details from already-fetched documentation
const lbKey = landbookIE || cadRefRaw;
const regsForParcel = regsByLandbook.get(String(lbKey)) ?? [];
for (const reg of regsForParcel) {
const nt = String(reg?.nodeType ?? "").toUpperCase();
const nn = String(reg?.nodeName ?? "").trim();
if (nt === "I" && nn && !tipInscriere) {
tipInscriere = nn;
const quota = reg?.registration?.actualQuota;
if (quota) cotaProprietate = String(quota);
}
if (nt === "A" && nn && !actProprietate) {
actProprietate = nn;
}
if (nt === "C" && !dataCerere) {
const appDate = reg?.application?.appDate;
if (typeof appDate === "number" && appDate > 0) {
dataCerere = new Date(appDate).toISOString().slice(0, 10);
}
}
}
// Building body details from local DB cladiri
const cadRefBase = baseCadRef(cadRefRaw);
let nrCorpuri = 0;
const corpuriParts: string[] = [];
for (const cFeature of cladiri) {
const cAttrs = cFeature.attributes as Record<string, unknown>;
const cRef = String(cAttrs.NATIONAL_CADASTRAL_REFERENCE ?? "");
if (baseCadRef(cRef) === cadRefBase && cRef.includes("-")) {
nrCorpuri++;
const suffix = cRef.slice(cRef.lastIndexOf("-") + 1);
const cArea = typeof cAttrs.AREA_VALUE === "number" ? cAttrs.AREA_VALUE : 0;
corpuriParts.push(`${suffix}:${Math.round(cArea)}mp`);
}
}
// Condominium status and creation date from documentation
const docImmovable = docKey ? docByImmovable.get(docKey) : undefined;
const isCondominium = Number(
(docImmovable as Record<string, unknown>)?.isCondominium ?? 0,
);
const createdDtm = attrs.CREATED_DTM;
const dataCreare =
typeof createdDtm === "number" && createdDtm > 0
? new Date(createdDtm).toISOString().slice(0, 10)
: "";
const enrichment: FeatureEnrichment = {
NR_CAD: cadRefRaw,
NR_CF: nrCF,
@@ -589,8 +1007,16 @@ export async function enrichFeatures(
SOLICITANT: solicitant,
INTRAVILAN: intravilan,
CATEGORIE_FOLOSINTA: categorie,
HAS_BUILDING: hasBuilding,
HAS_BUILDING: hasBuilding || (nrCorpuri > 0 ? 1 : 0),
BUILD_LEGAL: buildLegal,
TIP_INSCRIERE: tipInscriere || undefined,
ACT_PROPRIETATE: actProprietate || undefined,
COTA_PROPRIETATE: cotaProprietate || undefined,
DATA_CERERE: dataCerere || undefined,
NR_CORPURI: nrCorpuri,
CORPURI_DETALII: corpuriParts.length > 0 ? corpuriParts.join(", ") : undefined,
IS_CONDOMINIUM: isCondominium,
DATA_CREARE: dataCreare || undefined,
};
// Store enrichment in DB
@@ -603,6 +1029,16 @@ export async function enrichFeatures(
});
enrichedCount += 1;
} catch (featureErr) {
// Log and continue — don't abort the whole UAT
const cadRef = (attrs.NATIONAL_CADASTRAL_REFERENCE ?? "?") as string;
const msg = featureErr instanceof Error ? featureErr.message : String(featureErr);
console.warn(
`[enrich] Feature ${index + 1}/${terenuri.length} (cad=${cadRef}) failed: ${msg}`,
);
}
if (index % 10 === 0) {
push({
phase: "Îmbogățire parcele",
@@ -78,16 +78,16 @@ type SessionEntry = {
};
const globalStore = globalThis as {
__eterraSessionStore?: Map<string, SessionEntry>;
__eterraCleanupTimer?: ReturnType<typeof setInterval>;
__eterraClientCache?: Map<string, SessionEntry>;
__eterraClientCleanupTimer?: ReturnType<typeof setInterval>;
};
const sessionStore =
globalStore.__eterraSessionStore ?? new Map<string, SessionEntry>();
globalStore.__eterraSessionStore = sessionStore;
globalStore.__eterraClientCache ?? new Map<string, SessionEntry>();
globalStore.__eterraClientCache = sessionStore;
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
if (!globalStore.__eterraCleanupTimer) {
globalStore.__eterraCleanupTimer = setInterval(() => {
if (!globalStore.__eterraClientCleanupTimer) {
globalStore.__eterraClientCleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of sessionStore.entries()) {
if (now - entry.lastUsed > 9 * 60_000) {
@@ -130,7 +130,7 @@ export class EterraClient {
private maxRetries: number;
private username: string;
private password: string;
private reloginAttempted = false;
private cacheKey: string;
private layerFieldsCache = new Map<string, string[]>();
private constructor(
@@ -147,6 +147,7 @@ export class EterraClient {
this.username = username;
this.password = password;
this.maxRetries = maxRetries;
this.cacheKey = makeCacheKey(username, password);
}
/* ---- Factory --------------------------------------------------- */
@@ -297,6 +298,81 @@ export class EterraClient {
return this.countLayerWithParams(layer, params, true);
}
/* ---- Incremental sync: fetch only OBJECTIDs -------------------- */
async fetchObjectIds(layer: LayerConfig, siruta: string): Promise<number[]> {
const where = await this.buildWhere(layer, siruta);
return this.fetchObjectIdsByWhere(layer, where);
}
async fetchObjectIdsByWhere(
layer: LayerConfig,
where: string,
): Promise<number[]> {
const params = new URLSearchParams();
params.set("f", "json");
params.set("where", where);
params.set("returnIdsOnly", "true");
const data = await this.queryLayer(layer, params, false);
return data.objectIds ?? [];
}
async fetchObjectIdsByGeometry(
layer: LayerConfig,
geometry: EsriGeometry,
): Promise<number[]> {
const params = new URLSearchParams();
params.set("f", "json");
params.set("where", "1=1");
params.set("returnIdsOnly", "true");
this.applyGeometryParams(params, geometry);
const data = await this.queryLayer(layer, params, true);
return data.objectIds ?? [];
}
/* ---- Fetch specific features by OBJECTID list ------------------- */
async fetchFeaturesByObjectIds(
layer: LayerConfig,
objectIds: number[],
options?: {
baseWhere?: string;
outFields?: string;
returnGeometry?: boolean;
onProgress?: ProgressCallback;
delayMs?: number;
},
): Promise<EsriFeature[]> {
if (objectIds.length === 0) return [];
const chunkSize = 500;
const all: EsriFeature[] = [];
const total = objectIds.length;
for (let i = 0; i < objectIds.length; i += chunkSize) {
const chunk = objectIds.slice(i, i + chunkSize);
const idList = chunk.join(",");
const idWhere = `OBJECTID IN (${idList})`;
const where = options?.baseWhere
? `(${options.baseWhere}) AND ${idWhere}`
: idWhere;
try {
const features = await this.fetchAllLayerByWhere(layer, where, {
outFields: options?.outFields ?? "*",
returnGeometry: options?.returnGeometry ?? true,
delayMs: options?.delayMs ?? 200,
});
all.push(...features);
} catch (err) {
// Log but continue with remaining chunks — partial results better than none
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[fetchFeaturesByObjectIds] Chunk ${Math.floor(i / chunkSize) + 1} failed (${chunk.length} IDs): ${msg}`,
);
}
options?.onProgress?.(all.length, total);
}
return all;
}
async listLayer(
layer: LayerConfig,
siruta: string,
@@ -844,8 +920,7 @@ export class EterraClient {
);
} catch (error) {
const err = error as AxiosError;
if (err?.response?.status === 401 && !this.reloginAttempted) {
this.reloginAttempted = true;
if (err?.response?.status === 401) {
await this.login(this.username, this.password);
response = await this.requestWithRetry(() =>
this.client.get(url, { timeout: this.timeoutMs }),
@@ -909,23 +984,28 @@ export class EterraClient {
);
}
/** Touch session TTL in global store (prevents expiry during long pagination) */
private touchSession(): void {
const cached = sessionStore.get(this.cacheKey);
if (cached) cached.lastUsed = Date.now();
}
private async requestJson(
request: () => Promise<{
data: EsriQueryResponse | string;
status: number;
}>,
): Promise<EsriQueryResponse> {
this.touchSession();
let response;
try {
response = await this.requestWithRetry(request);
} catch (error) {
const err = error as AxiosError;
if (err?.response?.status === 401 && !this.reloginAttempted) {
this.reloginAttempted = true;
if (err?.response?.status === 401) {
// Always attempt relogin on 401 (session may expire multiple times during long syncs)
await this.login(this.username, this.password);
response = await this.requestWithRetry(request);
} else if (err?.response?.status === 401) {
throw new Error("Session expired (401)");
} else throw error;
}
const data = response.data as EsriQueryResponse | string;
@@ -944,17 +1024,15 @@ export class EterraClient {
private async requestRaw<T = any>(
request: () => Promise<{ data: T | string; status: number }>,
): Promise<T> {
this.touchSession();
let response;
try {
response = await this.requestWithRetry(request);
} catch (error) {
const err = error as AxiosError;
if (err?.response?.status === 401 && !this.reloginAttempted) {
this.reloginAttempted = true;
if (err?.response?.status === 401) {
await this.login(this.username, this.password);
response = await this.requestWithRetry(request);
} else if (err?.response?.status === 401) {
throw new Error("Session expired (401)");
} else throw error;
}
const data = response.data as T | string;
@@ -175,3 +175,33 @@ export const buildGpkg = async (options: GpkgBuildOptions): Promise<Buffer> => {
await fs.rm(tmpDir, { recursive: true, force: true });
return buffer;
};
/**
* Convert a GPKG buffer to DXF using ogr2ogr.
* Returns null if ogr2ogr is not available or conversion fails.
*/
export const gpkgToDxf = async (
gpkgBuffer: Buffer,
layerName: string,
): Promise<Buffer | null> => {
if (!hasOgr2Ogr()) return null;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "eterra-dxf-"));
const gpkgPath = path.join(tmpDir, "input.gpkg");
const dxfPath = path.join(tmpDir, `${layerName}.dxf`);
try {
await fs.writeFile(gpkgPath, gpkgBuffer);
await runOgr(
["-f", "DXF", dxfPath, gpkgPath, layerName],
{ ...process.env, OGR_CT_FORCE_TRADITIONAL_GIS_ORDER: "YES" },
);
const buffer = Buffer.from(await fs.readFile(dxfPath));
return buffer;
} catch {
// DXF conversion failed — not critical
return null;
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
};
@@ -537,9 +537,12 @@ export async function syncNoGeometryParcels(
}
if (staleIds.length > 0) {
await prisma.gisFeature.deleteMany({
where: { id: { in: staleIds } },
});
const BATCH = 30_000;
for (let i = 0; i < staleIds.length; i += BATCH) {
await prisma.gisFeature.deleteMany({
where: { id: { in: staleIds.slice(i, i + BATCH) } },
});
}
console.log(
`[no-geom-sync] Cleanup: removed ${staleIds.length} stale/invalid no-geom records`,
);
@@ -0,0 +1,43 @@
/**
* Shared helper triggers PMTiles rebuild via webhook after sync operations.
* The webhook server (pmtiles-webhook systemd service on satra) runs
* `docker run architools-tippecanoe` to regenerate overview tiles.
*/
const WEBHOOK_URL = process.env.N8N_WEBHOOK_URL || "";
export async function firePmtilesRebuild(
event: string,
metadata?: Record<string, unknown>,
): Promise<{ ok: boolean; alreadyRunning?: boolean }> {
if (!WEBHOOK_URL) {
console.warn("[pmtiles-webhook] N8N_WEBHOOK_URL not configured — skipping rebuild trigger");
return { ok: false };
}
try {
const res = await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event,
timestamp: new Date().toISOString(),
...metadata,
}),
});
if (res.ok) {
console.log(`[pmtiles-webhook] Rebuild triggered (event: ${event}, HTTP ${res.status})`);
return { ok: true };
}
if (res.status === 409) {
console.log(`[pmtiles-webhook] Rebuild already running (event: ${event})`);
return { ok: true, alreadyRunning: true };
}
console.warn(`[pmtiles-webhook] Webhook returned HTTP ${res.status}`);
return { ok: false };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[pmtiles-webhook] Failed: ${msg}`);
return { ok: false };
}
}
+232 -52
View File
@@ -7,7 +7,7 @@
import { Prisma, PrismaClient } from "@prisma/client";
import { EterraClient } from "./eterra-client";
import type { LayerConfig } from "./eterra-client";
import type { LayerConfig, EsriFeature } from "./eterra-client";
import { esriToGeojson } from "./esri-geojson";
import { findLayerById, type LayerCatalogItem } from "./eterra-layers";
import { fetchUatGeometry } from "./uat-geometry";
@@ -29,6 +29,8 @@ export type SyncResult = {
totalLocal: number;
newFeatures: number;
removedFeatures: number;
/** Features with VALID_FROM changed (attribute update, no new OBJECTID) */
validFromUpdated?: number;
status: "done" | "error";
error?: string;
};
@@ -116,50 +118,144 @@ export async function syncLayer(
uatGeometry = await fetchUatGeometry(client, siruta);
}
// Count remote features
push({ phase: "Numărare remote" });
let remoteCount: number;
try {
remoteCount = uatGeometry
? await client.countLayerByGeometry(layer, uatGeometry)
: await client.countLayer(layer, siruta);
} catch {
remoteCount = 0;
}
push({ phase: "Verificare locală", total: remoteCount });
// Get local OBJECTIDs for this layer+siruta
// Get local OBJECTIDs for this layer+siruta (only positive — skip no-geom)
push({ phase: "Verificare locală" });
const localFeatures = await prisma.gisFeature.findMany({
where: { layerId, siruta },
where: { layerId, siruta, objectId: { gt: 0 } },
select: { objectId: true },
});
const localObjIds = new Set(localFeatures.map((f) => f.objectId));
// Fetch all remote features
push({ phase: "Descărcare features", downloaded: 0, total: remoteCount });
// ── Quick-count check: if remote count == local count, skip full OBJECTID fetch ──
// Just do VALID_FROM delta for attribute changes (handled after download section).
let remoteCount = 0;
let remoteObjIds = new Set<number>();
let newObjIdArray: number[] = [];
let removedObjIds: number[] = [];
let useFullSync = false;
let quickCountMatch = false;
const allRemote = uatGeometry
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
total: remoteCount > 0 ? remoteCount : undefined,
onProgress: (dl, tot) =>
push({ phase: "Descărcare features", downloaded: dl, total: tot }),
delayMs: 200,
})
: await client.fetchAllLayerByWhere(
layer,
await buildWhere(client, layer, siruta),
{
if (!options?.forceFullSync && localObjIds.size > 0) {
push({ phase: "Verificare count remote" });
let qCount = -1;
try {
qCount = uatGeometry
? await client.countLayerByGeometry(layer, uatGeometry)
: await client.countLayer(layer, siruta);
} catch {
// Count check is best-effort — fall through to OBJECTID comparison
qCount = -1;
}
if (qCount >= 0 && qCount === localObjIds.size) {
// Counts match — very likely no new/removed features
quickCountMatch = true;
remoteCount = qCount;
remoteObjIds = localObjIds; // Treat as identical
newObjIdArray = [];
removedObjIds = [];
useFullSync = false;
console.log(
`[sync] Quick-count match: ${qCount} remote = ${localObjIds.size} local for ${layerId}/${siruta} — skipping OBJECTID fetch`,
);
}
}
if (!quickCountMatch) {
// Full OBJECTID comparison (original path)
push({ phase: "Comparare ID-uri remote" });
let remoteObjIdArray: number[];
try {
remoteObjIdArray = uatGeometry
? await client.fetchObjectIdsByGeometry(layer, uatGeometry)
: await client.fetchObjectIds(layer, siruta);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[syncLayer] fetchObjectIds failed for ${layerId}/${siruta}: ${msg} — falling back to full sync`,
);
remoteObjIdArray = [];
}
remoteObjIds = new Set(remoteObjIdArray);
remoteCount = remoteObjIds.size;
// Compute delta
newObjIdArray = [...remoteObjIds].filter((id) => !localObjIds.has(id));
removedObjIds = [...localObjIds].filter(
(id) => !remoteObjIds.has(id),
);
// Decide: incremental (download only delta) or full sync
const deltaRatio =
remoteCount > 0 ? newObjIdArray.length / remoteCount : 1;
useFullSync =
options?.forceFullSync ||
localObjIds.size === 0 ||
deltaRatio > 0.5;
}
let allRemote: EsriFeature[];
if (useFullSync) {
// Full sync: download all features (first sync or forced)
push({
phase: "Descărcare features (complet)",
downloaded: 0,
total: remoteCount,
});
allRemote = uatGeometry
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
total: remoteCount > 0 ? remoteCount : undefined,
onProgress: (dl, tot) =>
push({
phase: "Descărcare features",
phase: "Descărcare features (complet)",
downloaded: dl,
total: tot,
}),
delayMs: 200,
},
);
})
: await client.fetchAllLayerByWhere(
layer,
await buildWhere(client, layer, siruta),
{
total: remoteCount > 0 ? remoteCount : undefined,
onProgress: (dl, tot) =>
push({
phase: "Descărcare features (complet)",
downloaded: dl,
total: tot,
}),
delayMs: 200,
},
);
} else if (newObjIdArray.length > 0) {
// Incremental sync: download only the new features
push({
phase: "Descărcare features noi",
downloaded: 0,
total: newObjIdArray.length,
});
const baseWhere = uatGeometry
? undefined
: await buildWhere(client, layer, siruta);
allRemote = await client.fetchFeaturesByObjectIds(
layer,
newObjIdArray,
{
baseWhere,
onProgress: (dl, tot) =>
push({
phase: "Descărcare features noi",
downloaded: dl,
total: tot,
}),
delayMs: 200,
},
);
} else {
// Nothing new to download
allRemote = [];
}
// Convert to GeoJSON for geometry storage
const geojson = esriToGeojson(allRemote);
@@ -169,19 +265,11 @@ export async function syncLayer(
if (objId != null) geojsonByObjId.set(objId, f);
}
// Determine which OBJECTIDs are new
const remoteObjIds = new Set<number>();
for (const f of allRemote) {
const objId = f.attributes.OBJECTID as number | undefined;
if (objId != null) remoteObjIds.add(objId);
}
// For incremental sync, newObjIds = the delta we downloaded
// For full sync, newObjIds = all remote (if forced) or only truly new
const newObjIds = options?.forceFullSync
? remoteObjIds
: new Set([...remoteObjIds].filter((id) => !localObjIds.has(id)));
const removedObjIds = [...localObjIds].filter(
(id) => !remoteObjIds.has(id),
);
: new Set(newObjIdArray);
push({
phase: "Salvare în baza de date",
@@ -269,16 +357,107 @@ export async function syncLayer(
// PostGIS not available yet — not critical, skip silently
}
// Mark removed features
if (removedObjIds.length > 0) {
// Mark removed features (batch to avoid PostgreSQL 32767 bind variable limit)
// Safety: if remote returned very few features compared to local, the session
// likely expired mid-sync — skip deletion to avoid wiping valid data.
const removedRatio = localObjIds.size > 0 ? removedObjIds.length / localObjIds.size : 0;
if (removedObjIds.length > 0 && removedRatio > 0.8 && localObjIds.size > 100) {
console.warn(
`[sync] SKIP delete: ${removedObjIds.length}/${localObjIds.size} features (${Math.round(removedRatio * 100)}%) ` +
`would be removed for ${layerId}/${siruta} — likely stale remote data. Aborting deletion.`,
);
} else if (removedObjIds.length > 0) {
push({ phase: "Marcare șterse" });
await prisma.gisFeature.deleteMany({
where: {
layerId,
siruta,
objectId: { in: removedObjIds },
},
});
const BATCH = 30_000;
for (let i = 0; i < removedObjIds.length; i += BATCH) {
await prisma.gisFeature.deleteMany({
where: {
layerId,
siruta,
objectId: { in: removedObjIds.slice(i, i + BATCH) },
},
});
}
}
// ── VALID_FROM delta: detect attribute changes on existing features ──
// Features whose VALID_FROM changed since our stored copy need re-enrichment.
// This catches ownership/CF changes that don't create new OBJECTIDs.
let validFromUpdated = 0;
if (!useFullSync && newObjIdArray.length === 0 && removedObjIds.length === 0) {
// Nothing new/removed — check if existing features changed via VALID_FROM
// Fetch the max VALID_FROM we have stored locally
const maxValidFrom = await prisma.$queryRawUnsafe<
Array<{ max_vf: string | null }>
>(
`SELECT MAX((attributes->>'VALID_FROM')::bigint)::text as max_vf ` +
`FROM "GisFeature" WHERE "layerId" = $1 AND siruta = $2 AND "objectId" > 0`,
layerId,
siruta,
);
const localMaxVf = maxValidFrom[0]?.max_vf;
if (localMaxVf) {
// Ask eTerra: any features with VALID_FROM > our max?
const baseWhere = await buildWhere(client, layer, siruta);
const vfWhere = `${baseWhere} AND VALID_FROM>${localMaxVf}`;
try {
const changed = uatGeometry
? await client.fetchAllLayerByWhere(
layer,
`VALID_FROM>${localMaxVf}`,
{
outFields: "*",
returnGeometry: true,
delayMs: 200,
geometry: uatGeometry,
},
)
: await client.fetchAllLayerByWhere(layer, vfWhere, {
outFields: "*",
returnGeometry: true,
delayMs: 200,
});
if (changed.length > 0) {
push({ phase: `Actualizare ${changed.length} parcele modificate` });
const changedGeojson = esriToGeojson(changed);
const changedGeoMap = new Map<
number,
(typeof changedGeojson.features)[0]
>();
for (const f of changedGeojson.features) {
const objId = f.properties.OBJECTID as number | undefined;
if (objId != null) changedGeoMap.set(objId, f);
}
for (const feature of changed) {
const objId = feature.attributes.OBJECTID as number;
if (!objId) continue;
const geoFeature = changedGeoMap.get(objId);
const geom = geoFeature?.geometry;
await prisma.gisFeature.updateMany({
where: { layerId, objectId: objId },
data: {
attributes: feature.attributes as Prisma.InputJsonValue,
geometry: geom
? (geom as Prisma.InputJsonValue)
: undefined,
enrichedAt: null, // Force re-enrichment
updatedAt: new Date(),
},
});
}
validFromUpdated = changed.length;
console.log(
`[sync] VALID_FROM delta: ${changed.length} features updated for ${layerId}/${siruta}`,
);
}
} catch (err) {
// Non-critical — VALID_FROM check is best-effort
console.warn(
`[sync] VALID_FROM check failed for ${layerId}/${siruta}:`,
err instanceof Error ? err.message : err,
);
}
}
}
// Update sync run
@@ -312,6 +491,7 @@ export async function syncLayer(
totalLocal: localCount,
newFeatures: newObjIds.size,
removedFeatures: removedObjIds.length,
validFromUpdated,
status: "done",
};
} catch (error) {
@@ -0,0 +1,695 @@
/**
* Weekend Deep Sync full Magic processing for large cities.
*
* Runs Fri/Sat/Sun nights 23:0004:00. Processes cities in round-robin
* (one step per city, then rotate) so progress is spread across cities.
* State is persisted in KeyValueStore survives restarts and continues
* across multiple nights/weekends.
*
* Steps per city (each is resumable):
* 1. sync_terenuri syncLayer TERENURI_ACTIVE
* 2. sync_cladiri syncLayer CLADIRI_ACTIVE
* 3. import_nogeom import parcels without geometry
* 4. enrich enrichFeatures (slowest, naturally resumable)
*/
import { PrismaClient, Prisma } from "@prisma/client";
import { syncLayer } from "./sync-service";
import { EterraClient } from "./eterra-client";
import { isEterraAvailable } from "./eterra-health";
import { sendEmail } from "@/core/notifications/email-service";
const prisma = new PrismaClient();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ */
/* Live activity tracking (globalThis — same process) */
/* ------------------------------------------------------------------ */
const g = globalThis as {
__weekendSyncActivity?: {
city: string;
step: string;
startedAt: string;
} | null;
__parcelSyncRunning?: boolean;
};
export function getWeekendSyncActivity() {
return g.__weekendSyncActivity ?? null;
}
/* ------------------------------------------------------------------ */
/* City queue configuration */
/* ------------------------------------------------------------------ */
export type CityConfig = {
siruta: string;
name: string;
county: string;
priority: number; // lower = higher priority
};
/** Initial queue — priority 1 = first processed */
const DEFAULT_CITIES: CityConfig[] = [
{ siruta: "54975", name: "Cluj-Napoca", county: "Cluj", priority: 1 },
{ siruta: "32394", name: "Bistri\u021Ba", county: "Bistri\u021Ba-N\u0103s\u0103ud", priority: 1 },
{ siruta: "114319", name: "T\u00E2rgu Mure\u0219", county: "Mure\u0219", priority: 2 },
{ siruta: "139704", name: "Zal\u0103u", county: "S\u0103laj", priority: 2 },
{ siruta: "26564", name: "Oradea", county: "Bihor", priority: 2 },
{ siruta: "9262", name: "Arad", county: "Arad", priority: 2 },
{ siruta: "155243", name: "Timi\u0219oara", county: "Timi\u0219", priority: 2 },
{ siruta: "143450", name: "Sibiu", county: "Sibiu", priority: 2 },
{ siruta: "40198", name: "Bra\u0219ov", county: "Bra\u0219ov", priority: 2 },
];
/* ------------------------------------------------------------------ */
/* Step definitions */
/* ------------------------------------------------------------------ */
const STEPS = [
"sync_terenuri",
"sync_cladiri",
"import_nogeom",
"enrich",
] as const;
type StepName = (typeof STEPS)[number];
type StepStatus = "pending" | "done" | "error";
/* ------------------------------------------------------------------ */
/* Persisted state */
/* ------------------------------------------------------------------ */
type CityState = {
siruta: string;
name: string;
county: string;
priority: number;
steps: Record<StepName, StepStatus>;
lastActivity?: string;
errorMessage?: string;
};
type WeekendSyncState = {
cities: CityState[];
lastSessionDate?: string;
totalSessions: number;
completedCycles: number; // how many full cycles (all cities done)
};
const KV_NAMESPACE = "parcel-sync-weekend";
const KV_KEY = "queue-state";
async function loadState(): Promise<WeekendSyncState> {
const row = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
});
if (row?.value && typeof row.value === "object") {
return row.value as unknown as WeekendSyncState;
}
// Initialize with default cities
return {
cities: DEFAULT_CITIES.map((c) => ({
...c,
steps: {
sync_terenuri: "pending",
sync_cladiri: "pending",
import_nogeom: "pending",
enrich: "pending",
},
})),
totalSessions: 0,
completedCycles: 0,
};
}
async function saveState(state: WeekendSyncState): Promise<void> {
// Retry once on failure — state persistence is critical for resume
for (let attempt = 0; attempt < 2; attempt++) {
try {
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
update: { value: state as unknown as Prisma.InputJsonValue },
create: {
namespace: KV_NAMESPACE,
key: KV_KEY,
value: state as unknown as Prisma.InputJsonValue,
},
});
return;
} catch (err) {
if (attempt === 0) {
console.warn("[weekend-sync] saveState retry...");
await sleep(2000);
} else {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[weekend-sync] saveState failed: ${msg}`);
}
}
}
}
/* ------------------------------------------------------------------ */
/* Time window */
/* ------------------------------------------------------------------ */
const WEEKEND_START_HOUR = 23;
const WEEKEND_END_HOUR = 4;
const PAUSE_BETWEEN_STEPS_MS = 60_000 + Math.random() * 60_000; // 60-120s
/** Check if current time is within the weekend sync window */
export function isWeekendWindow(): boolean {
const now = new Date();
const day = now.getDay(); // 0=Sun, 5=Fri, 6=Sat
const hour = now.getHours();
// Fri 23:00+ or Sat 23:00+ or Sun 23:00+
if ((day === 5 || day === 6 || day === 0) && hour >= WEEKEND_START_HOUR) {
return true;
}
// Sat 00-04 (continuation of Friday night) or Sun 00-04 or Mon 00-04
if ((day === 6 || day === 0 || day === 1) && hour < WEEKEND_END_HOUR) {
return true;
}
return false;
}
/** Check if still within the window (called during processing) */
function stillInWindow(force?: boolean): boolean {
if (force) return true; // Manual trigger — no time restriction
const hour = new Date().getHours();
// We can be in 23,0,1,2,3 — stop at 4
if (hour >= WEEKEND_END_HOUR && hour < WEEKEND_START_HOUR) return false;
return isWeekendWindow();
}
/* ------------------------------------------------------------------ */
/* Step executors */
/* ------------------------------------------------------------------ */
async function executeStep(
city: CityState,
step: StepName,
client: EterraClient,
): Promise<{ success: boolean; message: string }> {
const start = Date.now();
switch (step) {
case "sync_terenuri": {
const res = await syncLayer(
process.env.ETERRA_USERNAME!,
process.env.ETERRA_PASSWORD!,
city.siruta,
"TERENURI_ACTIVE",
{ uatName: city.name, forceFullSync: true },
);
// Also sync admin layers (lightweight, non-fatal)
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
try {
await syncLayer(
process.env.ETERRA_USERNAME!,
process.env.ETERRA_PASSWORD!,
city.siruta,
adminLayer,
{ uatName: city.name },
);
} catch {
// admin layers are best-effort
}
}
const dur = ((Date.now() - start) / 1000).toFixed(1);
return {
success: res.status === "done",
message: `Terenuri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) + intravilan [${dur}s]`,
};
}
case "sync_cladiri": {
const res = await syncLayer(
process.env.ETERRA_USERNAME!,
process.env.ETERRA_PASSWORD!,
city.siruta,
"CLADIRI_ACTIVE",
{ uatName: city.name, forceFullSync: true },
);
const dur = ((Date.now() - start) / 1000).toFixed(1);
return {
success: res.status === "done",
message: `Cl\u0103diri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) [${dur}s]`,
};
}
case "import_nogeom": {
const { syncNoGeometryParcels } = await import("./no-geom-sync");
const res = await syncNoGeometryParcels(client, city.siruta);
const dur = ((Date.now() - start) / 1000).toFixed(1);
return {
success: res.status !== "error",
message: `No-geom: ${res.imported} importate, ${res.skipped} skip [${dur}s]`,
};
}
case "enrich": {
const { enrichFeatures } = await import("./enrich-service");
const res = await enrichFeatures(client, city.siruta);
const dur = ((Date.now() - start) / 1000).toFixed(1);
return {
success: res.status === "done",
message: res.status === "done"
? `Enrichment: ${res.enrichedCount}/${res.totalFeatures ?? "?"} (${dur}s)`
: `Enrichment eroare: ${res.error ?? "necunoscuta"} (${dur}s)`,
};
}
}
}
/* ------------------------------------------------------------------ */
/* Main runner */
/* ------------------------------------------------------------------ */
type SessionLog = {
city: string;
step: string;
success: boolean;
message: string;
};
export async function runWeekendDeepSync(options?: {
force?: boolean;
onlySteps?: StepName[];
}): Promise<void> {
const force = options?.force ?? false;
const activeSteps = options?.onlySteps ?? STEPS;
const username = process.env.ETERRA_USERNAME;
const password = process.env.ETERRA_PASSWORD;
if (!username || !password) return;
if (!isEterraAvailable()) {
console.log("[weekend-sync] eTerra indisponibil, skip.");
return;
}
const state = await loadState();
const today = new Date().toISOString().slice(0, 10);
// Prevent running twice in the same session (force bypasses)
if (!force && state.lastSessionDate === today) return;
state.totalSessions++;
state.lastSessionDate = today;
// Ensure new default cities are added if config expanded
for (const dc of DEFAULT_CITIES) {
if (!state.cities.some((c) => c.siruta === dc.siruta)) {
state.cities.push({
...dc,
steps: {
sync_terenuri: "pending",
sync_cladiri: "pending",
import_nogeom: "pending",
enrich: "pending",
},
});
}
}
const sessionStart = Date.now();
const log: SessionLog[] = [];
let stepsCompleted = 0;
console.log(
`[weekend-sync] Sesiune #${state.totalSessions} pornita. ${state.cities.length} orase in coada.`,
);
// Sort cities: priority first, then shuffle within same priority
const sorted = [...state.cities].sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return Math.random() - 0.5; // random within same priority
});
// Round-robin: iterate through steps, for each step iterate through cities
for (const stepName of activeSteps) {
// Find cities that still need this step
const needsStep = sorted.filter((c) => c.steps[stepName] === "pending");
if (needsStep.length === 0) continue;
for (const city of needsStep) {
// Check time window
if (!stillInWindow(force)) {
console.log("[weekend-sync] Fereastra s-a inchis, opresc.");
g.__weekendSyncActivity = null;
await saveState(state);
await sendStatusEmail(state, log, sessionStart);
return;
}
// Check eTerra health
if (!isEterraAvailable()) {
console.log("[weekend-sync] eTerra indisponibil, opresc.");
g.__weekendSyncActivity = null;
await saveState(state);
await sendStatusEmail(state, log, sessionStart);
return;
}
// Pause between steps
if (stepsCompleted > 0) {
const pause = 60_000 + Math.random() * 60_000;
console.log(
`[weekend-sync] Pauza ${Math.round(pause / 1000)}s inainte de ${city.name} / ${stepName}`,
);
await sleep(pause);
}
// Execute step — fresh client per step (sessions expire after ~10 min)
console.log(`[weekend-sync] ${city.name}: ${stepName}...`);
g.__weekendSyncActivity = {
city: city.name,
step: stepName,
startedAt: new Date().toISOString(),
};
try {
const client = await EterraClient.create(username, password);
const result = await executeStep(city, stepName, client);
city.steps[stepName] = result.success ? "done" : "error";
if (!result.success) {
city.errorMessage = result.message;
await sendStepErrorEmail(city, stepName, result.message);
}
city.lastActivity = new Date().toISOString();
log.push({
city: city.name,
step: stepName,
success: result.success,
message: result.message,
});
console.log(
`[weekend-sync] ${city.name}: ${stepName}${result.success ? "OK" : "EROARE"}${result.message}`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
city.steps[stepName] = "error";
city.errorMessage = msg;
city.lastActivity = new Date().toISOString();
log.push({
city: city.name,
step: stepName,
success: false,
message: msg,
});
console.error(
`[weekend-sync] ${city.name}: ${stepName} EROARE: ${msg}`,
);
await sendStepErrorEmail(city, stepName, msg);
}
g.__weekendSyncActivity = null;
stepsCompleted++;
// Save state after each step (crash safety)
await saveState(state);
}
}
// Check if all cities completed all steps → new cycle
const allDone = state.cities.every((c) =>
STEPS.every((s) => c.steps[s] === "done"),
);
if (allDone) {
state.completedCycles++;
// Reset for next cycle
for (const city of state.cities) {
for (const step of STEPS) {
city.steps[step] = "pending";
}
}
console.log(
`[weekend-sync] Ciclu complet #${state.completedCycles}! Reset pentru urmatorul ciclu.`,
);
// Notify N8N to rebuild PMTiles (overview tiles for geoportal)
await fireSyncWebhook(state.completedCycles);
}
await saveState(state);
await sendStatusEmail(state, log, sessionStart);
console.log(`[weekend-sync] Sesiune finalizata. ${stepsCompleted} pasi executati.`);
}
/* ------------------------------------------------------------------ */
/* Immediate error email */
/* ------------------------------------------------------------------ */
async function sendStepErrorEmail(
city: CityState,
step: StepName,
errorMsg: string,
): Promise<void> {
const emailTo = process.env.WEEKEND_SYNC_EMAIL;
if (!emailTo) return;
try {
const now = new Date();
const timeStr = now.toLocaleString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const stepLabel: Record<StepName, string> = {
sync_terenuri: "Sync Terenuri",
sync_cladiri: "Sync Cladiri",
import_nogeom: "Import No-Geom",
enrich: "Enrichment",
};
const html = `
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto">
<h2 style="color:#ef4444;margin-bottom:4px">Weekend Sync Eroare</h2>
<p style="color:#6b7280;margin-top:0">${timeStr}</p>
<table style="border-collapse:collapse;width:100%;border:1px solid #fecaca;border-radius:6px;background:#fef2f2">
<tr>
<td style="padding:8px 12px;font-weight:600;color:#374151">Oras</td>
<td style="padding:8px 12px">${city.name} (${city.county})</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:600;color:#374151">Pas</td>
<td style="padding:8px 12px">${stepLabel[step]}</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:600;color:#374151">Eroare</td>
<td style="padding:8px 12px;color:#dc2626;word-break:break-word">${errorMsg}</td>
</tr>
</table>
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
Generat automat de ArchiTools Weekend Sync
</p>
</div>
`;
await sendEmail({
to: emailTo,
subject: `[ArchiTools] WDS Eroare: ${city.name}${stepLabel[step]}`,
html,
});
console.log(`[weekend-sync] Email eroare trimis: ${city.name}/${step}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[weekend-sync] Nu s-a putut trimite email eroare: ${msg}`);
}
}
/* ------------------------------------------------------------------ */
/* Email status report */
/* ------------------------------------------------------------------ */
async function sendStatusEmail(
state: WeekendSyncState,
log: SessionLog[],
sessionStart: number,
): Promise<void> {
const emailTo = process.env.WEEKEND_SYNC_EMAIL;
if (!emailTo) return;
try {
const duration = Date.now() - sessionStart;
const durMin = Math.round(duration / 60_000);
const durStr =
durMin >= 60
? `${Math.floor(durMin / 60)}h ${durMin % 60}m`
: `${durMin}m`;
const now = new Date();
const dayNames = [
"Duminic\u0103",
"Luni",
"Mar\u021Bi",
"Miercuri",
"Joi",
"Vineri",
"S\u00E2mb\u0103t\u0103",
];
const dayName = dayNames[now.getDay()] ?? "";
const dateStr = now.toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
// Build city progress table
const cityRows = state.cities
.sort((a, b) => a.priority - b.priority)
.map((c) => {
const doneCount = STEPS.filter((s) => c.steps[s] === "done").length;
const errorCount = STEPS.filter((s) => c.steps[s] === "error").length;
const icon =
doneCount === STEPS.length
? "\u2713"
: doneCount > 0
? "\u25D0"
: "\u25CB";
const color =
doneCount === STEPS.length
? "#22c55e"
: errorCount > 0
? "#ef4444"
: doneCount > 0
? "#f59e0b"
: "#9ca3af";
const stepDetail = STEPS.map(
(s) =>
`<span style="color:${c.steps[s] === "done" ? "#22c55e" : c.steps[s] === "error" ? "#ef4444" : "#9ca3af"}">${s.replace("_", " ")}</span>`,
).join(" \u2192 ");
return `<tr>
<td style="padding:4px 8px;color:${color};font-size:16px">${icon}</td>
<td style="padding:4px 8px;font-weight:600">${c.name}</td>
<td style="padding:4px 8px;color:#6b7280;font-size:12px">${c.county}</td>
<td style="padding:4px 8px">${doneCount}/${STEPS.length}</td>
<td style="padding:4px 8px;font-size:11px">${stepDetail}</td>
</tr>`;
})
.join("\n");
// Build session log
const logRows =
log.length > 0
? log
.map(
(l) =>
`<tr>
<td style="padding:2px 6px;font-size:12px">${l.success ? "\u2713" : "\u2717"}</td>
<td style="padding:2px 6px;font-size:12px">${l.city}</td>
<td style="padding:2px 6px;font-size:12px;color:#6b7280">${l.step}</td>
<td style="padding:2px 6px;font-size:11px;color:#6b7280">${l.message}</td>
</tr>`,
)
.join("\n")
: '<tr><td colspan="4" style="padding:8px;color:#9ca3af;font-size:12px">Niciun pas executat in aceasta sesiune</td></tr>';
const html = `
<div style="font-family:system-ui,sans-serif;max-width:700px;margin:0 auto">
<h2 style="color:#1f2937;margin-bottom:4px">Weekend Sync ${dayName} ${dateStr}</h2>
<p style="color:#6b7280;margin-top:0">Durata sesiune: ${durStr} | Sesiunea #${state.totalSessions} | Cicluri complete: ${state.completedCycles}</p>
<h3 style="color:#374151;margin-bottom:8px">Progres per ora\u0219</h3>
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb;border-radius:6px">
<thead><tr style="background:#f9fafb">
<th style="padding:6px 8px;text-align:left;font-size:12px"></th>
<th style="padding:6px 8px;text-align:left;font-size:12px">Ora\u0219</th>
<th style="padding:6px 8px;text-align:left;font-size:12px">Jude\u021B</th>
<th style="padding:6px 8px;text-align:left;font-size:12px">Pa\u0219i</th>
<th style="padding:6px 8px;text-align:left;font-size:12px">Detaliu</th>
</tr></thead>
<tbody>${cityRows}</tbody>
</table>
<h3 style="color:#374151;margin-top:16px;margin-bottom:8px">Activitate sesiune curent\u0103</h3>
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb">
<tbody>${logRows}</tbody>
</table>
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
Generat automat de ArchiTools Weekend Sync
</p>
</div>
`;
await sendEmail({
to: emailTo,
subject: `[ArchiTools] Weekend Sync — ${dayName} ${dateStr}`,
html,
});
console.log(`[weekend-sync] Email status trimis la ${emailTo}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[weekend-sync] Nu s-a putut trimite email: ${msg}`);
}
}
/* ------------------------------------------------------------------ */
/* Manual force trigger */
/* ------------------------------------------------------------------ */
/**
* Trigger a sync run outside the weekend window.
* Resets error steps, clears lastSessionDate, and starts immediately.
* Uses an extended night window (22:0005:00) for the stillInWindow check.
*/
export async function triggerForceSync(options?: {
onlySteps?: StepName[];
}): Promise<{ started: boolean; reason?: string }> {
if (g.__parcelSyncRunning) {
return { started: false, reason: "O sincronizare ruleaza deja" };
}
const username = process.env.ETERRA_USERNAME;
const password = process.env.ETERRA_PASSWORD;
if (!username || !password) {
return { started: false, reason: "ETERRA credentials lipsesc" };
}
if (!isEterraAvailable()) {
return { started: false, reason: "eTerra indisponibil" };
}
// Reset error steps + lastSessionDate in DB so the run proceeds
const state = await loadState();
for (const city of state.cities) {
for (const step of STEPS) {
if (city.steps[step] === "error") {
city.steps[step] = "pending";
city.errorMessage = undefined;
}
}
}
state.lastSessionDate = undefined;
await saveState(state);
// Start in background — don't block the API response
g.__parcelSyncRunning = true;
void (async () => {
try {
const stepNames = options?.onlySteps;
console.log(
`[weekend-sync] Force sync declansat manual.${stepNames ? ` Steps: ${stepNames.join(", ")}` : ""}`,
);
await runWeekendDeepSync({ force: true, onlySteps: stepNames });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[weekend-sync] Force sync eroare: ${msg}`);
} finally {
g.__parcelSyncRunning = false;
}
})();
return { started: true };
}
/* ------------------------------------------------------------------ */
/* PMTiles Webhook — trigger rebuild after sync cycle */
/* ------------------------------------------------------------------ */
async function fireSyncWebhook(cycle: number): Promise<void> {
const { firePmtilesRebuild } = await import("./pmtiles-webhook");
await firePmtilesRebuild("weekend-sync-cycle-complete", { cycle });
}
@@ -166,11 +166,21 @@ export function RegistryEntryDetail({
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
const [copiedPath, setCopiedPath] = useState<string | null>(null);
const [monitorConfigOpen, setMonitorConfigOpen] = useState(false);
const [monitorEditMode, setMonitorEditMode] = useState(false);
// Auto-detect if recipient matches a known authority
// Authority for existing tracking or auto-detected from recipient
const trackingAuthority = useMemo(() => {
if (!entry) return undefined;
if (entry.externalStatusTracking) {
return getAuthority(entry.externalStatusTracking.authorityId) ?? undefined;
}
return undefined;
}, [entry]);
// Auto-detect if recipient matches a known authority (only when no tracking)
const matchedAuthority = useMemo(() => {
if (!entry) return undefined;
if (entry.externalStatusTracking?.active) return undefined;
if (entry.externalStatusTracking) return undefined;
if (!entry.recipientRegNumber) return undefined;
return findAuthorityForContact(entry.recipient);
}, [entry]);
@@ -757,14 +767,47 @@ export function RegistryEntryDetail({
)}
{/* ── External status monitoring ── */}
{entry.externalStatusTracking?.active && (
<ExternalStatusSection
entry={entry}
/>
{entry.externalStatusTracking && (
<>
<ExternalStatusSection
entry={entry}
onEdit={() => {
setMonitorEditMode(true);
setMonitorConfigOpen(true);
}}
/>
{trackingAuthority && (
<StatusMonitorConfig
open={monitorConfigOpen && monitorEditMode}
onOpenChange={(open) => {
setMonitorConfigOpen(open);
if (!open) setMonitorEditMode(false);
}}
entry={entry}
authority={trackingAuthority}
editMode
onActivate={async (tracking) => {
try {
await fetch("/api/registratura", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: entry.id,
updates: { externalStatusTracking: tracking },
}),
});
window.location.reload();
} catch {
// Best effort
}
}}
/>
)}
</>
)}
{/* ── Auto-detect: suggest monitoring activation ── */}
{matchedAuthority && !entry.externalStatusTracking?.active && (
{matchedAuthority && !entry.externalStatusTracking && (
<div className="rounded-lg border border-dashed border-blue-300 dark:border-blue-700 bg-blue-50/50 dark:bg-blue-950/20 p-3">
<div className="flex items-start gap-2">
<Radio className="h-4 w-4 text-blue-500 mt-0.5 shrink-0" />
@@ -780,7 +823,10 @@ export function RegistryEntryDetail({
variant="outline"
size="sm"
className="mt-2 h-6 text-xs"
onClick={() => setMonitorConfigOpen(true)}
onClick={() => {
setMonitorEditMode(false);
setMonitorConfigOpen(true);
}}
>
Configureaza monitorizarea
</Button>
@@ -788,12 +834,11 @@ export function RegistryEntryDetail({
</div>
<StatusMonitorConfig
open={monitorConfigOpen}
open={monitorConfigOpen && !monitorEditMode}
onOpenChange={setMonitorConfigOpen}
entry={entry}
authority={matchedAuthority}
onActivate={async (tracking) => {
// Save tracking to entry via API
try {
await fetch("/api/registratura", {
method: "PUT",
@@ -892,26 +937,55 @@ const STATUS_COLORS: Record<ExternalDocStatus, string> = {
necunoscut: "bg-muted text-muted-foreground",
};
function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
function ExternalStatusSection({
entry,
onEdit,
}: {
entry: RegistryEntry;
onEdit: () => void;
}) {
const tracking = entry.externalStatusTracking;
if (!tracking) return null;
const [checking, setChecking] = useState(false);
const [toggling, setToggling] = useState(false);
const [checkResult, setCheckResult] = useState<{
changed: boolean;
error: string | null;
newStatus?: string;
} | null>(null);
const [showHistory, setShowHistory] = useState(false);
const [liveTracking, setLiveTracking] = useState(tracking);
const authority = getAuthority(tracking.authorityId);
const handleManualCheck = useCallback(async () => {
setChecking(true);
setCheckResult(null);
try {
await fetch("/api/registratura/status-check/single", {
const res = await fetch("/api/registratura/status-check/single", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ entryId: entry.id }),
});
// Reload page to show updated status
window.location.reload();
} catch {
// Ignore — user will see if it worked on reload
const data = (await res.json()) as {
changed: boolean;
error: string | null;
newStatus?: string;
tracking?: typeof tracking;
};
setCheckResult({
changed: data.changed,
error: data.error,
newStatus: data.newStatus,
});
if (data.tracking) {
setLiveTracking(data.tracking);
}
} catch (err) {
setCheckResult({
changed: false,
error: err instanceof Error ? err.message : "Eroare conexiune",
});
} finally {
setChecking(false);
}
@@ -928,80 +1002,154 @@ function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
return `acum ${days}z`;
};
const handleToggleActive = useCallback(async () => {
setToggling(true);
try {
const updated = { ...liveTracking, active: !liveTracking.active };
await fetch("/api/registratura", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: entry.id,
updates: { externalStatusTracking: updated },
}),
});
setLiveTracking(updated);
} catch {
// Best effort
} finally {
setToggling(false);
}
}, [entry.id, liveTracking]);
const t = liveTracking;
return (
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Monitorizare status extern
{!t.active && (
<span className="ml-1.5 text-[10px] font-normal normal-case">(oprita)</span>
)}
</h3>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleManualCheck}
disabled={checking}
>
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
{checking ? "Se verifică..." : "Verifică acum"}
</Button>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={onEdit}
>
<Pencil className="h-3 w-3 mr-1" />
Modifica
</Button>
<Button
variant="ghost"
size="sm"
className={cn("h-6 px-2 text-xs", !t.active && "text-green-600")}
onClick={handleToggleActive}
disabled={toggling}
>
{t.active ? (
<><BellOff className="h-3 w-3 mr-1" />Opreste</>
) : (
<><Bell className="h-3 w-3 mr-1" />Reactiveaza</>
)}
</Button>
{t.active && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={handleManualCheck}
disabled={checking}
>
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
{checking ? "Se verifica..." : "Verifica acum"}
</Button>
)}
</div>
</div>
{/* Inline check result */}
{checkResult && (
<div className={cn(
"rounded border px-2.5 py-1.5 text-xs mb-2",
checkResult.error
? "border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-400"
: checkResult.changed
? "border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-400"
: "border-muted bg-muted/30 text-muted-foreground",
)}>
{checkResult.error
? `Eroare: ${checkResult.error}`
: checkResult.changed
? `Status actualizat: ${EXTERNAL_STATUS_LABELS[checkResult.newStatus as ExternalDocStatus] ?? checkResult.newStatus}`
: "Nicio schimbare detectata"}
</div>
)}
<div className="space-y-2">
{/* Authority + status badge */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">
{authority?.name ?? tracking.authorityId}
{authority?.name ?? t.authorityId}
</span>
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[tracking.semanticStatus])}>
<Badge className={cn("text-[10px] px-1.5 py-0", STATUS_COLORS[t.semanticStatus])}>
<Radio className="mr-0.5 inline h-2.5 w-2.5" />
{EXTERNAL_STATUS_LABELS[tracking.semanticStatus]}
{EXTERNAL_STATUS_LABELS[t.semanticStatus]}
</Badge>
</div>
{/* Last check time */}
{tracking.lastCheckAt && (
{t.lastCheckAt && (
<p className="text-[10px] text-muted-foreground">
Ultima verificare: {relativeTime(tracking.lastCheckAt)}
Ultima verificare: {relativeTime(t.lastCheckAt)}
</p>
)}
{/* Error state */}
{tracking.lastError && (
<p className="text-[10px] text-red-500">{tracking.lastError}</p>
{t.lastError && (
<p className="text-[10px] text-red-500">{t.lastError}</p>
)}
{/* Latest status row */}
{tracking.lastStatusRow && (
{t.lastStatusRow && (
<div className="rounded border bg-muted/30 p-2 text-xs space-y-1">
<div className="flex gap-3">
<span>
<span className="text-muted-foreground">Sursa:</span>{" "}
{tracking.lastStatusRow.sursa}
{t.lastStatusRow.sursa}
</span>
<span>
<span className="text-muted-foreground"></span>{" "}
{tracking.lastStatusRow.destinatie}
{t.lastStatusRow.destinatie}
</span>
</div>
{tracking.lastStatusRow.modRezolvare && (
{t.lastStatusRow.modRezolvare && (
<div>
<span className="text-muted-foreground">Rezolvare:</span>{" "}
<span className="font-medium">{tracking.lastStatusRow.modRezolvare}</span>
<span className="font-medium">{t.lastStatusRow.modRezolvare}</span>
</div>
)}
{tracking.lastStatusRow.comentarii && (
{t.lastStatusRow.comentarii && (
<div className="text-muted-foreground">
{tracking.lastStatusRow.comentarii}
{t.lastStatusRow.comentarii}
</div>
)}
<div className="text-muted-foreground">
{tracking.lastStatusRow.dataVenire} {tracking.lastStatusRow.oraVenire}
{t.lastStatusRow.dataVenire} {t.lastStatusRow.oraVenire}
</div>
</div>
)}
{/* Tracking config info */}
<div className="text-[10px] text-muted-foreground">
Nr: {t.regNumber} | Data: {t.regDate} | Deponent: {t.petitionerName}
</div>
{/* History toggle */}
{tracking.history.length > 0 && (
{t.history.length > 0 && (
<div>
<button
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
@@ -1012,12 +1160,12 @@ function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
) : (
<ChevronDown className="h-3 w-3" />
)}
Istoric ({tracking.history.length} schimbări)
Istoric ({t.history.length} schimbari)
</button>
{showHistory && (
<div className="mt-1 space-y-1.5 max-h-48 overflow-y-auto">
{[...tracking.history].reverse().map((change, i) => (
{[...t.history].reverse().map((change, i) => (
<div
key={`${change.timestamp}-${i}`}
className="rounded border bg-muted/20 p-1.5 text-[10px]"
@@ -29,6 +29,8 @@ interface StatusMonitorConfigProps {
entry: RegistryEntry;
authority: AuthorityConfig;
onActivate: (tracking: ExternalStatusTracking) => void;
/** When true, pre-fills from existing tracking data for editing */
editMode?: boolean;
}
export function StatusMonitorConfig({
@@ -37,30 +39,35 @@ export function StatusMonitorConfig({
entry,
authority,
onActivate,
editMode,
}: StatusMonitorConfigProps) {
const existing = entry.externalStatusTracking;
const [petitionerName, setPetitionerName] = useState("");
const [regNumber, setRegNumber] = useState(
entry.recipientRegNumber ?? "",
);
const [regDate, setRegDate] = useState("");
// Convert YYYY-MM-DD to dd.mm.yyyy
// Pre-fill: edit mode uses existing tracking, otherwise entry fields
useEffect(() => {
if (entry.recipientRegDate) {
const parts = entry.recipientRegDate.split("-");
if (parts.length === 3) {
setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`);
if (editMode && existing) {
setPetitionerName(existing.petitionerName);
setRegNumber(existing.regNumber);
setRegDate(existing.regDate);
} else {
setRegNumber(entry.recipientRegNumber ?? "");
if (entry.recipientRegDate) {
const parts = entry.recipientRegDate.split("-");
if (parts.length === 3) {
setRegDate(`${parts[2]}.${parts[1]}.${parts[0]}`);
}
}
const saved = localStorage.getItem(
`status-monitor-petitioner:${authority.id}`,
);
if (saved) setPetitionerName(saved);
}
}, [entry.recipientRegDate]);
// Load saved petitioner name from localStorage
useEffect(() => {
const saved = localStorage.getItem(
`status-monitor-petitioner:${authority.id}`,
);
if (saved) setPetitionerName(saved);
}, [authority.id]);
}, [editMode, existing, entry.recipientRegNumber, entry.recipientRegDate, authority.id]);
const canActivate =
petitionerName.trim().length >= 3 &&
@@ -74,19 +81,28 @@ export function StatusMonitorConfig({
petitionerName.trim(),
);
const tracking: ExternalStatusTracking = {
authorityId: authority.id,
petitionerName: petitionerName.trim(),
regNumber: regNumber.trim(),
regDate: regDate.trim(),
lastCheckAt: null,
lastStatusRow: null,
statusHash: "",
semanticStatus: "necunoscut",
history: [],
active: true,
lastError: null,
};
const tracking: ExternalStatusTracking = editMode && existing
? {
...existing,
petitionerName: petitionerName.trim(),
regNumber: regNumber.trim(),
regDate: regDate.trim(),
active: true,
lastError: null,
}
: {
authorityId: authority.id,
petitionerName: petitionerName.trim(),
regNumber: regNumber.trim(),
regDate: regDate.trim(),
lastCheckAt: null,
lastStatusRow: null,
statusHash: "",
semanticStatus: "necunoscut",
history: [],
active: true,
lastError: null,
};
onActivate(tracking);
onOpenChange(false);
@@ -98,11 +114,12 @@ export function StatusMonitorConfig({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" />
Monitorizare status extern
{editMode ? "Modifica monitorizarea" : "Monitorizare status extern"}
</DialogTitle>
<DialogDescription>
{authority.name} suporta verificarea automata a statusului.
Configureaza datele de mai jos pentru a activa monitorizarea.
{editMode
? "Modifica datele de monitorizare. Istoricul se pastreaza."
: `${authority.name} suporta verificarea automata a statusului. Configureaza datele de mai jos pentru a activa monitorizarea.`}
</DialogDescription>
</DialogHeader>
@@ -152,7 +169,7 @@ export function StatusMonitorConfig({
Anuleaza
</Button>
<Button onClick={handleActivate} disabled={!canActivate}>
Activeaza monitorizarea
{editMode ? "Salveaza" : "Activeaza monitorizarea"}
</Button>
</DialogFooter>
</DialogContent>
@@ -229,6 +229,14 @@ export async function runStatusCheck(
tracking.statusHash = newHash;
tracking.semanticStatus = checkResult.newStatus;
// Auto-deactivate monitoring when resolved or rejected
if (
checkResult.newStatus === "solutionat" ||
checkResult.newStatus === "respins"
) {
tracking.active = false;
}
// Cap history at 50
tracking.history.push(change);
if (tracking.history.length > 50) {
@@ -436,6 +444,15 @@ export async function checkSingleEntry(
tracking.lastStatusRow = result.newRow;
tracking.statusHash = newHash;
tracking.semanticStatus = result.newStatus;
// Auto-deactivate monitoring when resolved or rejected
if (
result.newStatus === "solutionat" ||
result.newStatus === "respins"
) {
tracking.active = false;
}
tracking.history.push(change);
if (tracking.history.length > 50) {
tracking.history = tracking.history.slice(-50);
+2
View File
@@ -13,6 +13,7 @@ import {
import { useAuth } from "@/core/auth";
import { signIn, signOut } from "next-auth/react";
import { ThemeToggle } from "@/shared/components/common/theme-toggle";
import { NotificationBell } from "./notification-bell";
interface HeaderProps {
onToggleSidebar?: () => void;
@@ -35,6 +36,7 @@ export function Header({ onToggleSidebar }: HeaderProps) {
</div>
<div className="flex items-center gap-3">
<NotificationBell />
<ThemeToggle />
<DropdownMenu>
@@ -0,0 +1,168 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Bell, Check, CheckCheck, AlertTriangle } from "lucide-react";
import { Button } from "@/shared/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/shared/components/ui/popover";
import { ScrollArea } from "@/shared/components/ui/scroll-area";
import type { AppNotification } from "@/core/notifications/app-notifications";
const POLL_INTERVAL = 60_000; // 60s
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "acum";
if (mins < 60) return `acum ${mins} min`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `acum ${hours} ore`;
const days = Math.floor(hours / 24);
if (days === 1) return "ieri";
return `acum ${days} zile`;
}
export function NotificationBell() {
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<AppNotification[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const fetchUnreadCount = useCallback(async () => {
try {
const res = await fetch("/api/notifications/app?limit=1");
if (!res.ok) return;
const data = (await res.json()) as { unreadCount: number };
setUnreadCount(data.unreadCount);
} catch { /* ignore */ }
}, []);
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const res = await fetch("/api/notifications/app?limit=30");
if (!res.ok) return;
const data = (await res.json()) as {
notifications: AppNotification[];
unreadCount: number;
};
setNotifications(data.notifications);
setUnreadCount(data.unreadCount);
} catch { /* ignore */ }
setLoading(false);
}, []);
// Poll unread count
useEffect(() => {
fetchUnreadCount();
const id = setInterval(fetchUnreadCount, POLL_INTERVAL);
return () => clearInterval(id);
}, [fetchUnreadCount]);
// Fetch full list when popover opens
useEffect(() => {
if (open) fetchAll();
}, [open, fetchAll]);
const handleMarkRead = async (id: string) => {
await fetch("/api/notifications/app", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "mark-read", id }),
});
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, readAt: new Date().toISOString() } : n)),
);
setUnreadCount((c) => Math.max(0, c - 1));
};
const handleMarkAllRead = async () => {
await fetch("/api/notifications/app", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "mark-all-read" }),
});
setNotifications((prev) =>
prev.map((n) => ({ ...n, readAt: n.readAt ?? new Date().toISOString() })),
);
setUnreadCount(0);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="relative">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-4 min-w-4 rounded-full bg-destructive text-[10px] font-medium text-white flex items-center justify-center px-1">
{unreadCount > 99 ? "99+" : unreadCount}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80 p-0">
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-medium">Notificari</span>
{unreadCount > 0 && (
<button
onClick={handleMarkAllRead}
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
>
<CheckCheck className="h-3 w-3" />
Marcheaza toate ca citite
</button>
)}
</div>
{/* List */}
<ScrollArea className="max-h-80">
{loading && notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Se incarca...
</div>
) : notifications.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
Nicio notificare
</div>
) : (
notifications.map((n) => (
<button
key={n.id}
onClick={() => !n.readAt && handleMarkRead(n.id)}
className={`w-full flex items-start gap-2.5 px-3 py-2.5 text-left border-b border-border/30 last:border-0 hover:bg-muted/50 transition-colors ${
!n.readAt ? "bg-primary/5" : ""
}`}
>
<div className="mt-0.5 shrink-0">
{n.type === "sync-error" ? (
<AlertTriangle className="h-4 w-4 text-destructive" />
) : (
<Check className="h-4 w-4 text-green-500" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className={`text-sm truncate ${!n.readAt ? "font-medium" : ""}`}>
{n.title}
</p>
{!n.readAt && (
<span className="shrink-0 h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-xs text-muted-foreground truncate">{n.message}</p>
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
{relativeTime(n.createdAt)}
</p>
</div>
</button>
))
)}
</ScrollArea>
</PopoverContent>
</Popover>
);
}
+3
View File
@@ -0,0 +1,3 @@
FROM nginx:1.27-alpine
RUN mkdir -p /var/cache/nginx/tiles
COPY nginx/tile-cache.conf /etc/nginx/conf.d/default.conf
+22
View File
@@ -0,0 +1,22 @@
# Stage 1: build tippecanoe from source
FROM alpine:3.20 AS builder
RUN apk add --no-cache git g++ make sqlite-dev zlib-dev bash
RUN git clone --depth 1 https://github.com/felt/tippecanoe.git /src/tippecanoe
WORKDIR /src/tippecanoe
RUN make -j$(nproc) && make install
# Stage 2: runtime with GDAL + tippecanoe + mc
FROM ghcr.io/osgeo/gdal:alpine-normal-latest
COPY --from=builder /usr/local/bin/tippecanoe /usr/local/bin/tippecanoe
COPY --from=builder /usr/local/bin/tile-join /usr/local/bin/tile-join
# Install MinIO client
RUN apk add --no-cache curl bash && \
curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc && \
chmod +x /usr/local/bin/mc
COPY scripts/rebuild-overview-tiles.sh /opt/rebuild.sh
RUN chmod +x /opt/rebuild.sh
ENTRYPOINT ["/opt/rebuild.sh"]