Compare commits

..

86 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
AI Assistant 8f65efd5d1 feat: add /prompts page — Claude Code prompt library
Personal prompt library at /prompts with:
- 6 categories: Module Work, API & Backend, Quality & Security,
  Session & Continue, Documentation & Meta, Quick Actions
- 22 optimized prompt templates for ArchiTools development
- Copy-to-clipboard on every prompt
- One-time prompts with checkbox persistence (localStorage)
- Search/filter across all prompts
- Best practices sidebar (10 tips from Claude Code research)
- Module name quick-copy badges
- Variable placeholders highlighted ({MODULE_NAME}, etc.)
- Deploy prep checklist, debug unknown errors, and more

Not registered as a module — accessible only via direct URL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 07:14:59 +02:00
AI Assistant eab465b8c3 chore: add STIRLING_PDF_URL, STIRLING_PDF_API_KEY, PORTAL_ONLY_USERS to docker-compose
These env vars were previously hardcoded in source code and removed during
the production audit. Now properly configured in docker-compose.yml.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 06:46:38 +02:00
AI Assistant 0c4b91707f audit: production safety fixes, cleanup, and documentation overhaul
CRITICAL fixes:
- Fix SQL injection in geoportal search (template literal in $queryRaw)
- Preserve enrichment data during GIS re-sync (upsert update explicit fields only)
- Fix ePay version race condition (advisory lock in transaction)
- Add requireAuth() to compress-pdf and unlock routes (were unauthenticated)
- Remove hardcoded Stirling PDF API key (env vars now required)

IMPORTANT fixes:
- Add admin role check on registratura debug-sequences endpoint
- Fix reserved slot race condition with advisory lock in transaction
- Use SSO identity in close-guard-dialog instead of hardcoded "Utilizator"
- Storage DELETE catches only P2025 (not found), re-throws real errors
- Add onDelete: SetNull for GisFeature → GisSyncRun relation
- Move portal-only users to PORTAL_ONLY_USERS env var
- Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
- Add periodic cleanup for eTerra/ePay session caches and progress store
- Log warning when ePay dataDocument is missing (expiry fallback)

Cleanup:
- Delete orphaned rgi-test page (1086 lines, unregistered, inaccessible)
- Delete legacy/ folder (5 files, unreferenced from src/)
- Remove unused ensureBucketExists() from minio-client.ts

Documentation:
- Optimize CLAUDE.md: 464 → 197 lines (moved per-module details to docs/)
- Create docs/ARCHITECTURE-QUICK.md (80 lines: data flow, deps, env vars)
- Create docs/MODULE-MAP.md (140 lines: entry points, API routes, cross-deps)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 06:40:34 +02:00
86 changed files with 10726 additions and 3854 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
+123 -389
View File
@@ -1,54 +1,43 @@
# ArchiTools — Project Context for AI Assistants
> This file provides all context needed for Claude Code, Sonnet, or any AI model to work on this project from scratch.
---
## Quick Start
```bash
npm install
npm run dev # http://localhost:3000
npx next build # verify zero errors before pushing
git push origin main # auto-deploys via Portainer webhook
git push origin main # manual redeploy via Portainer UI
```
---
## Project Overview
**ArchiTools** is a modular internal web dashboard for an architecture/engineering office group of 3 companies:
- **Beletage** (architecture)
- **Urban Switch** (urbanism)
- **Studii de Teren** (geotechnics)
It runs on two on-premise servers, containerized with Docker, managed via Portainer CE.
**ArchiTools** is a modular internal web dashboard for 3 architecture/engineering companies:
**Beletage** (architecture), **Urban Switch** (urbanism), **Studii de Teren** (geotechnics).
Production: `tools.beletage.ro` — Docker on-premise, Portainer CE, Traefik v3 proxy.
### Stack
| Layer | Technology |
| ------------ | ---------------------------------------------------------------------------- |
| ---------- | ------------------------------------------------------- |
| Framework | Next.js 16.x, App Router, TypeScript (strict) |
| Styling | Tailwind CSS v4, shadcn/ui |
| Database | PostgreSQL (10.10.10.166:5432) via Prisma v6 ORM |
| Storage | `DatabaseStorageAdapter` (PostgreSQL) — localStorage fallback available |
| File Storage | MinIO (10.10.10.166:9002 API / 9003 UI) — client configured, adapter pending |
| Database | PostgreSQL + PostGIS via Prisma v6 ORM |
| Storage | `DatabaseStorageAdapter` (Prisma), localStorage fallback |
| Files | MinIO (S3-compatible object storage) |
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
| Proxy | Traefik v3 on `10.10.10.199` (proxy server), SSL via Let's Encrypt |
| Deploy | Docker multi-stage, Portainer CE on `10.10.10.166` (satra) |
| Repo | Gitea at `https://git.beletage.ro/gitadmin/ArchiTools` |
| Language | Code in **English**, UI in **Romanian** |
| Deploy | Docker multi-stage → Portainer CE → Traefik v3 + SSL |
| Repo | Gitea at `git.beletage.ro/gitadmin/ArchiTools` |
| Language | Code: **English**, UI: **Romanian** |
### Architecture Principles
- **Module platform, not monolith** — each module isolated with own types/services/hooks/components
- **Feature flags** gate module loading (disabled = zero bundle cost)
- **Storage abstraction**: `StorageService` interface with adapters (database default via Prisma, localStorage fallback)
- **Cross-module tagging system** as shared service
- **Auth via Authentik SSO** — NextAuth v4 + OIDC, group→role/company mapping
- **All entities** include `visibility` / `createdBy` fields from day one
- **Company logos** — theme-aware (light/dark variants), dual-rendered for SSR safety
- **Module platform** — each module isolated: own types/services/hooks/components
- **Feature flags** gate loading (disabled = zero bundle cost)
- **Storage abstraction** via `StorageService` interface + adapters
- **Auth via Authentik SSO** — group → role/company mapping
- **All entities** include `visibility` / `createdBy` from day one
---
@@ -56,245 +45,45 @@ It runs on two on-premise servers, containerized with Docker, managed via Portai
```
src/
├── app/ # Routing only (thin wrappers)
│ ├── (modules)/ # Module route pages
│ └── layout.tsx # App shell
├── core/ # Platform services
│ ├── module-registry/ # Module registration + types
│ ├── feature-flags/ # Flag evaluation + env override
│ ├── storage/ # StorageService + adapters
│ └── adapters/ # localStorage adapter (+ future DB/MinIO)
── tagging/ # Cross-module tag service
│ ├── i18n/ # Romanian translations
│ ├── theme/ # Light/dark theme
│ └── auth/ # Auth types + stub (future Authentik)
├── modules/ # Module business logic
│ ├── <module-name>/
│ │ ├── components/ # Module UI components
│ │ ├── hooks/ # Module-specific hooks
│ │ ├── services/ # Module business logic
│ │ ├── types.ts # Module types
│ │ ├── config.ts # Module metadata
│ │ └── index.ts # Public exports
│ └── ...
├── shared/ # Shared UI
│ ├── components/
│ │ ├── ui/ # shadcn/ui primitives
│ │ ├── layout/ # Sidebar, Header
│ │ └── common/ # Reusable app components
│ ├── hooks/ # Shared hooks
│ └── lib/ # Utils (cn, etc.)
├── config/ # Global config
│ ├── modules.ts # Module registry entries
│ ├── flags.ts # Default feature flags
│ ├── navigation.ts # Sidebar nav structure
│ └── companies.ts # Company definitions
docs/ # 16 internal technical docs
legacy/ # Original HTML tools for reference
├── app/(modules)/ # Route pages (thin wrappers)
├── core/ # Platform: auth, storage, flags, tagging, i18n, theme
├── modules/<name>/ # Module business logic (see MODULE-MAP.md)
├── components/ # UI components
│ ├── hooks/ # Module hooks
│ ├── services/ # Business logic
│ ├── types.ts # Interfaces
├── config.ts # Module metadata
── index.ts # Public exports
├── shared/components/ # ui/ (shadcn), layout/ (sidebar/header), common/
├── config/ # modules.ts, flags.ts, navigation.ts, companies.ts
docs/ # Architecture, guides, module deep-dives
```
---
## Implemented Modules (16 total — 14 original + 2 new)
## Modules (17 total)
| # | Module | Route | Version | Key Features |
| --- | ---------------------- | --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Dashboard** | `/` | 0.1.0 | KPI cards (6), activity feed (last 20), module grid, external tools |
| 2 | **Email Signature** | `/email-signature` | 0.1.0 | Multi-company branding, address toggle (BTG/US/SDT), live preview, zoom/copy/download |
| 3 | **Word XML Generator** | `/word-xml` | 0.1.0 | Category-based XML gen, simple/advanced mode, ZIP export |
| 4 | **Registratura** | `/registratura` | 0.5.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking** (6 categories, 18 types), recipient registration, document expiry, **NAS network path attachments** (A/O/P/T drives, copy-to-clipboard), **detail sheet side panel**, **configurable column visibility**, **QuickLook attachment preview** (images: zoom/pan, PDFs: native viewer, multi-file navigation), **email notifications** (Brevo SMTP daily digest, per-user preferences), **compact registry numbers** (single-letter company badge + direction arrow + plain number) |
| 5 | **Tag Manager** | `/tag-manager` | 0.2.0 | CRUD tags, category/scope/color, US/SDT seeds, mandatory categories, **ManicTime bidirectional sync** |
| 6 | **IT Inventory** | `/it-inventory` | 0.2.0 | Dynamic equipment types, rented status (purple pulse), **42U rack visualization**, type/status/company filters |
| 7 | **Address Book** | `/address-book` | 0.2.0 | CRUD contacts (person OR institution), card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)**, **name OR company required** (flexible validation), **ContactPerson with department field**, **quick contact from Registratura** (persons + institutions) |
| 8 | **Password Vault** | `/password-vault` | 0.4.0 | CRUD credentials, 9 categorii cu iconițe, **WiFi QR code real**, context-aware form, strength meter, company scope, **AES-256-GCM encryption**, **utilizatori multipli per intrare** (VaultUser[]: username/password/email/notes, colapsibil în form, badge în list) |
| 9 | **Mini Utilities** | `/mini-utilities` | 0.4.0 | Text case, char counter, percentage, **TVA calculator (cotă configurabilă: 5/9/19/21% + custom)**, area converter, U→R, num→text, artifact cleaner, MDLPA, **PDF compression** (qpdf local lossless + iLovePDF API cloud lossy, streaming upload for large files), PDF unlock, **DWG→DXF**, OCR, color palette, **paste on all drop zones**, **thermal drag-drop reorder**, **Calculator scară desen** (real cm↔desen mm, 7 preseturi 1:20..1:5000 + custom) |
| 10 | **Prompt Generator** | `/prompt-generator` | 0.2.0 | Template-driven prompt builder, **18 templates** (14 text + 4 image), search bar, target type filter |
| 11 | **Digital Signatures** | `/digital-signatures` | 0.1.0 | CRUD assets, drag-and-drop file upload, tag chips |
| 12 | **Word Templates** | `/word-templates` | 0.1.0 | Template library, 8 categories, version tracking, .docx placeholder auto-detection |
| 13 | **AI Chat** | `/ai-chat` | 0.2.0 | Multi-provider (OpenAI/Claude/Ollama/demo), **project linking via Tag Manager**, provider status badge |
| 14 | **Hot Desk** | `/hot-desk` | 0.1.1 | 4 desks, week-ahead calendar, room layout (window+door proportioned), reserve/cancel |
| 15 | **ParcelSync** | `/parcel-sync` | 0.6.0 | eTerra ANCPI integration, **PostGIS database**, background sync, 23-layer catalog, enrichment pipeline, owner search, **per-UAT analytics dashboard**, **health check + maintenance detection**, **ANCPI ePay CF extract ordering** (batch orders, MinIO PDF storage, dedup protection, credit tracking), **static WORKSPACE_TO_COUNTY mapping**, **GisUat geometry select optimization**, **feature count cache (5-min TTL)** |
| 16 | **Visual Copilot** | `/visual-copilot` | 0.1.0 | AI-powered image analysis — **developed in separate repo** (`https://git.beletage.ro/gitadmin/vim`), placeholder in ArchiTools, will be merged as module later |
| Module | Route | Key Features |
| ------------------ | ------------------- | --------------------------------------------------- |
| Dashboard | `/` | KPI cards, activity feed, module grid |
| Email Signature | `/email-signature` | Multi-company, live preview, copy/download |
| Word XML | `/word-xml` | Category-based XML, simple/advanced, ZIP export |
| Registratura | `/registratura` | Registry CRUD, legal deadlines, notifications, NAS |
| Tag Manager | `/tag-manager` | Tags CRUD, ManicTime sync |
| IT Inventory | `/it-inventory` | Equipment, rack visualization, filters |
| Address Book | `/address-book` | Contacts, vCard, Registratura integration |
| Password Vault | `/password-vault` | AES-256-GCM encrypted, WiFi QR, multi-user |
| Mini Utilities | `/mini-utilities` | 12+ tools: PDF compress, OCR, converters, calc |
| Prompt Generator | `/prompt-generator` | 18 templates, text + image targets |
| Digital Signatures | `/digital-signatures` | Assets CRUD, file upload, tags |
| Word Templates | `/word-templates` | Template library, .docx placeholder detection |
| AI Chat | `/ai-chat` | Multi-provider (OpenAI/Claude/Ollama) |
| Hot Desk | `/hot-desk` | 4 desks, week calendar, room layout |
| ParcelSync | `/parcel-sync` | eTerra ANCPI, PostGIS, enrichment, ePay ordering |
| Geoportal | `/geoportal` | MapLibre viewer, parcel search, UAT layers |
| Visual CoPilot | `/visual-copilot` | Placeholder — separate repo |
### Registratura — Legal Deadline Tracking (Termene Legale)
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
- **18 deadline types** across 6 categories (Certificat, Avize, Completari, Urbanism, Autorizare, Litigii)
- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm)
- **Chain deadlines** (resolving one prompts adding the next — e.g., CU analiza → emitere, PUZ/PUD analiza → post-CTATU → emitere)
- **Tacit approval** (auto-detected when overdue + applicable type)
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
- **Email notifications**: daily digest via Brevo SMTP, per-user opt-in/opt-out preferences, N8N cron trigger
Key files:
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
- `services/deadline-catalog.ts` — 18 `DeadlineTypeDef` entries across 6 categories
- `services/deadline-service.ts``createTrackedDeadline()`, `resolveDeadline()`, `aggregateDeadlines()`
- `components/attachment-preview.tsx` — QuickLook-style fullscreen preview (images: zoom/pan, PDFs: blob URL iframe, multi-file nav)
- `components/deadline-dashboard.tsx` — Stats + filters + table
- `components/deadline-add-dialog.tsx` — 3-step wizard (category → type → date preview)
- `components/notification-preferences.tsx` — Bell button + dialog with per-type toggles
- `components/registry-table.tsx``CompactNumber` component: single-letter company badge (B/U/S/G), direction arrow (↓ intrat / ↑ iesit), plain number
### Address Book — Flexible Contact Model
The Address Book supports both persons and institutions:
- **Flexible validation**: either `name` OR `company` required (not both mandatory)
- **Auto-type detection**: when only company is set via quick-create, type defaults to "institution"
- **ContactPerson sub-entities**: each has `name`, `department`, `role`, `email`, `phone`
- **Quick contact creation from Registratura**: inline dialog with name + company + phone + email
- **Display logic**: if no name, company shows as primary; if both, shows "Name (Company)"
- **Creatable types**: dropdown with defaults (client/supplier/institution/collaborator/internal) + user-created custom types
Key files:
- `modules/address-book/types.ts``AddressContact`, `ContactPerson` interfaces
- `modules/address-book/components/address-book-module.tsx` — Full UI (cards, detail dialog, form)
- `modules/address-book/hooks/use-contacts.ts` — Storage hook with search/filter
- `modules/address-book/services/vcard-export.ts` — vCard 3.0 export
- `modules/registratura/components/quick-contact-dialog.tsx` — Quick create from registry
### PDF Compression — Dual Mode (Local + Cloud)
Two compression routes, both with streaming upload support for large files (tested up to 287MB):
- **Local (qpdf)**: lossless structural optimization — stream compression, object dedup, linearization. Safe, no font corruption. Typical reduction: 3-15%.
- **Cloud (iLovePDF API)**: lossy image re-compression via iLovePDF REST API. Levels: extreme/recommended/low. Typical reduction: 50-91%. Requires `ILOVEPDF_PUBLIC_KEY` env var.
**Architecture** (zero-memory for any file size):
1. `parseMultipartUpload()` streams request body to disk (constant 64KB memory)
2. Scans raw file for multipart boundaries using `findInFile()` with 64KB sliding window
3. Stream-copies PDF bytes to separate file
4. Route handler processes (qpdf exec or iLovePDF API) and streams response back
**Critical gotchas**:
- Middleware body buffering: `api/compress-pdf` routes are **excluded from middleware matcher** (middleware buffers entire body at 10MB default)
- Auth: route-level `requireAuth()` instead of middleware (in `auth-check.ts`)
- Unicode filenames: `Content-Disposition` header uses `encodeURIComponent()` to avoid ByteString errors with Romanian chars (Ș, Ț, etc.)
- Ghostscript `-sDEVICE=pdfwrite` destroys font encodings — **never use GS for compression**, only qpdf
Key files:
- `app/api/compress-pdf/parse-upload.ts` — Streaming multipart parser (zero memory)
- `app/api/compress-pdf/extreme/route.ts` — qpdf local compression
- `app/api/compress-pdf/cloud/route.ts` — iLovePDF API integration
- `app/api/compress-pdf/auth-check.ts` — Shared auth for routes excluded from middleware
### Email Notifications (Brevo SMTP)
Platform-level notification service for daily email digests:
- **Brevo SMTP relay** via nodemailer (port 587, STARTTLS)
- **N8N cron**: weekdays 8:00 → POST `/api/notifications/digest` with Bearer token
- **Per-user preferences**: stored in KeyValueStore (`notifications` namespace), toggle global opt-out + 3 notification types
- **Digest content**: urgent deadlines (<=5 days), overdue deadlines, expiring documents (CU/AC)
- **HTML email**: inline-styled table layout, color-coded rows (red/yellow/blue), per-company grouping
- **Sender**: "Alerte Termene" &lt;noreply@beletage.ro&gt;, test mode via `?test=true` query param
Key files:
- `src/core/notifications/types.ts``NotificationType`, `NotificationPreference`, `DigestSection`, `DigestItem`
- `src/core/notifications/email-service.ts` — Nodemailer transport singleton (Brevo SMTP)
- `src/core/notifications/notification-service.ts``runDigest()`, `buildCompanyDigest()`, `renderDigestHtml()`, preference CRUD
- `src/app/api/notifications/digest/route.ts` — POST endpoint (N8N cron, Bearer auth)
- `src/app/api/notifications/preferences/route.ts` — GET/PUT (user session auth)
### ParcelSync — eTerra ANCPI GIS Integration
The ParcelSync module connects to Romania's national eTerra/ANCPI cadastral system:
- **eTerra API client** (`eterra-client.ts`): form-post auth, JSESSIONID cookie jar, session caching (9min TTL), auto-relogin, paginated fetching with `maxRecordCount=1000` + fallback page sizes (500, 200)
- **23-layer catalog** (`eterra-layers.ts`): TERENURI_ACTIVE, CLADIRI_ACTIVE, LIMITE_UAT, etc. organized in 6 categories
- **PostGIS storage**: `GisFeature` model with geometry column, SIRUTA-based partitioning, `enrichment` JSONB field
- **Background sync**: long-running jobs via server singleton, progress polling (2s), phase tracking (fetch → save → enrich)
- **Enrichment pipeline** (`enrich-service.ts`): hits eTerra `/api/immovable/list` per parcel to extract NR_CAD, NR_CF, PROPRIETARI, SUPRAFATA, INTRAVILAN, CATEGORIE_FOLOSINTA, HAS_BUILDING, etc.
- **Owner search**: DB-first (ILIKE on enrichment JSON) with eTerra API fallback
- **Per-UAT dashboard**: SQL aggregates (area stats, intravilan/extravilan, land use, top owners), CSS-only visualizations (donut ring, bar charts)
- **Health check** (`eterra-health.ts`): pings `eterra.ancpi.ro` every 3min, detects maintenance by keywords in HTML response, blocks login when down, UI shows amber "Mentenanță" state
- **ANCPI ePay CF extract ordering**: batch orders via `epay-client.ts`, PDF storage to MinIO, dedup protection (queue + API level), credit tracking
- **Static county mapping**: `WORKSPACE_TO_COUNTY` in `county-refresh.ts` — 42 verified entries, preferred over unreliable nomenclature API
- **Performance**: GisUat queries use `select` to exclude geometry column; feature counts cached 5-min TTL
- **Test UAT**: Feleacu (SIRUTA 57582, ~30k immovables, ~8k GIS features)
Key files:
- `services/eterra-client.ts` — API client (~1000 lines), session cache, pagination, retry
- `services/eterra-layers.ts` — 23-layer catalog with categories
- `services/sync-service.ts` — Layer sync engine with progress tracking
- `services/enrich-service.ts` — Enrichment pipeline (FeatureEnrichment type)
- `services/eterra-health.ts` — Health check singleton, maintenance detection
- `services/session-store.ts` — Server-side session management
- `services/epay-client.ts` — ePay HTTP client (login, cart, metadata, submit, poll, download)
- `services/epay-queue.ts` — Batch queue with dedup protection
- `services/epay-storage.ts` — MinIO storage helpers for CF extract PDFs
- `services/epay-counties.ts` — County index mapping (eTerra county name → ePay alphabetical index 0-41)
- `app/api/eterra/session/county-refresh.ts` — Static `WORKSPACE_TO_COUNTY` mapping, LIMITE_UAT geometry refresh
- `components/parcel-sync-module.tsx` — Main UI (~4100 lines), 5 tabs (Export/Layers/Search/DB/Extrase CF)
- `components/uat-dashboard.tsx` — Per-UAT analytics dashboard (CSS-only charts)
- `components/epay-tab.tsx` — CF extract ordering tab
- `components/epay-connect.tsx` — ePay connection widget
---
## Infrastructure
### Server: `satra` — 10.10.10.166 (Ubuntu, app server)
| Service | Port | Purpose |
| ----------------------- | ---------------------- | ----------------------------------- |
| **ArchiTools** | 3000 | This app (tools.beletage.ro) |
| **Gitea** | 3002 | Git hosting (git.beletage.ro) |
| **PostgreSQL** | 5432 | App database (Prisma ORM) |
| **Portainer CE** | 9000 | Docker management + deploy |
| **Uptime Kuma** | 3001 | Service monitoring |
| **MinIO** | 9002 (API) / 9003 (UI) | Object storage |
| **Authentik** | 9100 | SSO (auth.beletage.ro) — **active** |
| **N8N** | 5678 | Workflow automation (daily digest) |
| **Stirling PDF** | 8087 | PDF tools |
| **IT-Tools** | 8085 | Developer utilities |
| **FileBrowser** | 8086 | File management |
| **Netdata** | 19999 | System monitoring |
| **Dozzle** | 9999 | Docker log viewer |
| **CrowdSec** | 8088 | Security |
### Server: `proxy` — 10.10.10.199 (Traefik reverse proxy)
| Config | Path / Value |
| ----------------------- | ---------------------------------------- |
| **Static config** | `/opt/traefik/traefik.yml` |
| **Dynamic configs** | `/opt/traefik/dynamic/` (file provider, `watch: true`) |
| **ArchiTools route** | `/opt/traefik/dynamic/tools.yml` |
| **SSL** | Let's Encrypt ACME, HTTP challenge |
| **Timeouts** | `readTimeout: 600s`, `writeTimeout: 600s`, `idleTimeout: 600s` on `websecure` entrypoint |
| **Response forwarding** | `flushInterval: 100ms` (streaming support) |
**IMPORTANT**: Default Traefik v2.11+ has 60s `readTimeout` — breaks large file uploads. Must set explicitly in static config.
### Deployment Pipeline
```
git push origin main
→ Gitea webhook fires
→ Portainer CE: Stacks → architools → "Pull and redeploy"
→ Toggle "Re-pull image and redeploy" ON → click "Update"
→ Portainer re-clones git repo + Docker multi-stage build (~2 min)
→ Container starts on :3000
→ Traefik routes tools.beletage.ro → http://10.10.10.166:3000
```
**Portainer CE deploy**: NOT automatic. Must manually click "Pull and redeploy" in Portainer UI after each push. The stack is configured from git repo `http://10.10.10.166:3002/gitadmin/ArchiTools`.
### Docker
- `Dockerfile`: 3-stage build (deps → builder → runner), `node:22-alpine`, non-root user
- Runner stage installs: `gdal gdal-tools ghostscript qpdf` (for PDF compression, GIS)
- `Dockerfile` includes `npx prisma generate` before build step
- `docker-compose.yml`: single service, port 3000, **all env vars hardcoded** (Portainer CE can't inject env vars)
- `output: 'standalone'` in `next.config.ts` is **required**
- `@prisma/client` must be in `dependencies` (not devDependencies) for runtime
See `docs/MODULE-MAP.md` for entry points, API routes, and cross-module deps.
---
@@ -302,91 +91,82 @@ git push origin main
### TypeScript Strict Mode Gotchas
- `array.split()[0]` returns `string | undefined` — use `.slice(0, 10)` instead
- `Record<string, T>[key]` returns `T | undefined` — always guard with null check
- Spread of possibly-undefined objects: `{ ...obj[key], field }` — check existence first
- `arr[0]` is `T | undefined` even after length check — assign to const first
- `Record<string, T>[key]` returns `T | undefined` — always null-check
- Spread of possibly-undefined: `{ ...obj[key] }` — check existence first
- lucide-react Icons: cast through `unknown``React.ComponentType<{ className?: string }>`
- `arr[0]` is `T | undefined` even after `arr.length > 0` check — assign to const first: `const first = arr[0]; if (first) { ... }`
- Prisma `$queryRaw` returns `unknown[]` — always cast with `as Array<{ field: type }>` and guard access
- `?? ""` on an object field typed `{}` produces `{}` not `string` — use explicit `typeof x === 'string'` or `'number'` check
- Prisma `$queryRaw` returns `unknown[]`cast with `as Array<{ field: type }>`
- `?? ""` on `{}` field produces `{}` not `string` — use `typeof` check
### Conventions
- **Code**: English
- **UI text**: Romanian
- **Components**: functional, `'use client'` directive where needed
- **State**: localStorage via `useStorage('module-name')` hook
- **IDs**: `uuid v4`
- **Dates**: ISO strings (`YYYY-MM-DD` for display, full ISO for timestamps)
- **No emojis** in code or UI unless explicitly requested
- **Code**: English | **UI text**: Romanian | **IDs**: uuid v4
- **Dates**: ISO strings (`YYYY-MM-DD` display, full ISO timestamps)
- **Components**: functional, `'use client'` where needed
- **No emojis** in code or UI
### Storage Performance Rules
### Storage Performance (CRITICAL)
- **NEVER** use `storage.list()` followed by `storage.get()` in a loop — this is an N+1 query bug
- `list()` fetches ALL items (keys+values) from DB but discards values, then each `get()` re-fetches individually
- **ALWAYS** use `storage.exportAll()` (namespaced) or `storage.export(namespace)` (service-level) to batch-load
- Filter items client-side after a single fetch: `for (const [key, value] of Object.entries(all)) { ... }`
- After mutations (add/update), either do optimistic local state update or a single `refresh()` — never both
- **NEVER store large binary data (base64 files) inside entity JSON** — this makes list loading transfer tens of MB
- For modules with attachments: use `exportAll({ lightweight: true })` for listing, `storage.get()` for single-entry full load
- The API `?lightweight=true` parameter strips `data`/`fileData` strings >1KB from JSON values server-side
- Future: move file data to MinIO; only store metadata (name, size, type, url) in the entity JSON
- **NEVER** `storage.list()` + `storage.get()` in loop — N+1 bug
- **ALWAYS** use `storage.exportAll()` or `storage.export(namespace)` for batch-load
- **NEVER** store base64 files in entity JSON — use `lightweight: true` for listing
- After mutations: optimistic update OR single `refresh()` — never both
### Module Development Pattern
### Middleware & Large Uploads
Every module follows:
- Middleware buffers entire body — exclude large-upload routes from matcher
- Excluded routes: `api/auth|api/notifications/digest|api/compress-pdf|api/address-book|api/projects`
- Excluded routes use `requireAuth()` from `auth-check.ts` instead
- To add new upload route: (1) exclude from middleware, (2) add `requireAuth()`
```
src/modules/<name>/
├── components/ # React components
├── hooks/ # Custom hooks (use-<name>.ts)
├── services/ # Business logic (pure functions)
├── types.ts # TypeScript interfaces
├── config.ts # ModuleConfig metadata
└── index.ts # Public exports
```
### eTerra / ANCPI Rules
### Middleware & Large Upload Routes
- Next.js middleware buffers the **entire request body** even if it only reads cookies/headers
- Default middleware body limit is 10MB — any upload route handling large files MUST be excluded
- Excluded routes pattern in `src/middleware.ts` matcher: `api/auth|api/notifications/digest|api/compress-pdf`
- Excluded routes handle auth via `requireAuth()` helper (`src/app/api/compress-pdf/auth-check.ts`)
- To add a new large-upload route: (1) add to middleware matcher exclusion, (2) add `requireAuth()` call in route handler
- `next.config.ts` has `experimental: { middlewareClientMaxBodySize: '500mb' }` but this is unreliable with `output: 'standalone'`
### eTerra / External API Rules
- **ArcGIS REST API** has `maxRecordCount=1000` — always paginate with `resultOffset`/`resultRecordCount`
- **eTerra sessions expire after ~10min** — session cache TTL is 9min, auto-relogin on 401/redirect
- **eTerra goes into maintenance regularly** — health check must detect and block login attempts
- **Never hardcode timeouts too low** — eTerra 1000-feature geometry pages can take 60-90s; default is 120s
- **CookieJar + axios-cookiejar-support** required for eTerra auth (JSESSIONID tracking)
- **Page size fallbacks**: if 1000 fails, retry with 500, then 200
- **WORKSPACE_TO_COUNTY is the authoritative county mapping** — static 42-entry map in `county-refresh.ts`, preferred over `fetchCounties()` which 404s intermittently
- **GisUat.geometry is huge** — always use Prisma `select` to exclude it in list queries; forgetting this turns 50ms into 5+ seconds
- **Feature counts are expensive** — cached in global with 5-min TTL in UATs route; returns stale data while refreshing
### ANCPI ePay Rules
- **ePay county IDs = eTerra WORKSPACE_IDs** (CLUJ=127, ALBA=10) — zero discovery calls needed
- **ePay UAT IDs = SIRUTA codes** — use `GisUat.workspacePk` + `siruta` directly
- **EpayJsonInterceptor uses form-urlencoded** (NOT JSON body) — `reqType=nomenclatorUAT&countyId=127`
- **saveProductMetadataForBasketItem uses multipart/form-data** (form-data npm package)
- **Document IDs are HTML-encoded** in ShowOrderDetails — `&quot;idDocument&quot;:47301767` must be decoded before JSON parse
- **ePay auth is OpenAM** — gets `AMAuthCookie`, then navigate to `http://` (not https) for JSESSIONID
- **MinIO metadata must be ASCII** — strip diacritics from values before storing
- Env vars: `ANCPI_USERNAME`, `ANCPI_PASSWORD`, `ANCPI_BASE_URL`, `ANCPI_LOGIN_URL`, `ANCPI_DEFAULT_SOLICITANT_ID`, `MINIO_BUCKET_ANCPI`
- ArcGIS: paginate with `resultOffset`/`resultRecordCount` (max 1000)
- Sessions expire ~10min — cache TTL 9min, auto-relogin on 401
- Health check detects maintenance — block login when down
- `WORKSPACE_TO_COUNTY` (42 entries in `county-refresh.ts`) is authoritative
- `GisUat.geometry` is huge — always `select` to exclude in list queries
- Feature counts cached 5-min TTL
- ePay: form-urlencoded body, OpenAM auth, MinIO metadata must be ASCII
### Before Pushing
1. `npx next build` must pass with zero errors
2. Test the feature manually on `localhost:3000`
1. `npx next build` — zero errors
2. Test on `localhost:3000`
3. Commit with descriptive message
4. `git push origin main` Portainer auto-deploys
4. `git push origin main` → manual Portainer redeploy
---
## Common Pitfalls (Top 10)
1. **Middleware body buffering** — upload routes >10MB must be excluded from matcher
2. **N+1 storage queries** — use `exportAll()`, never `list()` + `get()` loop
3. **GisUat geometry in queries** — exclude with `select`, or 50ms → 5+ seconds
4. **Enrichment data loss on re-sync** — upsert must preserve enrichment field
5. **Ghostscript corrupts fonts** — use qpdf for PDF compression, never GS
6. **eTerra timeout too low** — geometry pages need 60-90s; default 120s
7. **Traefik 60s readTimeout** — must set 600s in static config for uploads
8. **Portainer CE can't inject env vars** — all env in docker-compose.yml
9. **`@prisma/client` in dependencies** (not devDeps) — runtime requirement
10. **`output: 'standalone'`** in next.config.ts — required for Docker
---
## Infrastructure Quick Reference
| Service | Address | Purpose |
| ----------- | ------------------------ | -------------------------- |
| App | 10.10.10.166:3000 | ArchiTools (tools.beletage.ro) |
| PostgreSQL | 10.10.10.166:5432 | Database (Prisma) |
| MinIO | 10.10.10.166:9002/9003 | Object storage |
| Authentik | 10.10.10.166:9100 | SSO (auth.beletage.ro) |
| Portainer | 10.10.10.166:9000 | Docker management |
| Gitea | 10.10.10.166:3002 | Git (git.beletage.ro) |
| Traefik | 10.10.10.199 | Reverse proxy + SSL |
| N8N | 10.10.10.166:5678 | Workflow automation |
| Stirling PDF | 10.10.10.166:8087 | PDF tools (needs env vars!) |
## Company IDs
| ID | Name | Prefix |
@@ -398,66 +178,20 @@ src/modules/<name>/
---
## Current Integrations
## Documentation
| Feature | Status | Notes |
| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
| **Authentik SSO** | ✅ Active | NextAuth v4 + OIDC, group→role/company mapping |
| **PostgreSQL** | ✅ Active | Prisma ORM, `KeyValueStore` model, `/api/storage` route |
| **MinIO** | Client configured | 10.10.10.166:9002, bucket `tools`, adapter pending |
| **AI Chat API** | ✅ Multi-provider | `/api/ai-chat` — OpenAI/Claude/Ollama/demo; needs API key env |
| **Vault Encryption** | ✅ Active | AES-256-GCM server-side, `/api/vault`, ENCRYPTION_SECRET env |
| **ManicTime Sync** | ✅ Implemented | `/api/manictime` — bidirectional Tags.txt sync, needs SMB mount |
| **NAS Paths** | ✅ Active | `\\newamun` (10.10.10.10), drives A/O/P/T, hostname+IP fallback, `src/config/nas-paths.ts` |
| **eTerra ANCPI** | ✅ Active | ParcelSync module, `eterra-client.ts`, health check + maintenance detection |
| **ANCPI ePay** | ✅ Active | CF extract ordering, `epay-client.ts`, MinIO PDF storage, batch queue + dedup, `/api/ancpi/*` routes |
| **PostGIS** | ✅ Active | `GisFeature` model, geometry storage, spatial queries, used by ParcelSync |
| **Email Notifications** | ✅ Implemented | Brevo SMTP daily digest, `/api/notifications/digest` + `/preferences`, N8N cron trigger |
| **N8N automations** | ✅ Active (digest cron) | Daily digest cron `0 8 * * 1-5`, Bearer token auth, future: backups, workflows |
| **iLovePDF API** | ✅ Active | Cloud PDF compression, `ILOVEPDF_PUBLIC_KEY` env, free tier 250 files/month |
| **qpdf** | ✅ Active | Local lossless PDF optimization, installed in Docker image (`apk add qpdf`) |
| Doc | Path |
| ------------------- | ------------------------------------------ |
| Module Map | `docs/MODULE-MAP.md` |
| Architecture Quick | `docs/ARCHITECTURE-QUICK.md` |
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` |
| Module System | `docs/architecture/MODULE-SYSTEM.md` |
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` |
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` |
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` |
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` |
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` |
| Coding Standards | `docs/guides/CODING-STANDARDS.md` |
| Data Model | `docs/DATA-MODEL.md` |
---
## Model Recommendations
| Task Type | Claude | OpenAI | Google | Notes |
| ----------------------------- | -------------- | ------------- | ---------------- | ----------------------------------------------- |
| **Bug fixes, config** | Haiku 4.5 | GPT-4o-mini | Gemini 2.5 Flash | Fast, cheap |
| **Features, tests, UI** | **Sonnet 4.6** | GPT-5.2 | Gemini 3 Flash | Best value — Opus-class quality at Sonnet price |
| **New modules, architecture** | Opus 4.6 | GPT-5.3-Codex | Gemini 3 Pro | Complex multi-file, business logic |
**Default: Sonnet 4.6** for most work. See `ROADMAP.md` for per-task recommendations.
### Session Handoff Tips
- Read this `CLAUDE.md` first — it has all context
- Read `ROADMAP.md` for the complete task list with dependencies
- Check `docs/` for deep dives on specific systems
- Check `src/modules/<name>/types.ts` before modifying any module
- Always run `npx next build` before committing
- Push to `main` → Portainer auto-deploys via Gitea webhook
- The 16 docs in `docs/` total ~10,600 lines — search them for architecture questions
---
## Documentation Index
| Doc | Path | Content |
| ------------------- | ------------------------------------------ | -------------------------------------------- |
| System Architecture | `docs/architecture/SYSTEM-ARCHITECTURE.md` | Overall architecture, module platform design |
| Module System | `docs/architecture/MODULE-SYSTEM.md` | Module registry, lifecycle, config format |
| Feature Flags | `docs/architecture/FEATURE-FLAGS.md` | Flag system, env overrides |
| Storage Layer | `docs/architecture/STORAGE-LAYER.md` | StorageService interface, adapters |
| Tagging System | `docs/architecture/TAGGING-SYSTEM.md` | Cross-module tags |
| Security & Roles | `docs/architecture/SECURITY-AND-ROLES.md` | Visibility, auth, roles |
| Module Dev Guide | `docs/guides/MODULE-DEVELOPMENT.md` | How to create a new module |
| HTML Integration | `docs/guides/HTML-TOOL-INTEGRATION.md` | Legacy tool migration |
| UI Design System | `docs/guides/UI-DESIGN-SYSTEM.md` | Design tokens, component patterns |
| Docker Deployment | `docs/guides/DOCKER-DEPLOYMENT.md` | Full Docker/Portainer/Nginx guide |
| Coding Standards | `docs/guides/CODING-STANDARDS.md` | TS strict, naming, patterns |
| Testing Strategy | `docs/guides/TESTING-STRATEGY.md` | Testing approach |
| Configuration | `docs/guides/CONFIGURATION.md` | Env vars, flags, companies |
| Data Model | `docs/DATA-MODEL.md` | All entity schemas |
| Repo Structure | `docs/REPO-STRUCTURE.md` | Directory layout |
| Prompt Generator | `docs/modules/PROMPT-GENERATOR.md` | Prompt module deep dive |
For module-specific deep dives, see `docs/modules/`.
+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
+50 -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:
@@ -51,10 +51,15 @@ services:
- ANCPI_LOGIN_URL=https://oassl.ancpi.ro/openam/UI/Login
- ANCPI_DEFAULT_SOLICITANT_ID=14452
- MINIO_BUCKET_ANCPI=ancpi-documente
# Stirling PDF (local PDF tools)
- STIRLING_PDF_URL=http://10.10.10.166:8087
- STIRLING_PDF_API_KEY=cd829f62-6eef-43eb-a64d-c91af727b53a
# iLovePDF cloud compression (free: 250 files/month)
- 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)
@@ -65,6 +70,12 @@ 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)
- ADDRESSBOOK_API_KEY=abook-7f3e9a2b4c1d8e5f6a0b3c7d9e2f4a1b5c8d0e3f6a9b2c5d8e1f4a7b0c3d6e
depends_on:
@@ -95,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:
+80
View File
@@ -0,0 +1,80 @@
# ArchiTools — Architecture Quick Reference
## Data Flow
```
Browser → Traefik (tools.beletage.ro) → Next.js :3000
├── App Router (pages)
├── API Routes (/api/*)
│ ├── Prisma → PostgreSQL + PostGIS
│ ├── MinIO (file storage)
│ ├── eTerra ANCPI (external GIS API)
│ └── Brevo SMTP (email notifications)
└── Auth: NextAuth → Authentik OIDC
```
## Module Dependencies
```
registratura ←→ address-book (bidirectional: contacts + reverse lookup)
parcel-sync → geoportal (map components reuse)
geoportal → PostGIS (spatial queries, vector tiles)
parcel-sync → eTerra API (external: ANCPI cadastral data)
parcel-sync → ePay API (external: ANCPI CF extract ordering)
parcel-sync → MinIO (CF extract PDF storage)
notifications → registratura (deadline digest data)
all modules → core/storage (KeyValueStore via Prisma)
all modules → core/auth (Authentik SSO session)
```
## Critical API Routes (Write Operations)
| Route | Method | What it does | Auth |
| ---------------------------------- | ------ | ----------------------------------- | --------- |
| `/api/storage` | PUT/DELETE | KeyValueStore CRUD | Middleware |
| `/api/registratura` | POST/PUT/DELETE | Registry entries + audit | Middleware + Bearer |
| `/api/registratura/reserved` | POST | Reserve future registry slots | Middleware |
| `/api/registratura/debug-sequences`| POST/PATCH | Reset sequence counters | Admin only |
| `/api/vault` | PUT/DELETE | Encrypted vault entries | Middleware |
| `/api/address-book` | PUT/DELETE | Contact CRUD | Middleware + Bearer |
| `/api/eterra/sync-background` | POST | Start GIS sync job | Middleware |
| `/api/eterra/uats` | POST/PATCH | UAT management + county refresh | Middleware |
| `/api/ancpi/order` | POST | ePay CF extract order | Middleware |
| `/api/notifications/digest` | POST | Trigger email digest | Bearer |
| `/api/notifications/preferences` | PUT | User notification prefs | Middleware |
| `/api/compress-pdf/*` | POST | PDF compression/unlock | requireAuth |
## Storage Architecture
```
KeyValueStore (Prisma) GisFeature (PostGIS) MinIO
├── namespace: module-id ├── layerId + objectId ├── bucket: tools
├── key: entity UUID ├── geometry (GeoJSON) ├── bucket: ancpi-cf
└── value: JSON blob ├── enrichment (JSONB) └── PDF files
└── geom (native PostGIS)
```
## Auth Flow
```
User → /auth/signin → Authentik OIDC → callback → NextAuth session
├── Middleware: checks JWT token, redirects if unauthenticated
├── Portal-only users: env PORTAL_ONLY_USERS → redirected to /portal
└── API routes excluded from middleware: use requireAuth() or Bearer token
```
## Environment Variables (Critical)
| Var | Required | Used by |
| ---------------------- | -------- | -------------------------- |
| `DATABASE_URL` | Yes | Prisma |
| `NEXTAUTH_SECRET` | Yes | NextAuth JWT |
| `NEXTAUTH_URL` | Yes | Auth redirects |
| `ENCRYPTION_SECRET` | Yes | Password Vault AES-256 |
| `STIRLING_PDF_URL` | Yes | PDF compression/unlock |
| `STIRLING_PDF_API_KEY` | Yes | Stirling PDF auth |
| `NOTIFICATION_CRON_SECRET` | Yes | Digest endpoint Bearer |
| `MINIO_*` | Yes | MinIO connection |
| `ANCPI_*` | For ePay | ePay CF ordering |
| `ILOVEPDF_PUBLIC_KEY` | Optional | Cloud PDF compression |
| `PORTAL_ONLY_USERS` | Optional | Comma-separated usernames |
+142
View File
@@ -0,0 +1,142 @@
# ArchiTools — Module Map
Quick reference: entry points, key files, API routes, and cross-module dependencies.
## Module Index
| Module | Entry Point | Config | Types |
| ------ | ----------- | ------ | ----- |
| [Dashboard](#dashboard) | `modules/dashboard/index.ts` | — | `types.ts` |
| [Email Signature](#email-signature) | `modules/email-signature/index.ts` | `config.ts` | `types.ts` |
| [Word XML](#word-xml) | `modules/word-xml/index.ts` | `config.ts` | `types.ts` |
| [Registratura](#registratura) | `modules/registratura/index.ts` | `config.ts` | `types.ts` |
| [Tag Manager](#tag-manager) | `modules/tag-manager/index.ts` | `config.ts` | `types.ts` |
| [IT Inventory](#it-inventory) | `modules/it-inventory/index.ts` | `config.ts` | `types.ts` |
| [Address Book](#address-book) | `modules/address-book/index.ts` | `config.ts` | `types.ts` |
| [Password Vault](#password-vault) | `modules/password-vault/index.ts` | `config.ts` | `types.ts` |
| [Mini Utilities](#mini-utilities) | `modules/mini-utilities/index.ts` | `config.ts` | `types.ts` |
| [Prompt Generator](#prompt-generator) | `modules/prompt-generator/index.ts` | `config.ts` | `types.ts` |
| [Digital Signatures](#digital-signatures) | `modules/digital-signatures/index.ts` | `config.ts` | `types.ts` |
| [Word Templates](#word-templates) | `modules/word-templates/index.ts` | `config.ts` | `types.ts` |
| [AI Chat](#ai-chat) | `modules/ai-chat/index.ts` | `config.ts` | `types.ts` |
| [Hot Desk](#hot-desk) | `modules/hot-desk/index.ts` | `config.ts` | `types.ts` |
| [ParcelSync](#parcel-sync) | `modules/parcel-sync/index.ts` | `config.ts` | `types.ts` |
| [Geoportal](#geoportal) | `modules/geoportal/index.ts` | `config.ts` | `types.ts` |
| [Visual CoPilot](#visual-copilot) | `modules/visual-copilot/index.ts` | `config.ts` | — |
---
## Module Details
### Dashboard
- **Route**: `/`
- **Main component**: `app/(modules)/page.tsx` (home page, not a registered module)
- **API routes**: none (reads via storage API)
- **Cross-deps**: none
### Email Signature
- **Route**: `/email-signature`
- **Main component**: `components/email-signature-module.tsx`
- **API routes**: none (client-only)
- **Cross-deps**: none
### Word XML
- **Route**: `/word-xml`
- **Main component**: `components/word-xml-module.tsx`
- **Services**: `services/xml-builder.ts`, `services/zip-export.ts`
- **API routes**: none (client-only)
- **Cross-deps**: none
### Registratura
- **Route**: `/registratura`
- **Main component**: `components/registratura-module.tsx`
- **Key services**: `services/registry-service.ts` (numbering, advisory locks), `services/working-days.ts` (Romanian holidays), `services/deadline-catalog.ts` (18 legal deadline types), `services/deadline-service.ts`
- **API routes**: `/api/registratura` (CRUD + audit), `/api/registratura/reserved`, `/api/registratura/debug-sequences`, `/api/registratura/audit`, `/api/registratura/status-check`
- **Cross-deps**: **address-book** (quick contact, reverse lookup), **notifications** (deadline digest)
### Tag Manager
- **Route**: `/tag-manager`
- **Main component**: `components/tag-manager-module.tsx`
- **Services**: `services/manictime-sync.ts`
- **API routes**: `/api/manictime`
- **Cross-deps**: core/tagging
### IT Inventory
- **Route**: `/it-inventory`
- **Main component**: `components/it-inventory-module.tsx`
- **API routes**: none (via storage API)
- **Cross-deps**: none
### Address Book
- **Route**: `/address-book`
- **Main component**: `components/address-book-module.tsx`
- **Services**: `services/vcard-export.ts`
- **API routes**: `/api/address-book` (CRUD, Bearer token support)
- **Cross-deps**: **registratura** (reverse lookup via `useRegistry`)
### Password Vault
- **Route**: `/password-vault`
- **Main component**: `components/password-vault-module.tsx`
- **API routes**: `/api/vault` (AES-256-GCM encrypt/decrypt)
- **Cross-deps**: none
### Mini Utilities
- **Route**: `/mini-utilities`
- **Main component**: `components/mini-utilities-module.tsx` (monolithic, tab-based)
- **API routes**: `/api/compress-pdf/*` (local qpdf + cloud iLovePDF), `/api/compress-pdf/unlock`
- **Cross-deps**: none
### Prompt Generator
- **Route**: `/prompt-generator`
- **Main component**: `components/prompt-generator-module.tsx`
- **Services**: `services/prompt-templates.ts` (18 templates)
- **API routes**: none (client-only)
- **Cross-deps**: none
### Digital Signatures
- **Route**: `/digital-signatures`
- **Main component**: `components/digital-signatures-module.tsx`
- **API routes**: none (via storage API)
- **Cross-deps**: none
### Word Templates
- **Route**: `/word-templates`
- **Main component**: `components/word-templates-module.tsx`
- **Services**: `services/docx-analyzer.ts`
- **API routes**: none (via storage API)
- **Cross-deps**: none
### AI Chat
- **Route**: `/ai-chat`
- **Main component**: `components/ai-chat-module.tsx`
- **API routes**: `/api/ai-chat` (multi-provider proxy)
- **Cross-deps**: tag-manager (project linking)
### Hot Desk
- **Route**: `/hot-desk`
- **Main component**: `components/hot-desk-module.tsx`
- **Services**: `services/desk-layout.ts`
- **API routes**: none (via storage API)
- **Cross-deps**: none
### ParcelSync
- **Route**: `/parcel-sync`
- **Main component**: `components/parcel-sync-module.tsx` (~4100 lines, 5 tabs)
- **Key services**: `services/eterra-client.ts` (~1000 lines, eTerra API), `services/sync-service.ts`, `services/enrich-service.ts`, `services/eterra-health.ts`, `services/epay-client.ts`, `services/epay-queue.ts`, `services/epay-storage.ts`, `services/no-geom-sync.ts`
- **API routes**: `/api/eterra/*` (login, sync, search, features, UATs, health), `/api/ancpi/*` (order, test), `/api/geoportal/*` (search, boundaries, setup)
- **Cross-deps**: **geoportal** (map components via map-tab.tsx), **MinIO** (CF extract PDFs), **PostGIS** (GisFeature, GisUat)
### Geoportal
- **Route**: `/geoportal`
- **Main component**: `components/geoportal-module.tsx`
- **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`
- **Status**: Placeholder (iframe to separate repo `git.beletage.ro/gitadmin/vim`)
- **API routes**: none
- **Cross-deps**: none
+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
@@ -1,456 +0,0 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configurator semnatura e-mail</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
<style>
body { font-family: 'Inter', sans-serif; }
.no-select { -webkit-user-select: none; -ms-user-select: none; user-select: none; }
input[type=range] {
-webkit-appearance: none; appearance: none; width: 100%; height: 4px;
background: #e5e7eb; border-radius: 5px; outline: none; transition: background 0.2s ease;
}
input[type=range]:hover { background: #d1d5db; }
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 12px; height: 20px;
background: #22B5AB; cursor: pointer; border-radius: 4px;
margin-top: -8px; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
transition: transform 0.1s ease-in-out, box-shadow 0.1s ease-in-out;
}
input[type=range]::-webkit-slider-thumb:active { transform: scale(1.15); box-shadow: 0 2px 6px rgba(0,0,0,0.3); }
input[type=range]::-moz-range-thumb {
width: 12px; height: 20px; background: #22B5AB; cursor: pointer;
border-radius: 4px; border: none; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
#preview-wrapper { transition: transform 0.2s ease-in-out; transform-origin: top left; }
.color-swatch {
width: 24px; height: 24px; border-radius: 9999px; cursor: pointer;
border: 2px solid transparent; transition: all 0.2s ease;
}
.color-swatch.active { border-color: #22B5AB; transform: scale(1.1); box-shadow: 0 0 0 2px white, 0 0 0 4px #22B5AB; }
.collapsible-content { max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; }
.collapsible-content.open { max-height: 1000px; /* Valoare mare pentru a permite extinderea */ }
.collapsible-trigger svg { transition: transform 0.3s ease; }
.collapsible-trigger.open svg { transform: rotate(90deg); }
</style>
</head>
<body class="bg-gray-50 text-gray-800 no-select">
<div class="container mx-auto p-4 md:p-8">
<header class="text-center mb-10">
<h1 class="text-3xl md:text-4xl font-bold text-gray-900">Configurator semnatura e-mail</h1>
</header>
<div class="flex flex-col lg:flex-row gap-8">
<!-- Panoul de control -->
<aside class="lg:w-2/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200">
<div id="controls">
<!-- Secțiunea Date Personale -->
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Date Personale</h3>
<div class="space-y-3">
<div>
<label for="input-prefix" class="block text-sm font-medium text-gray-700 mb-1">Titulatură (prefix)</label>
<input type="text" id="input-prefix" value="arh." class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
</div>
<div>
<label for="input-name" class="block text-sm font-medium text-gray-700 mb-1">Nume și Prenume</label>
<input type="text" id="input-name" value="Marius TĂRĂU" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
</div>
<div>
<label for="input-title" class="block text-sm font-medium text-gray-700 mb-1">Funcția</label>
<input type="text" id="input-title" value="Arhitect • Beletage SRL" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
</div>
<div>
<label for="input-phone" class="block text-sm font-medium text-gray-700 mb-1">Telefon (format 07xxxxxxxx)</label>
<input type="tel" id="input-phone" value="0785123433" class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-teal-500 focus:border-teal-500">
</div>
</div>
</div>
<!-- Culori Text (Collapsible) -->
<div class="mb-4">
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
<h3 class="text-lg font-semibold text-gray-800">Culori Text</h3>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
<div class="collapsible-content">
<div id="color-controls" class="space-y-2 pt-2"></div>
</div>
</div>
<!-- Secțiunea Stil & Aranjare (Collapsible) -->
<div class="mb-4">
<div class="collapsible-trigger flex justify-between items-center cursor-pointer border-b pb-2 mb-2">
<h3 class="text-lg font-semibold text-gray-800">Stil & Aranjare</h3>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</div>
<div class="collapsible-content">
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-3 pt-2">
<div>
<label for="green-line-width" class="block text-sm font-medium text-gray-700 mb-2">Lungime linie verde (<span id="green-line-value">97</span>px)</label>
<input id="green-line-width" type="range" min="50" max="300" value="97">
</div>
<div>
<label for="section-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. secțiuni (<span id="section-spacing-value">10</span>px)</label>
<input id="section-spacing" type="range" min="0" max="30" value="10">
</div>
<div>
<label for="logo-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. Logo (<span id="logo-spacing-value">10</span>px)</label>
<input id="logo-spacing" type="range" min="0" max="30" value="10">
</div>
<div>
<label for="title-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. funcție (<span id="title-spacing-value">2</span>px)</label>
<input id="title-spacing" type="range" min="0" max="20" value="2">
</div>
<div>
<label for="b-gutter-width" class="block text-sm font-medium text-gray-700 mb-2">Aliniere contact (<span id="b-gutter-value">13</span>px)</label>
<input id="b-gutter-width" type="range" min="0" max="150" value="13">
</div>
<div>
<label for="icon-text-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiu Icon-Text (<span id="icon-text-spacing-value">5</span>px)</label>
<input id="icon-text-spacing" type="range" min="-10" max="30" value="5">
</div>
<div>
<label for="icon-vertical-pos" class="block text-sm font-medium text-gray-700 mb-2">Aliniere vert. iconițe (<span id="icon-vertical-value">1</span>px)</label>
<input id="icon-vertical-pos" type="range" min="-10" max="10" value="1">
</div>
<div>
<label for="motto-spacing" class="block text-sm font-medium text-gray-700 mb-2">Spațiere vert. motto (<span id="motto-spacing-value">3</span>px)</label>
<input id="motto-spacing" type="range" min="0" max="20" value="3">
</div>
</div>
</div>
</div>
<!-- Opțiuni -->
<div class="mb-4">
<h3 class="text-lg font-semibold text-gray-800 border-b pb-2 mb-4">Opțiuni</h3>
<div class="space-y-2">
<label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
<span class="text-sm font-medium text-gray-700">Variantă simplă (fără logo/adresă)</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="super-reply-variant-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
<span class="text-sm font-medium text-gray-700">Super-simplă (doar nume/telefon)</span>
</label>
<label class="flex items-center space-x-3 cursor-pointer">
<input type="checkbox" id="use-svg-checkbox" class="h-4 w-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500">
<span class="text-sm font-medium text-gray-700">Folosește imagini SVG (calitate maximă)</span>
</label>
</div>
</div>
</div>
<!-- Buton de Export -->
<div class="mt-8 pt-6 border-t">
<button id="export-btn" class="w-full bg-teal-600 text-white font-bold py-3 px-4 rounded-lg hover:bg-teal-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-teal-500 transition-all duration-300 ease-in-out transform hover:scale-105">
Descarcă HTML
</button>
</div>
</aside>
<!-- Previzualizare Live -->
<main class="lg:w-3/5 bg-white p-6 rounded-2xl shadow-lg border border-gray-200 overflow-hidden">
<div class="flex justify-between items-center border-b pb-3 mb-4">
<h2 class="text-2xl font-bold text-gray-900">Previzualizare Live</h2>
<button id="zoom-btn" class="text-sm bg-gray-200 text-gray-700 px-3 py-1 rounded-md hover:bg-gray-300">Zoom 100%</button>
</div>
<div id="preview-wrapper" class="overflow-auto">
<div id="preview-container">
<!-- Aici este inserat codul HTML al semnăturii -->
</div>
</div>
</main>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const controls = {
prefix: document.getElementById('input-prefix'),
name: document.getElementById('input-name'),
title: document.getElementById('input-title'),
phone: document.getElementById('input-phone'),
greenLine: document.getElementById('green-line-width'),
gutter: document.getElementById('b-gutter-width'),
iconTextSpacing: document.getElementById('icon-text-spacing'),
iconVertical: document.getElementById('icon-vertical-pos'),
mottoSpacing: document.getElementById('motto-spacing'),
sectionSpacing: document.getElementById('section-spacing'),
titleSpacing: document.getElementById('title-spacing'),
logoSpacing: document.getElementById('logo-spacing'),
replyCheckbox: document.getElementById('reply-variant-checkbox'),
superReplyCheckbox: document.getElementById('super-reply-variant-checkbox'),
useSvgCheckbox: document.getElementById('use-svg-checkbox'),
exportBtn: document.getElementById('export-btn'),
zoomBtn: document.getElementById('zoom-btn'),
colorControls: document.getElementById('color-controls')
};
const values = {
greenLine: document.getElementById('green-line-value'),
gutter: document.getElementById('b-gutter-value'),
iconTextSpacing: document.getElementById('icon-text-spacing-value'),
iconVertical: document.getElementById('icon-vertical-value'),
mottoSpacing: document.getElementById('motto-spacing-value'),
sectionSpacing: document.getElementById('section-spacing-value'),
titleSpacing: document.getElementById('title-spacing-value'),
logoSpacing: document.getElementById('logo-spacing-value')
};
const previewContainer = document.getElementById('preview-container');
const previewWrapper = document.getElementById('preview-wrapper');
const imageSets = {
png: {
logo: 'https://beletage.ro/img/Semnatura-Logo.png',
greySlash: 'https://beletage.ro/img/Grey-slash.png',
greenSlash: 'https://beletage.ro/img/Green-slash.png'
},
svg: {
logo: 'https://beletage.ro/img/Logo-Beletage.svg',
greySlash: 'https://beletage.ro/img/Grey-slash.svg',
greenSlash: 'https://beletage.ro/img/Green-slash.svg'
}
};
const beletageColors = {
verde: '#22B5AB',
griInchis: '#54504F',
griDeschis: '#A7A9AA',
negru: '#323232'
};
const colorConfig = {
prefix: { label: 'Titulatură', default: beletageColors.griInchis },
name: { label: 'Nume', default: beletageColors.griInchis },
title: { label: 'Funcție', default: beletageColors.griDeschis },
address: { label: 'Adresă', default: beletageColors.griDeschis },
phone: { label: 'Telefon', default: beletageColors.griInchis },
website: { label: 'Website', default: beletageColors.griInchis },
motto: { label: 'Motto', default: beletageColors.verde }
};
let currentColors = {};
function createColorPickers() {
for (const [key, config] of Object.entries(colorConfig)) {
currentColors[key] = config.default;
const controlRow = document.createElement('div');
controlRow.className = 'flex items-center justify-between';
const label = document.createElement('span');
label.className = 'text-sm font-medium text-gray-700';
label.textContent = config.label;
controlRow.appendChild(label);
const swatchesContainer = document.createElement('div');
swatchesContainer.className = 'flex items-center space-x-2';
swatchesContainer.dataset.controlKey = key;
for (const color of Object.values(beletageColors)) {
const swatch = document.createElement('div');
swatch.className = 'color-swatch';
swatch.style.backgroundColor = color;
swatch.dataset.color = color;
if (color === config.default) swatch.classList.add('active');
swatchesContainer.appendChild(swatch);
}
controlRow.appendChild(swatchesContainer);
controls.colorControls.appendChild(controlRow);
}
controls.colorControls.addEventListener('click', (e) => {
if (e.target.classList.contains('color-swatch')) {
const key = e.target.parentElement.dataset.controlKey;
currentColors[key] = e.target.dataset.color;
e.target.parentElement.querySelectorAll('.color-swatch').forEach(s => s.classList.remove('active'));
e.target.classList.add('active');
updatePreview();
}
});
}
function generateSignatureHTML(data) {
const {
prefix, name, title, phone, phoneLink, greenLineWidth, gutterWidth,
iconTextSpacing, iconVerticalOffset, mottoSpacing, sectionSpacing, titleSpacing, logoSpacing,
isReply, isSuperReply, colors, images
} = data;
const hideTitle = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
const hideLogoAddress = isReply || isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
const hideBottom = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
const hidePhoneIcon = isSuperReply ? 'mso-hide:all;display:none!important;max-height:0;overflow:hidden;font-size:0;line-height:0;' : '';
const spacerWidth = Math.max(0, iconTextSpacing);
const textPaddingLeft = Math.max(0, -iconTextSpacing);
const prefixHTML = prefix ? `<span style="font-size:13px; color:${colors.prefix};">${prefix} </span>` : '';
const logoWidth = controls.useSvgCheckbox.checked ? 162 : 162;
const logoHeight = controls.useSvgCheckbox.checked ? 24 : 24;
return `
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-family: Arial, Helvetica, sans-serif; color:#333333; font-size:14px; line-height:18px;">
<tbody>
<tr><td style="padding:0 0 ${titleSpacing}px 0;">${prefixHTML}<span style="font-size:15px; color:${colors.name}; font-weight:700;">${name}</span></td></tr>
<tr style="${hideTitle}"><td style="padding:0 0 8px 0;"><span style="font-size:12px; color:${colors.title};">${title}</span></td></tr>
<tr style="${hideBottom}">
<td style="padding:0; font-size:0; line-height:0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
<tr>
<td width="${greenLineWidth}" height="2" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:2px;"></td>
<td width="${540 - greenLineWidth}" height="2" style="font-size:0; line-height:0; height:2px;"></td>
</tr>
</table>
</td>
</tr>
<tr style="${hideLogoAddress}"><td style="padding:${logoSpacing}px 0 ${parseInt(logoSpacing, 10) + 2}px 0;">
<a href="https://www.beletage.ro" style="text-decoration:none; border:0;">
<img src="${images.logo}" alt="Beletage" style="display:block; border:0; height:${logoHeight}px; width:${logoWidth}px;" height="${logoHeight}" width="${logoWidth}">
</a>
</td></tr>
<tr>
<td style="padding-top:${hideLogoAddress ? '0' : sectionSpacing}px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540" style="font-size:13px; line-height:18px;">
<tbody>
<tr style="${hideLogoAddress}">
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
<td width="11" style="width:11px; vertical-align:top; padding-top:${4 + iconVerticalOffset}px;">
<img src="${images.greySlash}" alt="" width="11" height="11" style="display: block; border:0;">
</td>
<td width="${spacerWidth}" style="width:${spacerWidth}px; font-size:0; line-height:0;"></td>
<td style="vertical-align:top; padding:0 0 0 ${textPaddingLeft}px;">
<a href="https://maps.google.com/?q=str.%20Unirii%203%2C%20ap.%2026%2C%20Cluj-Napoca%20400417%2C%20Rom%C3%A2nia" style="color:${colors.address}; text-decoration:none;"><span style="color:${colors.address}; text-decoration:none;">str. Unirii, nr. 3, ap. 26<br>Cluj-Napoca, Cluj 400417<br>România</span></a>
</td>
</tr>
<tr>
<td width="${gutterWidth}" style="width:${gutterWidth}px; font-size:0; line-height:0;"></td>
<td width="11" style="width:11px; vertical-align:top; padding-top:${12 + iconVerticalOffset}px; ${hidePhoneIcon}">
<img src="${images.greenSlash}" alt="" width="11" height="7" style="display: block; border:0;">
</td>
<td width="${isSuperReply ? 0 : spacerWidth}" style="width:${isSuperReply ? 0 : spacerWidth}px; font-size:0; line-height:0;"></td>
<td style="vertical-align:top; padding:8px 0 0 ${isSuperReply ? 0 : textPaddingLeft}px;">
<a href="${phoneLink}" style="color:${colors.phone}; text-decoration:none;"><span style="color:${colors.phone}; text-decoration:none;">${phone}</span></a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr style="${hideBottom}"><td style="padding:${sectionSpacing}px 0 ${mottoSpacing}px 0;"><a href="https://www.beletage.ro" style="color:${colors.website}; text-decoration:none;"><span style="color:${colors.website}; text-decoration:none;">www.beletage.ro</span></a></td></tr>
<tr style="${hideBottom}">
<td style="padding:0; font-size:0; line-height:0;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="540">
<tr>
<td width="${greenLineWidth}" height="1" bgcolor="${beletageColors.verde}" style="font-size:0; line-height:0; height:1px;"></td>
<td width="${540 - greenLineWidth}" height="1" style="font-size:0; line-height:0; height:1px;"></td>
</tr>
</table>
</td>
</tr>
<tr style="${hideBottom}"><td style="padding:${mottoSpacing}px 0 0 0;"><span style="font-size:12px; color:${colors.motto}; font-style:italic;">we make complex simple</span></td></tr>
</tbody>
</table>
`;
}
function updatePreview() {
const phoneRaw = controls.phone.value.replace(/\s/g, '');
let formattedPhone = controls.phone.value;
let phoneLink = `tel:${phoneRaw}`;
if (phoneRaw.length === 10 && phoneRaw.startsWith('07')) {
formattedPhone = `+40 ${phoneRaw.substring(1, 4)} ${phoneRaw.substring(4, 7)} ${phoneRaw.substring(7, 10)}`;
phoneLink = `tel:+40${phoneRaw.substring(1)}`;
}
if (controls.superReplyCheckbox.checked) {
controls.replyCheckbox.checked = true;
controls.replyCheckbox.disabled = true;
} else {
controls.replyCheckbox.disabled = false;
}
const data = {
prefix: controls.prefix.value,
name: controls.name.value,
title: controls.title.value,
phone: formattedPhone,
phoneLink: phoneLink,
greenLineWidth: controls.greenLine.value,
gutterWidth: controls.gutter.value,
iconTextSpacing: controls.iconTextSpacing.value,
iconVerticalOffset: parseInt(controls.iconVertical.value, 10),
mottoSpacing: controls.mottoSpacing.value,
sectionSpacing: controls.sectionSpacing.value,
titleSpacing: controls.titleSpacing.value,
logoSpacing: controls.logoSpacing.value,
isReply: controls.replyCheckbox.checked,
isSuperReply: controls.superReplyCheckbox.checked,
colors: { ...currentColors },
images: controls.useSvgCheckbox.checked ? imageSets.svg : imageSets.png
};
values.greenLine.textContent = data.greenLineWidth;
values.gutter.textContent = data.gutterWidth;
values.iconTextSpacing.textContent = data.iconTextSpacing;
values.iconVertical.textContent = data.iconVerticalOffset;
values.mottoSpacing.textContent = data.mottoSpacing;
values.sectionSpacing.textContent = data.sectionSpacing;
values.titleSpacing.textContent = data.titleSpacing;
values.logoSpacing.textContent = data.logoSpacing;
previewContainer.innerHTML = generateSignatureHTML(data);
}
// --- Inițializare ---
createColorPickers();
Object.values(controls).forEach(control => {
if (control.id !== 'export-btn' && control.id !== 'zoom-btn') {
control.addEventListener('input', updatePreview);
}
});
document.querySelectorAll('.collapsible-trigger').forEach(trigger => {
trigger.addEventListener('click', () => {
const content = trigger.nextElementSibling;
trigger.classList.toggle('open');
content.classList.toggle('open');
});
});
controls.zoomBtn.addEventListener('click', () => {
const isZoomed = previewWrapper.style.transform === 'scale(2)';
if (isZoomed) {
previewWrapper.style.transform = 'scale(1)';
controls.zoomBtn.textContent = 'Zoom 200%';
} else {
previewWrapper.style.transform = 'scale(2)';
controls.zoomBtn.textContent = 'Zoom 100%';
}
});
controls.exportBtn.addEventListener('click', () => {
const finalHTML = previewContainer.innerHTML;
const blob = new Blob([finalHTML], { type: 'text/html' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'semnatura-beletage.html';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
updatePreview();
});
</script>
</body>
</html>
@@ -1,148 +0,0 @@
Pauza de masa
Timp personal
Concediu
Compensare overtime
Beletage
Ofertare
Configurari
Organizare initiala
Pregatire Portofoliu
Website
Documentare
Design grafic
Design interior
Design exterior
Releveu
Reclama
000 Farmacie
002 Cladire birouri Stratec
003 PUZ Bellavista
007 Design Apartament Teodora
010 Casa Doinei
016 Duplex Eremia
024 Bloc Petofi
028 PUZ Borhanci-Sopor
033 Mansardare Branului
039 Cabinete Stoma Scala
041 Imobil mixt Progresului
045 Casa Andrei Muresanu
052 PUZ Carpenului
059 PUZ Nordului
064 Casa Salicea
066 Terasa Gherase
070 Bloc Fanatelor
073 Case Frumoasa
074 PUG Cosbuc
076 Casa Copernicus
077 PUZ Schimbare destinatie Brancusi
078 Service auto Linistei
079 Amenajare drum Servitute Eremia
080 Bloc Tribunul
081 Extindere casa Gherase
083 Modificari casa Zsigmund 18
084 Mansardare Petofi 21
085 Container CT Spital Tabacarilor
086 Imprejmuire casa sat Gheorgheni
087 Duplex Oasului fn
089 PUZ A-Liu Sopor
090 VR MedEvents
091 Reclama Caparol
092 Imobil birouri 13 Septembrie
093 Casa Salistea Noua
094 PUD Casa Rediu
095 Duplex Vanatorului
096 Design apartament Sopor
097 Cabana Gilau
101 PUZ Gilau
102 PUZ Ghimbav
103 Piscine Lunca Noua
104 PUZ REGHIN
105 CUT&Crust
106 PUZ Mihai Romanu Nord
108 Reabilitare Bloc Beiusului
109 Case Samboleni
110 Penny Crasna
111 Anexa Piscina Borhanci
112 PUZ Blocuri Bistrita
113 PUZ VARATEC-FIRIZA
114 PUG Husi
115 PUG Josenii Bargaului
116 PUG Monor
117 Schimbare Destinatie Mihai Viteazu 2
120 Anexa Brasov
121 Imprejurare imobil Mesterul Manole 9
122 Fastfood Bashar
123 PUD Rediu 2
127 Casa Socaciu Ciurila
128 Schimbare de destinatie Danubius
129 (re) Casa Sarca-Sorescu
130 Casa Suta-Wonderland
131 PUD Oasului Hufi
132 Reabilitare Camin Cultural Baciu
133 PUG Feldru
134 DALI Blocuri Murfatlar
135 Case de vacanta Dianei
136 PUG BROSTENI
139 Casa Turda
140 Releveu Bistrita (Morariu)
141 PUZ Janovic Jeno
142 Penny Borhanci
143 Pavilion Politie Radauti
149 Duplex Sorescu 31-33
150 DALI SF Scoala Baciu
151 Casa Alexandru Bohatiel 17
152 PUZ Penny Tautii Magheraus
153 PUG Banita
155 PT Scoala Floresti
156 Case Sorescu
157 Gradi-Cresa Baciu
158 Duplex Sorescu 21-23
159 Amenajare Spatiu Grenke PBC
160 Etajare Primaria Baciu
161 Extindere Ap Baciu
164 SD salon Aurel Vlaicu
165 Reclama Marasti
166 Catei Apahida
167 Apartament Mircea Zaciu 13-15
169 Casa PETRILA 37
170 Cabana Campeni AB
171 Camin Apahida
L089 PUZ TUSA-BOJAN
172 Design casa Iugoslaviei 18
173 Reabilitare spitale Sighetu
174 StudX UMFST
176 - 2025 - ReAC Ansamblu rezi Bibescu
CU
Schita
Avize
PUD
AO
PUZ
PUG
DTAD
DTAC
PT
Detalii de Executie
Studii de fundamentare
Regulament
Parte desenata
Parte scrisa
Consultanta client
Macheta
Consultanta receptie
Redactare
Depunere
Ridicare
Verificare proiect
Vizita santier
Master MATDR
@@ -1,694 +0,0 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>Beletage Word XML Data Engine</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- JSZip pentru arhivă ZIP -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"
integrity="sha512-FGv7V3GpCr3C6wz6Q4z8F1v8y4mZohwPqhwKiPfz0btvAvOE0tfLOgvBcFQncn1C3KW0y5fN9c7v1sQW8vGfMQ=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<style>
:root {
color-scheme: dark;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 1.5rem;
background: #020617;
color: #e5e7eb;
}
h1 {
font-size: 1.7rem;
margin-bottom: .25rem;
}
.subtitle {
font-size: .9rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.card {
background: #020617;
border-radius: 1rem;
padding: 1.1rem 1.3rem;
margin-bottom: 1rem;
border: 1px solid #1e293b;
box-shadow: 0 15px 35px rgba(0,0,0,.45);
}
label {
font-size: .8rem;
color: #9ca3af;
display: block;
margin-bottom: .2rem;
}
input, textarea, select {
width: 100%;
box-sizing: border-box;
padding: .5rem .6rem;
border-radius: .5rem;
border: 1px solid #334155;
background: #020617;
color: #e5e7eb;
font-family: inherit;
font-size: .9rem;
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 1px #38bdf8;
}
textarea { min-height: 140px; resize: vertical; }
.row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.col-3 { flex: 1 1 220px; }
.col-6 { flex: 1 1 320px; }
.col-9 { flex: 3 1 420px; }
button {
padding: .55rem 1.1rem;
border-radius: 999px;
border: none;
background: linear-gradient(135deg, #38bdf8, #6366f1);
color: #fff;
font-size: .9rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 12px 25px rgba(37,99,235,.4);
}
button:hover { filter: brightness(1.05); transform: translateY(-1px); }
button:active { transform: translateY(0); box-shadow: 0 8px 18px rgba(37,99,235,.6); }
.btn-secondary {
background: transparent;
border: 1px solid #4b5563;
box-shadow: none;
}
.btn-secondary:hover {
background: #020617;
box-shadow: 0 8px 20px rgba(0,0,0,.6);
}
.btn-small {
font-size: .8rem;
padding: .35rem .8rem;
box-shadow: none;
}
.toggle {
display: inline-flex;
align-items: center;
gap: .4rem;
font-size: .8rem;
color: #cbd5f5;
cursor: pointer;
user-select: none;
}
.toggle input { width: auto; }
.pill-row {
display: flex;
flex-wrap: wrap;
gap: .4rem;
margin-bottom: .4rem;
}
.pill {
padding: .25rem .7rem;
border-radius: 999px;
border: 1px solid #334155;
font-size: .8rem;
cursor: pointer;
background: #020617;
color: #e5e7eb;
display: inline-flex;
align-items: center;
gap: .35rem;
}
.pill.active {
background: linear-gradient(135deg, #38bdf8, #6366f1);
border-color: transparent;
color: #0f172a;
}
.pill span.remove {
font-size: .8rem;
opacity: .7;
}
.pill span.remove:hover { opacity: 1; }
.small {
font-size: .8rem;
color: #9ca3af;
margin-top: .25rem;
}
pre {
background: #020617;
border-radius: .75rem;
padding: .7rem .8rem;
border: 1px solid #1f2937;
overflow: auto;
font-size: .8rem;
max-height: 340px;
}
.badge {
display: inline-flex;
align-items: center;
padding: .15rem .45rem;
border-radius: 999px;
font-size: .7rem;
background: rgba(148,163,184,.18);
margin-right: .4rem;
margin-bottom: .25rem;
}
@media (max-width: 768px) {
body { padding: 1rem; }
.card { padding: 1rem; }
}
</style>
</head>
<body>
<div class="container">
<h1>Beletage Word XML Data Engine</h1>
<p class="subtitle">
Generator de <strong>Custom XML Parts</strong> pentru Word, pe categorii (Beneficiar, Proiect, Suprafete, Meta etc.),
cu mod <em>Simple</em> / <em>Advanced</em> și câmpuri derivate (Short, Upper, Initials) + POT/CUT pregătite.
</p>
<!-- SETĂRI GLOBALE -->
<div class="card">
<div class="row">
<div class="col-6">
<label for="baseNs">Bază Namespace (se completează automat cu /Categorie)</label>
<input id="baseNs" type="text" value="http://schemas.beletage.ro/contract">
<div class="small">Ex: <code>http://schemas.beletage.ro/contract</code> → pentru categoria „Proiect” devine
<code>http://schemas.beletage.ro/contract/Proiect</code>.
</div>
</div>
<div class="col-3">
<label>Mod generare câmpuri</label>
<div class="pill-row">
<div class="pill active" id="modeSimplePill" onclick="setMode('simple')">Simple</div>
<div class="pill" id="modeAdvancedPill" onclick="setMode('advanced')">Advanced</div>
</div>
<div class="small">
<strong>Simple</strong>: doar câmpurile tale.<br>
<strong>Advanced</strong>: + Short / Upper / Lower / Initials / First pentru fiecare câmp.
</div>
</div>
<div class="col-3">
<label>Opțiuni extra</label>
<div class="small" style="margin-top:.25rem;">
<label class="toggle">
<input type="checkbox" id="computeMetrics" checked>
<span>Adaugă câmpuri POT / CUT în categoria Suprafete</span>
</label>
</div>
</div>
</div>
</div>
<!-- CATEGORII -->
<div class="card">
<div class="row">
<div class="col-3">
<label>Categorii de date</label>
<div id="categoryPills" class="pill-row"></div>
<button class="btn-secondary btn-small" onclick="addCategoryPrompt()">+ Adaugă categorie</button>
<div class="small">
Exemple de organizare: <code>Beneficiar</code>, <code>Proiect</code>, <code>Suprafete</code>, <code>Meta</code>.
</div>
</div>
<div class="col-9">
<label>Câmpuri pentru categoria selectată</label>
<textarea id="fieldsArea"></textarea>
<div class="small">
Un câmp pe linie. Poți edita lista. Butonul „Reset categorie la preset” reîncarcă valorile default pentru
categoria curentă (dacă există).
</div>
<div style="margin-top:.5rem; display:flex; gap:.5rem; flex-wrap:wrap;">
<button class="btn-secondary btn-small" onclick="resetCategoryToPreset()">Reset categorie la preset</button>
<button class="btn-secondary btn-small" onclick="clearCategoryFields()">Curăță câmpurile</button>
</div>
<div class="small" id="nsRootInfo" style="margin-top:.6rem;"></div>
</div>
</div>
</div>
<!-- GENERARE & DOWNLOAD -->
<div class="card">
<div style="display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; margin-bottom:.5rem;">
<button onclick="generateAll()">Generează XML pentru toate categoriile</button>
<button class="btn-secondary" onclick="downloadCurrentXml()">Descarcă XML categorie curentă</button>
<button class="btn-secondary" onclick="downloadZipAll()">Descarcă ZIP cu toate XML-urile</button>
</div>
<div class="small">
<span class="badge">Tip</span>
În Word, fiecare fișier generat devine un Custom XML Part separat (ex: <code>BeneficiarData.xml</code>,
<code>ProiectData.xml</code> etc.), perfect pentru organizarea mapping-urilor.
</div>
</div>
<!-- PREVIEW -->
<div class="card">
<h3 style="margin-top:0;">Preview XML & XPaths</h3>
<div class="small" style="margin-bottom:.4rem;">
Selectează o categorie pentru a vedea XML-ul și XPaths-urile aferente.
</div>
<div class="row">
<div class="col-6">
<div class="badge">XML categorie curentă</div>
<pre id="xmlPreview"></pre>
</div>
<div class="col-6">
<div class="badge">XPaths categorie curentă</div>
<pre id="xpathPreview"></pre>
</div>
</div>
</div>
</div>
<script>
// --- PRESETURI CATEGORII ---
const defaultPresets = {
"Beneficiar": [
"NumeClient",
"Adresa",
"CUI",
"CNP",
"Reprezentant",
"Email",
"Telefon"
],
"Proiect": [
"TitluProiect",
"AdresaImobil",
"NrCadastral",
"NrCF",
"Localitate",
"Judet"
],
"Suprafete": [
"SuprafataTeren",
"SuprafataConstruitaLaSol",
"SuprafataDesfasurata",
"SuprafataUtila"
],
"Meta": [
"NrContract",
"DataContract",
"Responsabil",
"VersiuneDocument",
"DataGenerarii"
]
};
// --- STATE ---
let categories = {}; // { Categorie: { fieldsText: "..." } }
let currentCategory = "Beneficiar";
let mode = "advanced"; // "simple" | "advanced"
const xmlParts = {}; // { Categorie: xmlString }
const xpathParts = {}; // { Categorie: xpathString }
// --- UTILITARE ---
function sanitizeName(name) {
if (!name) return null;
let n = name.trim();
if (!n) return null;
n = n.replace(/\s+/g, "_").replace(/[^A-Za-z0-9_.-]/g, "");
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
return n;
}
function initialsFromLabel(label) {
if (!label) return "";
return label.trim().split(/\s+/).map(s => s.charAt(0).toUpperCase() + ".").join("");
}
function firstToken(label) {
if (!label) return "";
return label.trim().split(/\s+/)[0] || "";
}
function getBaseNamespace() {
const val = document.getElementById("baseNs").value.trim();
return val || "http://schemas.beletage.ro/contract";
}
function getCategoryNamespace(cat) {
const base = getBaseNamespace();
const safeCat = sanitizeName(cat) || cat;
return base.replace(/\/+$/,"") + "/" + safeCat;
}
function getCategoryRoot(cat) {
const safeCat = sanitizeName(cat) || cat;
return safeCat + "Data";
}
// --- MOD SIMPLE/ADVANCED ---
function setMode(m) {
mode = m === "advanced" ? "advanced" : "simple";
document.getElementById("modeSimplePill").classList.toggle("active", mode === "simple");
document.getElementById("modeAdvancedPill").classList.toggle("active", mode === "advanced");
// regenerăm previw dacă avem ceva
generateAll(false);
}
// --- CATEGORII: INIT, UI, STORAGE ---
function initCategories() {
// încarcă din localStorage, altfel default
const saved = window.localStorage.getItem("beletage_xml_categories");
if (saved) {
try {
const parsed = JSON.parse(saved);
categories = parsed.categories || {};
currentCategory = parsed.currentCategory || "Beneficiar";
} catch(e) {
Object.keys(defaultPresets).forEach(cat => {
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
});
currentCategory = "Beneficiar";
}
} else {
Object.keys(defaultPresets).forEach(cat => {
categories[cat] = { fieldsText: defaultPresets[cat].join("\n") };
});
currentCategory = "Beneficiar";
}
renderCategoryPills();
loadCategoryToUI(currentCategory);
}
function persistCategories() {
try {
window.localStorage.setItem("beletage_xml_categories", JSON.stringify({
categories,
currentCategory
}));
} catch(e){}
}
function renderCategoryPills() {
const container = document.getElementById("categoryPills");
container.innerHTML = "";
Object.keys(categories).forEach(cat => {
const pill = document.createElement("div");
pill.className = "pill" + (cat === currentCategory ? " active" : "");
pill.onclick = () => switchCategory(cat);
pill.textContent = cat;
// nu permitem ștergerea preset-urilor de bază direct (doar la custom)
if (!defaultPresets[cat]) {
const remove = document.createElement("span");
remove.className = "remove";
remove.textContent = "×";
remove.onclick = (ev) => {
ev.stopPropagation();
deleteCategory(cat);
};
pill.appendChild(remove);
}
container.appendChild(pill);
});
}
function switchCategory(cat) {
saveCurrentCategoryFields();
currentCategory = cat;
renderCategoryPills();
loadCategoryToUI(cat);
updateNsRootInfo();
showPreview(cat);
persistCategories();
}
function loadCategoryToUI(cat) {
const area = document.getElementById("fieldsArea");
area.value = categories[cat]?.fieldsText || "";
updateNsRootInfo();
}
function saveCurrentCategoryFields() {
const area = document.getElementById("fieldsArea");
if (!categories[currentCategory]) {
categories[currentCategory] = { fieldsText: "" };
}
categories[currentCategory].fieldsText = area.value;
}
function deleteCategory(cat) {
if (!confirm(`Sigur ștergi categoria "${cat}"?`)) return;
delete categories[cat];
const keys = Object.keys(categories);
currentCategory = keys[0] || "Beneficiar";
renderCategoryPills();
loadCategoryToUI(currentCategory);
updateNsRootInfo();
persistCategories();
}
function addCategoryPrompt() {
const name = prompt("Nume categorie nouă (ex: Urbanism, Fiscal, Altele):");
if (!name) return;
const trimmed = name.trim();
if (!trimmed) return;
if (categories[trimmed]) {
alert("Categoria există deja.");
return;
}
categories[trimmed] = { fieldsText: "" };
currentCategory = trimmed;
renderCategoryPills();
loadCategoryToUI(currentCategory);
updateNsRootInfo();
persistCategories();
}
function resetCategoryToPreset() {
if (!defaultPresets[currentCategory]) {
alert("Categoria curentă nu are preset definit.");
return;
}
if (!confirm("Resetezi lista de câmpuri la presetul standard pentru această categorie?")) return;
categories[currentCategory].fieldsText = defaultPresets[currentCategory].join("\n");
loadCategoryToUI(currentCategory);
persistCategories();
}
function clearCategoryFields() {
categories[currentCategory].fieldsText = "";
loadCategoryToUI(currentCategory);
persistCategories();
}
function updateNsRootInfo() {
const ns = getCategoryNamespace(currentCategory);
const root = getCategoryRoot(currentCategory);
document.getElementById("nsRootInfo").innerHTML =
`<strong>Namespace:</strong> <code>${ns}</code><br>` +
`<strong>Root element:</strong> <code>&lt;${root}&gt;</code>`;
}
// --- GENERARE XML PENTRU O CATEGORIE ---
function generateCategory(cat) {
const entry = categories[cat];
if (!entry) return { xml: "", xpaths: "" };
const raw = (entry.fieldsText || "").split(/\r?\n/)
.map(l => l.trim())
.filter(l => l.length > 0);
if (raw.length === 0) {
return { xml: "", xpaths: "" };
}
const ns = getCategoryNamespace(cat);
const root = getCategoryRoot(cat);
const computeMetrics = document.getElementById("computeMetrics").checked;
const usedNames = new Set();
const fields = []; // { label, baseName, variants: [] }
for (const label of raw) {
const base = sanitizeName(label);
if (!base) continue;
let baseName = base;
let idx = 2;
while (usedNames.has(baseName)) {
baseName = base + "_" + idx;
idx++;
}
usedNames.add(baseName);
const variants = [baseName];
if (mode === "advanced") {
const advCandidates = [
baseName + "Short",
baseName + "Upper",
baseName + "Lower",
baseName + "Initials",
baseName + "First"
];
for (let v of advCandidates) {
let vn = v;
let k = 2;
while (usedNames.has(vn)) {
vn = v + "_" + k;
k++;
}
usedNames.add(vn);
variants.push(vn);
}
}
fields.push({ label, baseName, variants });
}
// detectăm câmpuri pentru metrici (în special categoria Suprafete)
const extraMetricFields = [];
if (computeMetrics && cat.toLowerCase().includes("suprafete")) {
const hasTeren = fields.some(f => f.baseName.toLowerCase().includes("suprafatateren"));
const hasLaSol = fields.some(f => f.baseName.toLowerCase().includes("suprafataconstruitalasol"));
const hasDesf = fields.some(f => f.baseName.toLowerCase().includes("suprafatadesfasurata"));
if (hasTeren && hasLaSol) {
if (!usedNames.has("POT")) {
usedNames.add("POT");
extraMetricFields.push({ label: "Procent Ocupare Teren", baseName: "POT", variants: ["POT"] });
}
}
if (hasTeren && hasDesf) {
if (!usedNames.has("CUT")) {
usedNames.add("CUT");
extraMetricFields.push({ label: "Coeficient Utilizare Teren", baseName: "CUT", variants: ["CUT"] });
}
}
}
// generăm XML
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${ns}">\n`;
const allFieldEntries = fields.concat(extraMetricFields);
for (const f of allFieldEntries) {
for (const v of f.variants) {
xml += ` <${v}></${v}>\n`;
}
}
xml += `</${root}>\n`;
// generăm XPaths
let xp = `Categorie: ${cat}\nNamespace: ${ns}\nRoot: /${root}\n\n`;
for (const f of fields) {
xp += `# ${f.label}\n`;
for (const v of f.variants) {
xp += `/${root}/${v}\n`;
}
xp += `\n`;
}
if (extraMetricFields.length > 0) {
xp += `# Metrici auto (POT / CUT)\n`;
for (const f of extraMetricFields) {
for (const v of f.variants) {
xp += `/${root}/${v}\n`;
}
}
xp += `\n`;
}
return { xml, xpaths: xp };
}
// --- GENERARE PENTRU TOATE CATEGORIILE ---
function generateAll(showForCurrent = true) {
saveCurrentCategoryFields();
Object.keys(categories).forEach(cat => {
const { xml, xpaths } = generateCategory(cat);
xmlParts[cat] = xml;
xpathParts[cat] = xpaths;
});
if (showForCurrent) {
showPreview(currentCategory);
}
persistCategories();
}
// --- PREVIEW ---
function showPreview(cat) {
document.getElementById("xmlPreview").textContent = xmlParts[cat] || "<!-- Niciun XML generat încă pentru această categorie. -->";
document.getElementById("xpathPreview").textContent = xpathParts[cat] || "";
}
// --- DOWNLOAD: XML CATEGORIE ---
function downloadCurrentXml() {
generateAll(false);
const xml = xmlParts[currentCategory];
if (!xml) {
alert("Nu există XML generat pentru categoria curentă. Apasă întâi „Generează XML pentru toate categoriile”.");
return;
}
const root = getCategoryRoot(currentCategory);
const fileName = root + ".xml";
const blob = new Blob([xml], { type: "application/xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = fileName;
a.click();
URL.revokeObjectURL(a.href);
}
// --- DOWNLOAD: ZIP CU TOATE XML-URILE ---
async function downloadZipAll() {
generateAll(false);
const cats = Object.keys(categories);
if (cats.length === 0) {
alert("Nu există categorii.");
return;
}
const zip = new JSZip();
const folder = zip.folder("customXmlParts");
let hasAny = false;
for (const cat of cats) {
const xml = xmlParts[cat];
if (!xml) continue;
hasAny = true;
const root = getCategoryRoot(cat);
const fileName = root + ".xml";
folder.file(fileName, xml);
}
if (!hasAny) {
alert("Nu există XML generat încă. Apasă întâi „Generează XML pentru toate categoriile”.");
return;
}
const content = await zip.generateAsync({ type: "blob" });
const a = document.createElement("a");
a.href = URL.createObjectURL(content);
a.download = "beletage_custom_xml_parts.zip";
a.click();
URL.revokeObjectURL(a.href);
}
// --- INIT ---
window.addEventListener("DOMContentLoaded", () => {
initCategories();
updateNsRootInfo();
generateAll();
});
</script>
</body>
</html>
@@ -1,151 +0,0 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>Generator XML Word Versiune Extinsă</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: system-ui, sans-serif;
padding: 1.5rem;
background: #0f172a;
color: #e5e7eb;
}
.card {
background: #020617;
border-radius: 1rem;
padding: 1.25rem;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
border: 1px solid #1e293b;
margin-bottom: 1rem;
}
label { font-size: .85rem; color: #94a3b8; }
input, textarea {
width: 100%; padding: .55rem .7rem;
border-radius: .5rem; border: 1px solid #334155;
background: #020617; color: #e5e7eb;
}
textarea { min-height: 120px; }
button {
padding: .6rem 1.2rem; border-radius: 999px; border: none;
background: linear-gradient(135deg,#38bdf8,#6366f1);
font-weight: 600; color: white; cursor: pointer;
}
pre {
background: #000; padding: .8rem; border-radius: .7rem;
border: 1px solid #1e293b; max-height: 350px; overflow: auto;
font-size: .85rem;
}
</style>
</head>
<body>
<h1>Generator Word XML Varianta Extinsă (cu Short / Upper / Lower / Initials)</h1>
<div class="card">
<label>Namespace URI</label>
<input id="nsUri" value="http://schemas.beletage.ro/word/contract">
<label style="margin-top:1rem;">Element rădăcină</label>
<input id="rootElement" value="ContractData">
<label style="margin-top:1rem;">Lista de câmpuri (unul pe linie)</label>
<textarea id="fieldList">NumeClient
TitluProiect
Adresa</textarea>
<button onclick="generateXML()" style="margin-top:1rem;">Generează XML complet</button>
</div>
<div class="card">
<h3>Custom XML Part (item1.xml)</h3>
<pre id="xmlOutput"></pre>
<button onclick="downloadXML()">Descarcă XML</button>
</div>
<div class="card">
<h3>XPaths pentru mapping</h3>
<pre id="xpathOutput"></pre>
</div>
<script>
function sanitize(name) {
if (!name) return null;
let n = name.trim();
if (!n) return null;
n = n.replace(/\s+/g,"_").replace(/[^A-Za-z0-9_.-]/g,"");
if (!/^[A-Za-z_]/.test(n)) n = "_" + n;
return n;
}
function initials(str) {
return str.split(/\s+/).map(s => s[0]?.toUpperCase() + ".").join("");
}
function generateXML() {
const ns = document.getElementById("nsUri").value.trim();
const root = sanitize(document.getElementById("rootElement").value) || "Root";
const fieldRaw = document.getElementById("fieldList").value;
const lines = fieldRaw.split(/\r?\n/)
.map(l => l.trim()).filter(l => l.length);
const fields = [];
for (let l of lines) {
const base = sanitize(l);
if (!base) continue;
fields.push({
base,
variants: [
base, // original
base + "Short", // prescurtat
base + "Upper", // caps
base + "Lower", // lowercase
base + "Initials", // inițiale
base + "First" // primul cuvânt
]
});
}
// === GENERĂM XML ===
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${ns}">\n`;
for (const f of fields) {
for (const v of f.variants) {
xml += ` <${v}></${v}>\n`;
}
}
xml += `</${root}>`;
document.getElementById("xmlOutput").textContent = xml;
// === GENERĂM XPATHS ===
let xp = `Namespace: ${ns}\nRoot: /${root}\n\n`;
for (const f of fields) {
xp += `# ${f.base}\n`;
xp += `/${root}/${f.base}\n`;
xp += `/${root}/${f.base}Short\n`;
xp += `/${root}/${f.base}Upper\n`;
xp += `/${root}/${f.base}Lower\n`;
xp += `/${root}/${f.base}Initials\n`;
xp += `/${root}/${f.base}First\n\n`;
}
document.getElementById("xpathOutput").textContent = xp;
}
function downloadXML() {
const text = document.getElementById("xmlOutput").textContent;
const blob = new Blob([text], { type: "application/xml" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "item1.xml";
a.click();
}
</script>
</body>
</html>
@@ -1,330 +0,0 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8">
<title>Generator Word XML Custom Part</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
margin: 0;
padding: 1.5rem;
background: #0f172a;
color: #e5e7eb;
}
h1 {
font-size: 1.6rem;
margin-bottom: 0.5rem;
}
.container {
max-width: 1100px;
margin: 0 auto;
}
.card {
background: #020617;
border-radius: 1rem;
padding: 1.25rem 1.5rem;
box-shadow: 0 15px 40px rgba(0,0,0,0.35);
border: 1px solid #1f2937;
margin-bottom: 1rem;
}
label {
display: block;
font-size: 0.85rem;
color: #9ca3af;
margin-bottom: 0.25rem;
}
input, textarea {
width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
border-radius: 0.5rem;
border: 1px solid #374151;
background: #020617;
color: #e5e7eb;
font-family: inherit;
font-size: 0.9rem;
outline: none;
}
input:focus, textarea:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 1px #38bdf8;
}
textarea {
min-height: 140px;
resize: vertical;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.col-6 {
flex: 1 1 260px;
}
button {
padding: 0.6rem 1.2rem;
border-radius: 999px;
border: none;
font-size: 0.9rem;
cursor: pointer;
margin-top: 0.75rem;
background: linear-gradient(135deg, #38bdf8, #6366f1);
color: white;
font-weight: 600;
box-shadow: 0 10px 25px rgba(37, 99, 235, 0.4);
}
button:hover {
filter: brightness(1.05);
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
box-shadow: 0 6px 18px rgba(37,99,235,0.6);
}
pre {
background: #020617;
border-radius: 0.75rem;
padding: 0.75rem 1rem;
overflow: auto;
font-size: 0.8rem;
border: 1px solid #1f2937;
max-height: 360px;
}
.subtitle {
font-size: 0.85rem;
color: #9ca3af;
margin-bottom: 1rem;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
background: rgba(148, 163, 184, 0.2);
margin-right: 0.25rem;
margin-bottom: 0.25rem;
}
.pill span {
opacity: 0.8;
}
.small {
font-size: 0.8rem;
color: #9ca3af;
margin-top: 0.4rem;
}
.btn-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
}
.btn-secondary {
background: transparent;
border: 1px solid #4b5563;
box-shadow: none;
color: #e5e7eb;
}
.btn-secondary:hover {
background: #111827;
box-shadow: 0 8px 18px rgba(0,0,0,0.5);
}
@media (max-width: 640px) {
body {
padding: 1rem;
}
.card {
padding: 1rem;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Generator XML pentru Word Custom XML Part</h1>
<p class="subtitle">
Introdu câmpurile (unul pe linie) și obții XML pentru <strong>Custom XML Part</strong>, plus XPaths pentru mapping în Word.
</p>
<div class="card">
<div class="row">
<div class="col-6">
<label for="nsUri">Namespace URI (obligatoriu)</label>
<input id="nsUri" type="text"
value="http://schemas.beletage.ro/word/data">
<div class="small">
Exemplu: <code>http://schemas.firma-ta.ro/word/contract</code>
</div>
</div>
<div class="col-6">
<label for="rootElement">Nume element rădăcină</label>
<input id="rootElement" type="text" value="Root">
<div class="small">
Exemplu: <code>ContractData</code>, <code>ClientInfo</code> etc.
</div>
</div>
</div>
<div style="margin-top:1rem;">
<label for="fieldList">Lista de câmpuri (unul pe linie)</label>
<textarea id="fieldList" placeholder="Exemplu:
NumeClient
Adresa
DataContract
ValoareTotala"></textarea>
<div class="small">
Numele va fi curățat automat pentru a fi valid ca nume de element XML
(spațiile devin <code>_</code>, caracterele ciudate se elimină).
</div>
</div>
<div class="btn-row">
<button type="button" onclick="generateXML()">Generează XML</button>
<button type="button" class="btn-secondary" onclick="fillDemo()">Exemplu demo</button>
</div>
</div>
<div class="card">
<div class="pill"><strong>1</strong><span>Custom XML Part (item1.xml)</span></div>
<pre id="xmlOutput"></pre>
<div class="btn-row">
<button type="button" class="btn-secondary" onclick="copyToClipboard('xmlOutput')">
Copiază XML
</button>
<button type="button" class="btn-secondary" onclick="downloadXML()">
Descarcă item1.xml
</button>
</div>
</div>
<div class="card">
<div class="pill"><strong>2</strong><span>XPaths pentru mapping în Word</span></div>
<pre id="xpathOutput"></pre>
<button type="button" class="btn-secondary" onclick="copyToClipboard('xpathOutput')">
Copiază XPaths
</button>
<p class="small">
În Word &rarr; <strong>Developer</strong> &rarr; <strong>XML Mapping Pane</strong> &rarr; alegi Custom XML Part-ul
&rarr; pentru fiecare câmp, click dreapta &rarr; <em>Insert Content Control</em> &rarr; tipul dorit.
</p>
</div>
</div>
<script>
function sanitizeXmlName(name) {
if (!name) return null;
let n = name.trim();
if (!n) return null;
// înlocuim spații cu underscore
n = n.replace(/\s+/g, "_");
// eliminăm caractere invalide pentru nume de element XML
n = n.replace(/[^A-Za-z0-9_.-]/g, "");
// numele XML nu are voie să înceapă cu cifră sau punct sau cratimă
if (!/^[A-Za-z_]/.test(n)) {
n = "_" + n;
}
return n || null;
}
function generateXML() {
const nsUri = document.getElementById("nsUri").value.trim();
const root = sanitizeXmlName(document.getElementById("rootElement").value) || "Root";
const fieldRaw = document.getElementById("fieldList").value;
const xmlOutput = document.getElementById("xmlOutput");
const xpathOutput = document.getElementById("xpathOutput");
if (!nsUri) {
alert("Te rog completează Namespace URI.");
return;
}
const lines = fieldRaw.split(/\r?\n/)
.map(l => l.trim())
.filter(l => l.length > 0);
const fields = [];
const used = new Set();
for (let line of lines) {
const clean = sanitizeXmlName(line);
if (!clean) continue;
let finalName = clean;
let idx = 2;
while (used.has(finalName)) {
finalName = clean + "_" + idx;
idx++;
}
used.add(finalName);
fields.push({ original: line, xmlName: finalName });
}
if (fields.length === 0) {
xmlOutput.textContent = "<!-- Niciun câmp valid. Completează lista de câmpuri. -->";
xpathOutput.textContent = "";
return;
}
// Generăm XML-ul pentru Custom XML Part
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += `<${root} xmlns="${nsUri}">\n`;
for (const f of fields) {
xml += ` <${f.xmlName}></${f.xmlName}>\n`;
}
xml += `</${root}>\n`;
xmlOutput.textContent = xml;
// Generăm lista de XPaths
let xpaths = `Namespace: ${nsUri}\nRoot: /${root}\n\nCâmpuri:\n`;
for (const f of fields) {
xpaths += `- ${f.original} => /${root}/${f.xmlName}\n`;
}
xpathOutput.textContent = xpaths;
}
function copyToClipboard(elementId) {
const el = document.getElementById(elementId);
if (!el || !el.textContent) return;
navigator.clipboard.writeText(el.textContent)
.then(() => alert("Copiat în clipboard."))
.catch(() => alert("Nu am reușit să copiez în clipboard."));
}
function downloadXML() {
const xmlText = document.getElementById("xmlOutput").textContent;
if (!xmlText || xmlText.startsWith("<!--")) {
alert("Nu există XML valid de descărcat.");
return;
}
const blob = new Blob([xmlText], { type: "application/xml" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "item1.xml";
a.click();
URL.revokeObjectURL(url);
}
function fillDemo() {
document.getElementById("nsUri").value = "http://schemas.beletage.ro/word/contract";
document.getElementById("rootElement").value = "ContractData";
document.getElementById("fieldList").value = [
"NumeClient",
"AdresaClient",
"Proiect",
"DataContract",
"ValoareTotala",
"Moneda",
"TermenExecutie"
].join("\n");
generateXML();
}
</script>
</body>
</html>
+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
+13
View File
@@ -6,6 +6,19 @@ const nextConfig: NextConfig = {
experimental: {
middlewareClientMaxBodySize: '500mb',
},
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
],
},
];
},
async rewrites() {
const martinUrl = process.env.MARTIN_URL || 'http://martin:3000';
return [
+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
+31 -1
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 {
@@ -42,7 +72,7 @@ model GisFeature {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id])
syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id], onDelete: SetNull)
@@unique([layerId, objectId])
@@index([siruta])
+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>
);
}
+878
View File
@@ -0,0 +1,878 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/shared/components/ui/button";
import { Badge } from "@/shared/components/ui/badge";
import { Card, CardContent } from "@/shared/components/ui/card";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/shared/components/ui/tabs";
import {
Copy,
Check,
ChevronDown,
ChevronRight,
Terminal,
Bug,
Sparkles,
Shield,
TestTube,
Plug,
RefreshCw,
FileText,
Zap,
Rocket,
ListChecks,
Brain,
Wrench,
} from "lucide-react";
// ─── Types ─────────────────────────────────────────────────────────
type PromptCategory = {
id: string;
label: string;
icon: React.ReactNode;
description: string;
prompts: PromptTemplate[];
};
type PromptTemplate = {
id: string;
title: string;
description: string;
prompt: string;
tags: string[];
oneTime?: boolean;
variables?: string[];
};
// ─── Module list for variable substitution hints ───────────────────
const MODULES = [
"registratura", "address-book", "parcel-sync", "geoportal", "password-vault",
"mini-utilities", "email-signature", "word-xml", "word-templates", "tag-manager",
"it-inventory", "digital-signatures", "prompt-generator", "ai-chat", "hot-desk",
"visual-copilot", "dashboard",
] as const;
// ─── Prompt Templates ──────────────────────────────────────────────
const CATEGORIES: PromptCategory[] = [
{
id: "module-work",
label: "Lucru pe modul",
icon: <Terminal className="size-4" />,
description: "Prompturi pentru lucru general, bugfix-uri si features pe module existente",
prompts: [
{
id: "module-work-general",
title: "Lucru general pe modul",
description: "Sesiune de lucru pe un modul specific — citeste contextul, propune, implementeaza",
tags: ["regular", "module"],
variables: ["MODULE_NAME"],
prompt: `Scopul acestei sesiuni este sa lucram pe modulul {MODULE_NAME} din ArchiTools.
Citeste CLAUDE.md si docs/MODULE-MAP.md inainte de orice.
Apoi citeste tipurile si componentele modulului:
- src/modules/{MODULE_NAME}/types.ts
- src/modules/{MODULE_NAME}/config.ts
- src/modules/{MODULE_NAME}/components/ (fisierele principale)
- src/modules/{MODULE_NAME}/services/ (daca exista)
Dupa ce ai inteles codul existent, intreaba-ma ce vreau sa fac.
Nu propune schimbari pana nu intelegi modulul complet.
npx next build TREBUIE sa treaca dupa fiecare schimbare.`,
},
{
id: "bugfix",
title: "Bugfix pe modul",
description: "Investigheaza si rezolva un bug specific",
tags: ["regular", "bugfix"],
variables: ["MODULE_NAME", "BUG_DESCRIPTION"],
prompt: `Am un bug in modulul {MODULE_NAME}: {BUG_DESCRIPTION}
Citeste CLAUDE.md si docs/MODULE-MAP.md pentru context.
Pasi:
1. Citeste fisierele relevante ale modulului
2. Identifica cauza root — nu ghici, citeste codul
3. Propune fix-ul INAINTE sa-l aplici
4. Aplica fix-ul minimal (nu refactoriza alte lucruri)
5. Verifica ca npx next build trece
6. Explica ce s-a schimbat si de ce
Daca bug-ul e in interactiunea cu alt modul sau API, citeste si acel cod.
Nu adauga features sau "imbunatatiri" — doar fix bug-ul raportat.`,
},
{
id: "feature-existing",
title: "Feature nou in modul existent",
description: "Adauga o functionalitate noua intr-un modul care exista deja",
tags: ["regular", "feature"],
variables: ["MODULE_NAME", "FEATURE_DESCRIPTION"],
prompt: `Vreau sa adaug un feature in modulul {MODULE_NAME}: {FEATURE_DESCRIPTION}
Citeste CLAUDE.md si docs/MODULE-MAP.md pentru context.
Pasi obligatorii:
1. Citeste types.ts, config.ts si componentele existente ale modulului
2. Verifica daca feature-ul necesita schimbari de tip (types.ts)
3. Verifica daca necesita API route nou sau modificare la cel existent
4. Propune planul de implementare INAINTE de a scrie cod
5. Implementeaza pas cu pas, verificand build dupa fiecare fisier major
6. Pastreaza conventiile existente (English code, Romanian UI)
7. npx next build TREBUIE sa treaca
Reguli:
- Nu schimba structura modulului fara motiv
- Nu adauga dependinte noi daca nu e necesar
- Pastreaza compatibilitatea cu datele existente in storage/DB
- Daca trebuie migrare de date, propune-o separat`,
},
{
id: "feature-new-module",
title: "Modul complet nou",
description: "Creeaza un modul nou de la zero urmand pattern-ul standard",
tags: ["one-time", "feature", "architecture"],
oneTime: true,
variables: ["MODULE_NAME", "MODULE_DESCRIPTION"],
prompt: `Vreau sa creez un modul nou: {MODULE_NAME} — {MODULE_DESCRIPTION}
Citeste CLAUDE.md, docs/MODULE-MAP.md si docs/guides/MODULE-DEVELOPMENT.md.
Studiaza un modul existent similar ca referinta (ex: it-inventory sau hot-desk pentru module simple, registratura pentru module complexe).
Creeaza structura standard:
src/modules/{MODULE_NAME}/
components/{MODULE_NAME}-module.tsx
hooks/use-{MODULE_NAME}.ts (daca e nevoie)
services/ (daca e nevoie)
types.ts
config.ts
index.ts
Plus:
- src/app/(modules)/{MODULE_NAME}/page.tsx (route page)
- Adauga config in src/config/modules.ts
- Adauga flag in src/config/flags.ts
- Adauga navigare in src/config/navigation.ts
Reguli:
- Urmeaza EXACT pattern-ul celorlalte module
- English code, Romanian UI text
- Feature flag enabled by default
- Storage via useStorage('{MODULE_NAME}') hook
- npx next build TREBUIE sa treaca
- Nu implementa mai mult decat MVP-ul — pot adauga dupa`,
},
],
},
{
id: "api",
label: "API & Backend",
icon: <Plug className="size-4" />,
description: "Creare si modificare API routes, integrari externe, Prisma schema",
prompts: [
{
id: "api-new-route",
title: "API route nou",
description: "Creeaza un endpoint API nou cu auth, validare, error handling",
tags: ["regular", "api"],
variables: ["ROUTE_PATH", "ROUTE_DESCRIPTION"],
prompt: `Creeaza un API route nou: /api/{ROUTE_PATH} — {ROUTE_DESCRIPTION}
Citeste CLAUDE.md (sectiunea Middleware & Large Uploads) si docs/ARCHITECTURE-QUICK.md.
Cerinte obligatorii:
1. Auth: middleware coverage SAU requireAuth() pentru rute excluse
2. Input validation pe toate parametrii
3. Error handling: try/catch cu mesaje utile (nu stack traces)
4. Prisma queries: parametrizate ($queryRaw cu template literals, NU string concat)
5. TypeScript strict: toate return types explicit
Pattern de referinta — citeste un API route existent similar:
- CRUD simplu: src/app/api/storage/route.ts
- Cu Prisma raw: src/app/api/registratura/route.ts
- Cu external API: src/app/api/eterra/search/route.ts
Daca ruta accepta uploads mari:
- Exclude din middleware matcher (src/middleware.ts)
- Adauga requireAuth() manual
- Documenteaza in CLAUDE.md sectiunea Middleware
npx next build TREBUIE sa treaca.`,
},
{
id: "prisma-schema",
title: "Modificare Prisma schema",
description: "Adauga model nou sau modifica schema existenta",
tags: ["regular", "database"],
variables: ["CHANGE_DESCRIPTION"],
prompt: `Vreau sa modific Prisma schema: {CHANGE_DESCRIPTION}
Citeste prisma/schema.prisma complet inainte.
Pasi:
1. Propune schimbarea de schema INAINTE de a o aplica
2. Verifica impactul asupra codului existent (grep pentru modelul afectat)
3. Aplica in schema.prisma
4. Ruleaza: npx prisma generate
5. Actualizeaza codul care foloseste modelul
6. npx next build TREBUIE sa treaca
Reguli:
- Adauga @@index pe coloane folosite in WHERE/ORDER BY
- Adauga @@unique pe combinatii care trebuie sa fie unice
- onDelete: SetNull sau Cascade — niciodata default (restrict)
- Foloseste Json? pentru campuri flexibile (enrichment pattern)
- DateTime cu @default(now()) pe createdAt, @updatedAt pe updatedAt
IMPORTANT: Aceasta schimbare necesita si migrare pe server (prisma migrate).
Nu face breaking changes fara plan de migrare.`,
},
{
id: "external-integration",
title: "Integrare API extern",
description: "Conectare la un serviciu extern (pattern eTerra/ePay)",
tags: ["one-time", "api", "architecture"],
oneTime: true,
variables: ["SERVICE_NAME", "SERVICE_DESCRIPTION"],
prompt: `Vreau sa integrez un serviciu extern: {SERVICE_NAME} — {SERVICE_DESCRIPTION}
Citeste CLAUDE.md sectiunile eTerra/ANCPI Rules si Middleware.
Studiaza pattern-ul din src/modules/parcel-sync/services/eterra-client.ts ca referinta.
Pattern obligatoriu pentru integrari externe:
1. Client class separat in services/ (nu inline in route)
2. Session/token caching cu TTL (global singleton pattern)
3. Periodic cleanup pe cache (setInterval)
4. Health check daca serviciul e instabil
5. Retry logic pentru erori tranziente (ECONNRESET, 500)
6. Timeout explicit pe toate request-urile
7. Error handling granular (nu catch-all generic)
8. Logging cu prefix: console.log("[{SERVICE_NAME}] ...")
Env vars:
- Adauga in docker-compose.yml
- NU hardcoda credentials in cod
- Documenteaza in docs/ARCHITECTURE-QUICK.md
npx next build TREBUIE sa treaca.`,
},
],
},
{
id: "quality",
label: "Calitate & Securitate",
icon: <Shield className="size-4" />,
description: "Audituri de securitate, testing, performance, code review",
prompts: [
{
id: "security-audit",
title: "Audit securitate complet",
description: "Scanare completa de securitate pe tot codebase-ul",
tags: ["periodic", "security"],
prompt: `Scopul acestei sesiuni este un audit complet de securitate al ArchiTools.
Aplicatia este IN PRODUCTIE la https://tools.beletage.ro.
Citeste CLAUDE.md si docs/ARCHITECTURE-QUICK.md inainte de orice.
Scaneaza cu agenti in paralel:
1. API AUTH: Verifica ca TOATE rutele din src/app/api/ au auth check
(middleware matcher + requireAuth fallback)
2. SQL INJECTION: Cauta $queryRaw/$executeRaw cu string concatenation
3. INPUT VALIDATION: Verifica sanitizarea pe toate endpoint-urile
4. SECRETS: Cauta credentials hardcoded, env vars expuse in client
5. ERROR HANDLING: Catch goale, stack traces in responses
6. RACE CONDITIONS: Write operations concurente fara locks
7. DATA INTEGRITY: Upsert-uri care pot suprascrie date
Grupeaza in: CRITICAL / IMPORTANT / NICE-TO-HAVE
Pentru fiecare: fisier, linia, problema, solutia propusa.
NU aplica fix-uri fara sa le listezi mai intai.
npx next build TREBUIE sa treaca dupa fiecare fix.`,
},
{
id: "security-module",
title: "Audit securitate pe modul",
description: "Review de securitate focusat pe un singur modul",
tags: ["regular", "security"],
variables: ["MODULE_NAME"],
prompt: `Fa un audit de securitate pe modulul {MODULE_NAME}.
Citeste CLAUDE.md si docs/MODULE-MAP.md.
Verifica:
1. API routes folosite de modul — au auth? Input validation?
2. Prisma queries — SQL injection posibil?
3. User input — sanitizat inainte de stocare/afisare?
4. File uploads (daca exista) — validare tip/dimensiune?
5. Storage operations — race conditions la concurrent access?
6. Error handling — erori silentioase? Stack traces expuse?
7. Cross-module deps — sunt corecte si necesare?
Raporteaza gasirile cu: fisier, linia, severitate, fix propus.`,
},
{
id: "testing-hardcore",
title: "Testing hardcore",
description: "Edge cases, stress testing, error scenarios pentru un modul",
tags: ["periodic", "testing"],
variables: ["MODULE_NAME"],
prompt: `Vreau sa testez hardcore modulul {MODULE_NAME}.
Citeste codul modulului complet, apoi gandeste:
1. EDGE CASES: Ce se intampla cu input gol? Cu caractere speciale (diacritice, emoji)? Cu valori extreme (numar foarte mare, string foarte lung)?
2. CONCURRENT ACCESS: Ce se intampla daca 2 useri fac aceeasi operatie simultan? Race conditions la write/update/delete?
3. ERROR PATHS: Ce se intampla daca DB-ul e down? Daca API-ul extern nu raspunde? Daca sesiunea expira mid-operation?
4. DATA INTEGRITY: Pot pierde date? Pot crea duplicate? Pot suprascrie datele altcuiva?
5. UI STATE: Ce se intampla daca user-ul da click dublu pe buton? Daca navigheaza away in timpul unui save? Daca face refresh?
6. STORAGE: Ce se intampla cu date legacy (format vechi)? Cu valori null/undefined in JSON?
Pentru fiecare problema gasita: descrie scenariul, impactul, si propune fix.
Aplica doar ce e CRITICAL dupa aprobare.`,
},
{
id: "performance-audit",
title: "Audit performanta",
description: "Identificare bottleneck-uri, optimizare queries, bundle size",
tags: ["periodic", "performance"],
variables: ["MODULE_NAME"],
prompt: `Analizeaza performanta modulului {MODULE_NAME}.
Citeste CLAUDE.md (Storage Performance Rules) si codul modulului.
Verifica:
1. N+1 QUERIES: storage.list() + get() in loop? Ar trebui exportAll()
2. LARGE PAYLOADS: Se incarca date inutile? lightweight: true folosit?
3. RE-RENDERS: useEffect-uri care trigger-uiesc re-render excesiv?
4. BUNDLE SIZE: Import-uri heavy (librarii intregi vs tree-shaking)?
5. API CALLS: Request-uri redundante? Lipseste caching?
6. DB QUERIES: Lipsesc indexuri? SELECT * in loc de select specific?
7. MEMORY: Global singletons care cresc nelimitat? Cache fara TTL?
Propune optimizarile ordonate dupa impact.
Aplica doar dupa aprobare.`,
},
],
},
{
id: "session",
label: "Sesiune & Continuare",
icon: <RefreshCw className="size-4" />,
description: "Prompturi pentru inceperea sau continuarea sesiunilor de lucru",
prompts: [
{
id: "continue-tasklist",
title: "Continuare din sesiunea anterioara",
description: "Reia lucrul de unde am ramas, cu verificare task list",
tags: ["regular", "session"],
prompt: `Continuam din sesiunea anterioara.
Citeste CLAUDE.md, MEMORY.md si docs/MODULE-MAP.md.
Verifica memory/ pentru context despre ce s-a lucrat recent.
Apoi:
1. Citeste ROADMAP.md (daca exista) pentru task list-ul curent
2. Verifica git log --oneline -20 sa vezi ce s-a comis recent
3. Verifica git status sa vezi daca sunt schimbari uncommited
4. Rezuma ce s-a facut si ce a ramas
5. Intreaba-ma cum vreau sa continuam
Nu incepe sa lucrezi fara confirmare.
npx next build TREBUIE sa treaca inainte de orice schimbare.`,
},
{
id: "fresh-session",
title: "Sesiune noua — orientare",
description: "Prima sesiune sau sesiune dupa pauza lunga — ia-ti bearings",
tags: ["regular", "session"],
prompt: `Sesiune noua pe ArchiTools.
Citeste in ordine:
1. CLAUDE.md (context proiect + reguli)
2. memory/MEMORY.md (index memorii)
3. Fiecare fisier din memory/ (context sesiuni anterioare)
4. git log --oneline -20 (activitate recenta)
5. git status (stare curenta)
6. docs/MODULE-MAP.md (harta module)
Dupa ce ai citit tot, da-mi un rezumat de 5-10 randuri:
- Ce s-a facut recent
- Ce e in progress / neterminat
- Ce probleme sunt cunoscute
- Recomandarea ta pentru ce sa facem azi
Asteapta confirmarea mea inainte de a incepe.`,
},
{
id: "review-refactor",
title: "Code review & refactoring",
description: "Review si curatare cod pe o zona specifica",
tags: ["periodic", "review"],
variables: ["TARGET"],
prompt: `Fa code review pe: {TARGET}
Citeste CLAUDE.md si codul tinta complet.
Verifica:
1. PATTERN COMPLIANCE: Urmeaza conventiile din CLAUDE.md?
2. TYPE SAFETY: TypeScript strict — sunt tipuri corecte? Null checks?
3. ERROR HANDLING: Catch blocks complete? Promise-uri handled?
4. NAMING: English code, Romanian UI? Consistent cu restul?
5. COMPLEXITY: Functii prea lungi? Logica duplicata?
6. SECURITY: Input validation? Auth checks?
7. PERFORMANCE: N+1 queries? Re-renders inutile?
Raporteaza gasirile ordonate dupa severitate.
NU aplica refactoring fara listarea schimbarilor propuse si aprobare.
Refactoring-ul trebuie sa fie minimal — nu rescrie ce functioneaza.`,
},
{
id: "full-audit",
title: "Audit complet codebase",
description: "Scanare completa: cod mort, consistenta, securitate, documentatie",
tags: ["periodic", "audit"],
oneTime: true,
prompt: `Scopul acestei sesiuni este un audit complet al codebase-ului ArchiTools cu 3 obiective:
1. REVIEW & CLEANUP: Cod mort, dependinte neutilizate, TODO/FIXME, consistenta module
2. SIGURANTA IN PRODUCTIE: SQL injection, auth gaps, race conditions, data integrity
3. DOCUMENTATIE: CLAUDE.md actualizat, docs/ la zi, memory/ updatat
Citeste CLAUDE.md si MEMORY.md inainte de orice.
Foloseste agenti Explore in paralel (minim 5 simultan) pentru scanare.
Grupeaza gasirile in: CRITICAL / IMPORTANT / NICE-TO-HAVE
NU modifica cod fara sa listezi mai intai toate schimbarile propuse.
npx next build TREBUIE sa treaca dupa fiecare schimbare.
Commit frecvent cu mesaje descriptive.`,
},
],
},
{
id: "docs",
label: "Documentatie & Meta",
icon: <FileText className="size-4" />,
description: "Update documentatie, CLAUDE.md, memory, si meta-prompting",
prompts: [
{
id: "update-claudemd",
title: "Actualizeaza CLAUDE.md",
description: "Sincronizeaza CLAUDE.md cu starea actuala a codului",
tags: ["periodic", "docs"],
prompt: `CLAUDE.md trebuie actualizat sa reflecte starea curenta a proiectului.
Pasi:
1. Citeste CLAUDE.md curent
2. Verifica fiecare sectiune contra codului real:
- Module table: sunt toate modulele? Versiuni corecte?
- Stack: versiuni la zi?
- Conventions: se respecta?
- Common Pitfalls: mai sunt relevante? Lipsesc altele noi?
- Infrastructure: porturi/servicii corecte?
3. Citeste docs/MODULE-MAP.md — e la zi?
4. Citeste docs/ARCHITECTURE-QUICK.md — e la zi?
Propune schimbarile necesare inainte de a le aplica.
Target: CLAUDE.md sub 200 linii, informatii derivabile din cod mutate in docs/.`,
},
{
id: "update-memory",
title: "Actualizeaza memory/",
description: "Curata memorii vechi si adauga context nou",
tags: ["periodic", "meta"],
prompt: `Verifica si actualizeaza memory/ files.
Citeste memory/MEMORY.md si fiecare fisier indexat.
Pentru fiecare memorie:
1. E inca relevanta? Daca nu, sterge-o.
2. Informatia e la zi? Daca nu, actualizeaz-o.
3. Informatia e derivabila din cod? Daca da, sterge-o (redundanta).
Adauga memorii NOI pentru:
- Decizii arhitecturale recente care nu sunt in CLAUDE.md
- Feedback-ul meu din aceasta sesiune (daca am corectat ceva)
- Starea task-urilor in progress
NU salva in memory: cod, structura fisierelor, git history — astea se pot citi direct.`,
},
{
id: "improve-prompts",
title: "Imbunatateste prompturile",
description: "Meta-prompt: analizeaza si rafineaza prompturile din aceasta pagina",
tags: ["periodic", "meta"],
prompt: `Citeste codul paginii /prompts (src/app/(modules)/prompts/page.tsx).
Analizeaza fiecare prompt din CATEGORIES:
1. E clar si specific? Lipseste context?
2. Pasii sunt in ordine logica?
3. Include safety nets (build check, aprobare)?
4. E prea lung/scurt?
5. Variabilele sunt utile?
Apoi gandeste: ce prompturi noi ar fi utile bazat pe:
- Tipurile de task-uri care apar frecvent in git log
- Module care sunt modificate des
- Greseli care se repeta (din memory/ feedback)
Propune imbunatatiri si prompturi noi. Aplica dupa aprobare.`,
},
],
},
{
id: "quick",
label: "Quick Actions",
icon: <Zap className="size-4" />,
description: "Prompturi scurte pentru actiuni rapide si frecvente",
prompts: [
{
id: "quick-build",
title: "Verifica build",
description: "Build check rapid",
tags: ["quick"],
prompt: `Ruleaza npx next build si raporteaza rezultatul. Daca sunt erori, propune fix-uri.`,
},
{
id: "deploy-prep",
title: "Pregatire deploy",
description: "Checklist complet inainte de push la productie",
tags: ["regular", "deploy"],
prompt: `Pregateste deploy-ul pe productie (tools.beletage.ro).
Checklist:
1. git status — totul comis? Fisiere untracked suspecte?
2. npx next build — zero erori?
3. docker-compose.yml — env vars noi necesare?
4. prisma/schema.prisma — s-a schimbat? Necesita migrate?
5. middleware.ts — rute noi excluse daca e cazul?
6. Verifica ca nu sunt credentials hardcoded in cod
7. git log --oneline -5 — commit messages descriptive?
Daca totul e ok, confirma "Ready to push".
Daca sunt probleme, listeaza-le cu fix propus.
IMPORTANT: Dupa push, deploy-ul e MANUAL in Portainer.
Daca schema Prisma s-a schimbat, trebuie migrate pe server.`,
},
{
id: "debug-unknown",
title: "Debug eroare necunoscuta",
description: "Investigheaza o eroare fara cauza evidenta",
tags: ["regular", "debug"],
variables: ["ERROR_DESCRIPTION"],
prompt: `Am o eroare: {ERROR_DESCRIPTION}
Investigheaza:
1. Citeste stack trace-ul (daca exista) — gaseste fisierul root cause
2. Citeste codul relevant — nu ghici, verifica
3. Cauta pattern-uri similare in codebase (grep)
4. Verifica git log recent — s-a schimbat ceva care ar cauza asta?
5. Verifica env vars — lipseste ceva?
6. Verifica Prisma schema — model-ul e in sync?
Dupa investigatie:
- Explica cauza root (nu simptomul)
- Propune fix minim
- Aplica fix dupa aprobare
- npx next build TREBUIE sa treaca`,
},
{
id: "quick-deps",
title: "Update dependinte",
description: "Verifica si actualizeaza package.json",
tags: ["quick"],
prompt: `Verifica daca sunt update-uri disponibile pentru dependintele din package.json.
Ruleaza: npm outdated
Listeaza ce se poate actualiza safe (minor/patch).
NU actualiza major versions fara discutie.
Dupa update: npx next build TREBUIE sa treaca.`,
},
{
id: "quick-git-cleanup",
title: "Git cleanup",
description: "Verifica starea repo-ului si curata",
tags: ["quick"],
prompt: `Verifica starea repo-ului:
1. git status — fisiere uncommited?
2. git log --oneline -10 — commit-uri recente ok?
3. Fisiere untracked suspecte? (.env, tmp files, build artifacts)
4. .gitignore — lipseste ceva?
Propune cleanup daca e nevoie.`,
},
{
id: "quick-type-check",
title: "Verificare tipuri modul",
description: "Verifica typesafety pe un modul specific",
tags: ["quick"],
variables: ["MODULE_NAME"],
prompt: `Citeste src/modules/{MODULE_NAME}/types.ts si verifica:
1. Toate interfetele sunt folosite? (grep imports)
2. Sunt tipuri any sau unknown neutipizate?
3. Optional fields corect marcate cu ?
4. Consistenta cu Prisma schema (daca modulul foloseste DB direct)
Raporteaza rapid ce nu e in regula.`,
},
],
},
];
// ─── Copy button component ─────────────────────────────────────────
function CopyButton({ text, className }: { text: string; className?: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [text]);
return (
<Button
variant="ghost"
size="icon-xs"
onClick={handleCopy}
className={className}
title="Copiaza"
>
{copied ? <Check className="size-3 text-green-500" /> : <Copy className="size-3" />}
</Button>
);
}
// ─── Prompt Card ───────────────────────────────────────────────────
function PromptCard({ prompt }: { prompt: PromptTemplate }) {
const [expanded, setExpanded] = useState(false);
const [done, setDone] = useState(false);
// Persist one-time completion in localStorage
useEffect(() => {
if (prompt.oneTime) {
const stored = localStorage.getItem(`prompt-done-${prompt.id}`);
if (stored === "true") setDone(true);
}
}, [prompt.id, prompt.oneTime]);
const toggleDone = useCallback(() => {
const next = !done;
setDone(next);
localStorage.setItem(`prompt-done-${prompt.id}`, String(next));
}, [done, prompt.id]);
return (
<Card className={`transition-all ${done ? "opacity-50" : ""}`}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{prompt.oneTime && (
<input
type="checkbox"
checked={done}
onChange={toggleDone}
className="size-4 rounded accent-primary cursor-pointer"
title={done ? "Marcheaza ca nefacut" : "Marcheaza ca facut"}
/>
)}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 text-sm font-semibold hover:text-primary transition-colors text-left"
>
{expanded ? <ChevronDown className="size-3.5 shrink-0" /> : <ChevronRight className="size-3.5 shrink-0" />}
{prompt.title}
</button>
</div>
<p className="text-xs text-muted-foreground ml-5">{prompt.description}</p>
<div className="flex flex-wrap gap-1 mt-2 ml-5">
{prompt.tags.map((tag) => (
<Badge key={tag} variant="secondary" className="text-[10px] px-1.5 py-0">
{tag}
</Badge>
))}
{prompt.variables?.map((v) => (
<Badge key={v} variant="outline" className="text-[10px] px-1.5 py-0 border-amber-500/50 text-amber-600 dark:text-amber-400">
{`{${v}}`}
</Badge>
))}
</div>
</div>
<CopyButton text={prompt.prompt} className="shrink-0 mt-0.5" />
</div>
{expanded && (
<div className="mt-3 ml-5">
<div className="relative group">
<pre className="text-xs bg-muted/50 border rounded-md p-3 whitespace-pre-wrap font-mono leading-relaxed overflow-x-auto max-h-[500px] overflow-y-auto">
{prompt.prompt}
</pre>
<CopyButton
text={prompt.prompt}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-background/80"
/>
</div>
</div>
)}
</CardContent>
</Card>
);
}
// ─── Stats bar ─────────────────────────────────────────────────────
function StatsBar() {
const totalPrompts = CATEGORIES.reduce((s, c) => s + c.prompts.length, 0);
const oneTimePrompts = CATEGORIES.reduce(
(s, c) => s + c.prompts.filter((p) => p.oneTime).length,
0,
);
return (
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><Brain className="size-3" /> {totalPrompts} prompturi</span>
<span className="flex items-center gap-1"><ListChecks className="size-3" /> {oneTimePrompts} one-time</span>
<span className="flex items-center gap-1"><Wrench className="size-3" /> {CATEGORIES.length} categorii</span>
</div>
);
}
// ─── Best practices sidebar ────────────────────────────────────────
const BEST_PRACTICES = [
{ icon: <Rocket className="size-3" />, text: "Incepe cu CLAUDE.md — da context inainte de task" },
{ icon: <Brain className="size-3" />, text: "Cere plan inainte de implementare" },
{ icon: <Shield className="size-3" />, text: "npx next build dupa fiecare schimbare" },
{ icon: <Bug className="size-3" />, text: "Nu refactoriza cand faci bugfix" },
{ icon: <Sparkles className="size-3" />, text: "O sesiune = un obiectiv clar" },
{ icon: <TestTube className="size-3" />, text: "Verifica-ti munca: teste, build, manual" },
{ icon: <ListChecks className="size-3" />, text: "Listeaza schimbarile inainte de a le aplica" },
{ icon: <RefreshCw className="size-3" />, text: "Actualizeaza memory/ la sfarsit de sesiune" },
{ icon: <Zap className="size-3" />, text: "/clear intre task-uri diferite" },
{ icon: <Terminal className="size-3" />, text: "Dupa 2 corectii → /clear + prompt mai bun" },
];
// ─── Main Page ─────────────────────────────────────────────────────
export default function PromptsPage() {
const [search, setSearch] = useState("");
const filtered = CATEGORIES.map((cat) => ({
...cat,
prompts: cat.prompts.filter(
(p) =>
!search ||
p.title.toLowerCase().includes(search.toLowerCase()) ||
p.description.toLowerCase().includes(search.toLowerCase()) ||
p.tags.some((t) => t.toLowerCase().includes(search.toLowerCase())),
),
})).filter((cat) => cat.prompts.length > 0);
return (
<div className="max-w-5xl mx-auto p-6 space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Terminal className="size-6" />
Claude Code Prompts
</h1>
<p className="text-sm text-muted-foreground mt-1">
Biblioteca de prompturi optimizate pentru ArchiTools. Click pe titlu pentru a vedea, buton pentru a copia.
</p>
<StatsBar />
</div>
{/* Search */}
<div className="flex gap-3">
<input
type="text"
placeholder="Cauta prompt... (ex: bugfix, security, parcel-sync)"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 h-9 rounded-md border bg-background px-3 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
{search && (
<Button variant="ghost" size="sm" onClick={() => setSearch("")}>
Sterge
</Button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-[1fr_240px] gap-6">
{/* Main content */}
<Tabs defaultValue={CATEGORIES[0]?.id} className="w-full">
<TabsList className="w-full flex flex-wrap h-auto gap-1 bg-transparent p-0 mb-4">
{filtered.map((cat) => (
<TabsTrigger
key={cat.id}
value={cat.id}
className="flex items-center gap-1.5 text-xs data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-md px-3 py-1.5 border"
>
{cat.icon}
{cat.label}
<Badge variant="secondary" className="text-[10px] px-1 py-0 ml-1">
{cat.prompts.length}
</Badge>
</TabsTrigger>
))}
</TabsList>
{filtered.map((cat) => (
<TabsContent key={cat.id} value={cat.id} className="space-y-3 mt-0">
<p className="text-xs text-muted-foreground mb-3">{cat.description}</p>
{cat.prompts.map((prompt) => (
<PromptCard key={prompt.id} prompt={prompt} />
))}
</TabsContent>
))}
</Tabs>
{/* Sidebar */}
<div className="space-y-4">
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
<Sparkles className="size-3.5" />
Best Practices
</h3>
<div className="space-y-2.5">
{BEST_PRACTICES.map((bp, i) => (
<div key={i} className="flex items-start gap-2 text-xs text-muted-foreground">
<span className="mt-0.5 shrink-0">{bp.icon}</span>
<span>{bp.text}</span>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
<Terminal className="size-3.5" />
Module disponibile
</h3>
<div className="flex flex-wrap gap-1">
{MODULES.map((m) => (
<Badge key={m} variant="outline" className="text-[10px] px-1.5 py-0 cursor-pointer hover:bg-accent" onClick={() => {
navigator.clipboard.writeText(m);
}}>
{m}
</Badge>
))}
</div>
<p className="text-[10px] text-muted-foreground mt-2">Click pe modul = copiaza numele</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+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>
);
}
+13 -4
View File
@@ -1,11 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "./auth-check";
const STIRLING_PDF_URL =
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
const STIRLING_PDF_API_KEY =
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
export async function POST(req: NextRequest) {
const authErr = await requireAuth(req);
if (authErr) return authErr;
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
return NextResponse.json(
{ error: "Stirling PDF nu este configurat" },
{ status: 503 },
);
}
try {
// Buffer the full body then forward to Stirling — streaming passthrough
// (req.body + duplex:half) is unreliable for large files in Next.js.
+13 -4
View File
@@ -1,11 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAuth } from "../auth-check";
const STIRLING_PDF_URL =
process.env.STIRLING_PDF_URL ?? "http://10.10.10.166:8087";
const STIRLING_PDF_API_KEY =
process.env.STIRLING_PDF_API_KEY ?? "cd829f62-6eef-43eb-a64d-c91af727b53a";
const STIRLING_PDF_URL = process.env.STIRLING_PDF_URL;
const STIRLING_PDF_API_KEY = process.env.STIRLING_PDF_API_KEY;
export async function POST(req: NextRequest) {
const authErr = await requireAuth(req);
if (authErr) return authErr;
if (!STIRLING_PDF_URL || !STIRLING_PDF_API_KEY) {
return NextResponse.json(
{ error: "Stirling PDF nu este configurat" },
{ status: 503 },
);
}
try {
// Stream body directly to Stirling — avoids FormData re-serialization
// failure on large files ("Failed to parse body as FormData")
+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`;
}
+55 -29
View File
@@ -187,63 +187,88 @@ 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) {
// 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 r = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
forceFullSync: forceSync,
const terenuriResult = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
forceFullSync: terenuriNeedsFullSync,
jobId,
isSubStep: true,
});
if (r.status === "error")
throw new Error(r.error ?? "Sync terenuri failed");
}
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,
const cladiriResult = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
forceFullSync: cladiriNeedsFullSync,
jobId,
isSubStep: true,
});
if (r.status === "error")
throw new Error(r.error ?? "Sync clădiri failed");
}
if (cladiriResult.status === "error")
throw new Error(cladiriResult.error ?? "Sync clădiri failed");
// Sync intravilan limits (always, lightweight layer)
phase = "Sincronizare limite intravilan";
// 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, "LIMITE_INTRAV_DYNAMIC", {
await syncLayer(username, password, siruta, adminLayer, {
forceFullSync: forceSync,
jobId,
isSubStep: true,
});
} catch {
// Non-critical — don't fail the whole job
note = "Avertisment: limite intravilan nu s-au sincronizat";
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);
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 {
phase = "Import parcele fără geometrie";
push({});
const noGeomClient = await EterraClient.create(username, password, {
timeoutMs: 120_000,
});
@@ -262,6 +287,7 @@ async function runBackground(params: {
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 });
}
}
@@ -11,11 +11,21 @@ import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getAuthSession } from "@/core/auth";
export async function GET() {
async function requireAdmin(): Promise<NextResponse | null> {
const session = await getAuthSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const u = session.user as { role?: string } | undefined;
if (u?.role !== "admin") {
return NextResponse.json({ error: "Admin access required" }, { status: 403 });
}
return null;
}
export async function GET() {
const denied = await requireAdmin();
if (denied) return denied;
// Get all sequence counters
const counters = await prisma.$queryRaw<
@@ -79,10 +89,8 @@ export async function GET() {
}
export async function POST() {
const session = await getAuthSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const denied = await requireAdmin();
if (denied) return denied;
// Delete ALL old counters
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
@@ -146,10 +154,8 @@ export async function POST() {
* Rewrites the "number" field inside the JSONB value for matching entries.
*/
export async function PATCH() {
const session = await getAuthSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const denied = await requireAdmin();
if (denied) return denied;
// Map old 3-letter prefixes to new single-letter
const migrations: Array<{ old: string; new: string }> = [
+16 -10
View File
@@ -213,7 +213,10 @@ export async function POST(req: NextRequest) {
let claimedSlotId: string | undefined;
if (isPastMonth && direction === "intrat") {
// Try to claim a reserved slot
// Try to claim a reserved slot — use advisory lock to prevent concurrent claims
const lockKey = `reserved:${company}-${docDate.getFullYear()}-${docDate.getMonth()}`;
const claimed = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${lockKey}))`;
const allEntries = await loadAllEntries(true);
const slot = findAvailableReservedSlot(
allEntries,
@@ -221,19 +224,22 @@ export async function POST(req: NextRequest) {
docDate.getFullYear(),
docDate.getMonth(),
);
if (!slot) return null;
// Delete the placeholder slot within the lock
await tx.keyValueStore.delete({
where: { namespace_key: { namespace: "registratura", key: slot.id } },
});
return slot;
});
if (slot) {
// Claim the reserved slot — reuse its number
registryNumber = slot.number;
if (claimed) {
registryNumber = claimed.number;
registrationType = "reserved-claimed";
claimedSlotId = slot.id;
// Delete the placeholder slot
await deleteEntryFromDB(slot.id);
claimedSlotId = claimed.id;
await logAuditEvent({
entryId: slot.id,
entryNumber: slot.number,
entryId: claimed.id,
entryNumber: claimed.number,
action: "reserved_claimed",
actor: actor.id,
actorName: actor.name,
+2 -2
View File
@@ -144,8 +144,8 @@ export async function DELETE(request: NextRequest) {
},
},
})
.catch(() => {
// Ignore error if item doesn't exist
.catch((err: { code?: string }) => {
if (err.code !== "P2025") throw err;
});
} else {
// Clear namespace
+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";
-13
View File
@@ -18,16 +18,3 @@ if (process.env.NODE_ENV !== "production")
globalForMinio.minioClient = minioClient;
export const MINIO_BUCKET_NAME = process.env.MINIO_BUCKET_NAME || "tools";
// Helper to ensure bucket exists
export async function ensureBucketExists() {
try {
const exists = await minioClient.bucketExists(MINIO_BUCKET_NAME);
if (!exists) {
await minioClient.makeBucket(MINIO_BUCKET_NAME, "eu-west-1");
console.log(`Bucket '${MINIO_BUCKET_NAME}' created successfully.`);
}
} catch (error) {
console.error("Error checking/creating MinIO bucket:", error);
}
}
+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");
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ export async function middleware(request: NextRequest) {
if (token) {
const { pathname } = request.nextUrl;
// Portal-only users: redirect to /portal when accessing main app
const portalUsers = ["dtiurbe", "d.tiurbe"];
const portalUsers = (process.env.PORTAL_ONLY_USERS ?? "dtiurbe,d.tiurbe").split(",").map(s => s.trim().toLowerCase());
const tokenEmail = String(token.email ?? "").toLowerCase();
const tokenName = String(token.name ?? "").toLowerCase();
const isPortalUser = portalUsers.some(
@@ -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) */}
+116 -18
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,17 +392,56 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
}
}
// === UAT z0-5: very coarse — lines only ===
// === UAT sources: PMTiles (if configured) or Martin fallback ===
const pmtilesUrl = DEFAULT_PMTILES_URL;
const usePmtiles = !!pmtilesUrl;
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}` });
// 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 } });
// 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 } });
// 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 } });
// 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 } });
// === UAT z5-8: coarse ===
// 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 } });
// === UAT z8-12: moderate ===
// 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 } });
@@ -401,7 +449,7 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
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 } });
// === UAT z12+: full detail (no simplification) ===
// 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 } });
@@ -409,20 +457,38 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
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+ ===
// 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) — no simplification ===
// === 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 } });
// Parcel cadastral number label
map.addLayer({ id: LAYER_IDS.terenuriLabel, type: "symbol", source: SOURCES.terenuri, "source-layer": SOURCES.terenuri, minzoom: 16,
layout: {
"text-field": ["coalesce", ["get", "cadastral_ref"], ""],
@@ -431,19 +497,55 @@ export const MapViewer = forwardRef<MapViewerHandle, MapViewerProps>(
"text-max-width": 8,
},
paint: { "text-color": "#166534", "text-halo-color": "#fff", "text-halo-width": 1 } });
}
// === Cladiri (buildings) — no simplification ===
// === 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": [
"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": 9, "text-anchor": "center", "text-allow-overlap": false,
"text-max-width": 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,16 +677,52 @@ export function ExportTab({
)}
{/* Hero buttons */}
{sirutaValid && session.connected ? (
{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";
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}
onClick={() => void handleExportBundle("base")}
disabled={exporting || downloadingFromDb}
title={baseTooltip}
onClick={() =>
canExportLocal
? void handleDownloadFromDb("base")
: void handleExportBundle("base")
}
>
{exporting && exportProgress?.phase !== "Detalii parcele" ? (
{(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" />
)}
@@ -667,7 +731,11 @@ export function ExportTab({
Descarcă Terenuri și Clădiri
</div>
<div className="text-xs opacity-70 font-normal">
Sync + GPKG (din cache dacă e proaspăt)
{canExportLocal
? `Din DB (sync ${oldestSyncDate ? relativeTime(oldestSyncDate) : "recent"})`
: hasData
? "Sync incremental + GPKG + DXF"
: "Sync complet + GPKG + DXF"}
</div>
</div>
</Button>
@@ -675,10 +743,16 @@ export function ExportTab({
<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")}
disabled={exporting || downloadingFromDb}
title={magicTooltip}
onClick={() =>
canExportLocal
? void handleDownloadFromDb("magic")
: void handleExportBundle("magic")
}
>
{exporting && exportProgress?.phase === "Detalii parcele" ? (
{(exporting || downloadingFromDb) &&
exportProgress?.phase === "Detalii parcele" ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Sparkles className="mr-2 h-5 w-5" />
@@ -686,15 +760,34 @@ export function ExportTab({
<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
{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>
)}
</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;
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,13 +724,11 @@ 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 =
enrichJson != null &&
[
// Structural check: all 7 core fields must exist
const coreFields = [
"NR_CAD",
"NR_CF",
"PROPRIETARI",
@@ -406,8 +736,36 @@ export async function enrichFeatures(
"ADRESA",
"CATEGORIE_FOLOSINTA",
"HAS_BUILDING",
].every((k) => k in enrichJson && enrichJson[k] !== undefined);
if (isComplete) {
];
const structurallyComplete =
enrichJson != null &&
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,6 +835,7 @@ export async function enrichFeatures(
const folKey = `${workspaceId}:${immovableId}:${appId}`;
let fol = folCache.get(folKey);
if (!fol) {
try {
fol = await throttled(() =>
client.fetchParcelFolosinte(
workspaceId as string | number,
@@ -481,6 +843,9 @@ export async function enrichFeatures(
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",
@@ -54,11 +54,24 @@ type SessionEntry = {
const globalStore = globalThis as {
__epaySessionCache?: Map<string, SessionEntry>;
__epayCleanupTimer?: ReturnType<typeof setInterval>;
};
const sessionCache =
globalStore.__epaySessionCache ?? new Map<string, SessionEntry>();
globalStore.__epaySessionCache = sessionCache;
// Periodic cleanup of expired sessions (every 5 minutes, 9-min TTL)
if (!globalStore.__epayCleanupTimer) {
globalStore.__epayCleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of sessionCache.entries()) {
if (now - entry.lastUsed > 9 * 60_000) {
sessionCache.delete(key);
}
}
}, 5 * 60_000);
}
const makeCacheKey = (u: string, p: string) =>
crypto.createHash("sha256").update(`epay:${u}:${p}`).digest("hex");
+15 -10
View File
@@ -117,8 +117,15 @@ export async function enqueueBatch(
const items: QueueItem[] = [];
for (const input of inputs) {
// Create DB record in "queued" status
const record = await prisma.cfExtract.create({
// Create DB record in "queued" status — use transaction + advisory lock
// to prevent duplicate version numbers from concurrent requests
const record = await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'cfextract:' + input.nrCadastral}))`;
const agg = await tx.cfExtract.aggregate({
where: { nrCadastral: input.nrCadastral },
_max: { version: true },
});
return tx.cfExtract.create({
data: {
nrCadastral: input.nrCadastral,
nrCF: input.nrCF ?? input.nrCadastral,
@@ -130,15 +137,10 @@ export async function enqueueBatch(
gisFeatureId: input.gisFeatureId,
prodId: input.prodId ?? 14200,
status: "queued",
version:
((
await prisma.cfExtract.aggregate({
where: { nrCadastral: input.nrCadastral },
_max: { version: true },
})
)._max.version ?? 0) + 1,
version: (agg._max.version ?? 0) + 1,
},
});
});
items.push({ extractId: record.id, input });
}
@@ -418,7 +420,10 @@ async function processBatch(
},
);
// Complete
// Complete — require document date from ANCPI for accurate expiry
if (!doc.dataDocument) {
console.warn(`[epay-queue] Missing dataDocument for extract ${item.extractId}, using download date`);
}
const documentDate = doc.dataDocument
? new Date(doc.dataDocument)
: new Date();
+105 -14
View File
@@ -78,11 +78,24 @@ type SessionEntry = {
};
const globalStore = globalThis as {
__eterraSessionStore?: Map<string, SessionEntry>;
__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.__eterraClientCleanupTimer) {
globalStore.__eterraClientCleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, entry] of sessionStore.entries()) {
if (now - entry.lastUsed > 9 * 60_000) {
sessionStore.delete(key);
}
}
}, 5 * 60_000);
}
const makeCacheKey = (u: string, p: string) =>
crypto.createHash("sha256").update(`${u}:${p}`).digest("hex");
@@ -117,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(
@@ -134,6 +147,7 @@ export class EterraClient {
this.username = username;
this.password = password;
this.maxRetries = maxRetries;
this.cacheKey = makeCacheKey(username, password);
}
/* ---- Factory --------------------------------------------------- */
@@ -284,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,
@@ -831,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 }),
@@ -896,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;
@@ -931,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) {
const BATCH = 30_000;
for (let i = 0; i < staleIds.length; i += BATCH) {
await prisma.gisFeature.deleteMany({
where: { id: { in: staleIds } },
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 };
}
}
@@ -16,10 +16,24 @@ export type SyncProgress = {
type ProgressStore = Map<string, SyncProgress>;
const g = globalThis as { __parcelSyncProgressStore?: ProgressStore };
const g = globalThis as {
__parcelSyncProgressStore?: ProgressStore;
__progressCleanupTimer?: ReturnType<typeof setInterval>;
};
const store: ProgressStore = g.__parcelSyncProgressStore ?? new Map();
g.__parcelSyncProgressStore = store;
// Periodic cleanup of stale progress entries (every 30 minutes)
if (!g.__progressCleanupTimer) {
g.__progressCleanupTimer = setInterval(() => {
for (const [jobId, p] of store.entries()) {
if (p.status === "done" || p.status === "error") {
store.delete(jobId);
}
}
}, 30 * 60_000);
}
export const setProgress = (p: SyncProgress) => store.set(p.jobId, p);
export const getProgress = (jobId: string) => store.get(jobId);
export const clearProgress = (jobId: string) => store.delete(jobId);
+224 -36
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,34 +118,100 @@ 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
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", downloaded: dl, total: tot }),
push({
phase: "Descărcare features (complet)",
downloaded: dl,
total: tot,
}),
delayMs: 200,
})
: await client.fetchAllLayerByWhere(
@@ -153,13 +221,41 @@ export async function syncLayer(
total: remoteCount > 0 ? remoteCount : undefined,
onProgress: (dl, tot) =>
push({
phase: "Descărcare features",
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",
@@ -237,8 +325,16 @@ export async function syncLayer(
},
create: item,
update: {
...item,
siruta: item.siruta,
inspireId: item.inspireId,
cadastralRef: item.cadastralRef,
areaValue: item.areaValue,
isActive: item.isActive,
attributes: item.attributes,
geometry: item.geometry,
syncRunId: item.syncRunId,
updatedAt: new Date(),
// enrichment + enrichedAt preserved — not overwritten
},
});
}
@@ -261,17 +357,108 @@ 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" });
const BATCH = 30_000;
for (let i = 0; i < removedObjIds.length; i += BATCH) {
await prisma.gisFeature.deleteMany({
where: {
layerId,
siruta,
objectId: { in: removedObjIds },
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
const localCount = await prisma.gisFeature.count({
@@ -304,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 });
}
@@ -9,6 +9,7 @@ import {
X,
FileText,
} from "lucide-react";
import { useAuth } from "@/core/auth";
import { Button } from "@/shared/components/ui/button";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
@@ -66,6 +67,7 @@ export function CloseGuardDialog({
activeDeadlines,
onConfirmClose,
}: CloseGuardDialogProps) {
const { user } = useAuth();
const [search, setSearch] = useState("");
const [selectedEntryId, setSelectedEntryId] = useState("");
const [resolution, setResolution] = useState<ClosureResolution>("finalizat");
@@ -130,7 +132,7 @@ export function CloseGuardDialog({
onConfirmClose({
resolution,
reason: reason.trim(),
closedBy: "Utilizator", // TODO: replace with SSO identity
closedBy: user?.name ?? "Utilizator",
closedAt: new Date().toISOString(),
linkedEntryId: selectedEntryId || undefined,
linkedEntryNumber: selectedEntry?.number,
@@ -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 && (
{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,12 +1002,61 @@ 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>
<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"
@@ -942,66 +1065,91 @@ function ExternalStatusSection({ entry }: { entry: RegistryEntry }) {
disabled={checking}
>
<RefreshCw className={cn("h-3 w-3 mr-1", checking && "animate-spin")} />
{checking ? "Se verifică..." : "Verifică acum"}
{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 (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]}`);
}
}
}, [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,7 +81,16 @@ export function StatusMonitorConfig({
petitionerName.trim(),
);
const tracking: ExternalStatusTracking = {
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(),
@@ -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"]