Compare commits

..

320 Commits

Author SHA1 Message Date
Claude VM 6b3d56e1e8 refactor(deploy): externalize all secrets to .env, migrate Brevo SMTP → REST API
- docker-compose.yml: replace 43 hardcoded env values with ${VAR} references.
  Operators must provide /opt/architools/.env (chmod 600, gitignored) with the
  matching keys. Removes the historical leak surface where every edit risked
  echoing secrets.
- email-service.ts: drop nodemailer SMTP transport; use Brevo REST API
  (POST https://api.brevo.com/v3/smtp/email) with BREVO_API_KEY header.
  Brevo SMTP relay credentials have been deleted upstream.
- package.json: remove nodemailer + @types/nodemailer.

NOTE: legacy hardcoded credentials present in git history must still be
rotated separately (DB password, Authentik client secret, ENCRYPTION_SECRET,
ANCPI password, etc.).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 07:49:08 +03:00
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
AI Assistant c012adaa77 fix: export buttons direct (no dropdown), compact mobile cards
Export fix:
- Replaced DropdownMenu with direct DXF/GPKG buttons in SelectionToolbar.
  Radix dropdown portals don't work inside fixed z-[110] containers.
  Direct buttons work reliably on all platforms.

Mobile RGI cards:
- Single-row compact layout: icon + nr cerere + solicitant + termen + status
- Smaller icons (3.5), tighter spacing, shorter status labels
- No Card wrapper — lightweight border div for less visual weight

Mobile filters:
- Tighter spacing, smaller labels

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:03:24 +02:00
AI Assistant d82b873552 fix(portal): mobile toolbar as fixed viewport element + layout fixes
Selection toolbar:
- Moved OUTSIDE map container div to a fixed viewport position
  (bottom-4, z-[110]). iOS Safari clips absolute elements inside
  calc(100vh) containers — fixed positioning solves this.
- Only shown when UAT selected and has data.

Mobile top layout:
- UAT card takes full width (right-2 not right-[140px])
- Basemap switcher at top-[52px] left-2 on mobile (below UAT card)
- Desktop: unchanged (top-right offset from zoom controls)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 11:06:48 +02:00
AI Assistant 12ff629fbf feat: ZIP download, mobile fixes, click centering, tooltip
ZIP download:
- Both portal and RGI test page now create a single ZIP archive
  (Documente_eliberate_{appNo}.zip) instead of sequential downloads
- Uses JSZip (already in project dependencies)

Portal mobile:
- Basemap switcher drops below UAT card on mobile (top-14 sm:top-2)
- Selection toolbar at bottom-3 with z-30 (always visible)
- Click on feature centers map on that parcel (flyTo)

Tooltips:
- Green download icon: "Descarca arhiva ZIP cu documentele cererii X"
- Updated on both portal and RGI test page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:17:29 +02:00
AI Assistant 8acafe958b fix: freehand drawing, click highlight, mobile toolbar visibility
Freehand drawing fix:
- Disable dragPan when in freehand mode (was only disabling dblclick
  zoom). Without this, clicks were interpreted as pan gestures.
- Re-enable dragPan when exiting freehand mode.

Click highlight:
- Clicking a parcel in "off" mode now highlights it with the selection
  layer (amber fill + orange outline). Clicking empty space clears it.
- Provides visual feedback for which parcel was clicked.

Mobile toolbar:
- Moved selection toolbar higher (bottom-12 on mobile) with z-20
  to ensure it's above MapLibre attribution bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 07:11:16 +02:00
AI Assistant 45d4d1bf40 fix: hide enrichment in portal, CF disabled button, no UAT flash, fix overlaps
SelectionToolbar: new hideEnrichment prop hides the Enrichment button.
Portal uses it to show only Export + Clear in selection toolbar.

Portal feature panel: added disabled "Solicita extras CF" button with
tooltip "Sectiune platita — contacteaza administratorul".

Initial map zoom: starts at zoom 15 (close-up) instead of 7 (Romania
overview). Prevents the UAT boundaries flash before fitBounds runs.
Applied to both ParcelSync Harta tab and Portal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:58:01 +02:00
AI Assistant 6f46a85ff3 fix(rgi+portal): default sort desc by termen, fix overlaps, tooltip
RGI (both pages):
- Default sort: Termen descrescator (cel mai in viitor sus)

Portal map:
- Basemap switcher moved left (right-12) to avoid zoom controls overlap
- Selection toolbar moved up (bottom-8) to avoid attribution overlap
- Download button has title tooltip on mobile cards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:52:22 +02:00
AI Assistant 2cd35c790d fix(portal): mobile responsive — card view for RGI, visible map controls
Mobile (< 640px):
- RGI: card-based layout instead of table (shows nr cerere, status,
  solicitant, termen, rezolutie, UAT in compact card)
- Header: compact "Portal" title, smaller tab buttons
- Map: selection toolbar centered at bottom (always visible)
- UAT info card: smaller text, truncated, doesn't overlap basemap switcher
- Feature panel: narrower (w-56 vs w-64)
- Filter buttons: smaller text

Desktop (>= 640px):
- Same table view as before (hidden on mobile)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:25:12 +02:00
AI Assistant 7a36f0b613 fix(portal): build error + simple feature panel without enrichment/CF
Fixed:
- Added setLayoutProperty to MapLike type (was missing, broke build)
- Replaced FeatureInfoPanel with simple inline panel showing only:
  SIRUTA, Nr. cadastral, Suprafata (no enrichment, no CF extract,
  no "Actualizeaza" button)
- Fixed unknown type errors in JSX property access
- Hidden basemap boundaries + UAT layers for cleaner map

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:23:37 +02:00
AI Assistant 1919155d41 perf: hide basemap boundaries, remove UAT layers, optimize Martin minzoom
Basemap cleanup (after map load):
- Hide OpenFreeMap boundary/admin layers (redundant with our UATs)
- Keep city/town place labels, remove village-level
- Hide our Martin UAT layers (not needed in ParcelSync — filtered by siruta)

Martin tile optimization:
- gis_terenuri_status minzoom: 10 → 13 (parcels not visible below 13)
- gis_cladiri_status minzoom: 12 → 14 (buildings not visible below 14)
- Prevents unnecessary tile fetches at low zoom levels

Applied to both ParcelSync Harta tab and Portal map.
Requires docker restart martin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:15:00 +02:00
AI Assistant 1a5487f0f7 fix: zoom no longer resets after manual pan/zoom (fitBounds once per siruta)
The fitBounds effect was re-triggered every time mapReady toggled
(which happened frequently due to the source-checking polling interval).
Now uses boundsFittedForSirutaRef to ensure fitBounds runs only ONCE
per siruta selection — changing UAT still zooms correctly, but manual
zoom/pan is preserved afterwards.

Fixed in both ParcelSync Harta tab and Portal map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 01:08:33 +02:00
AI Assistant daca222427 fix: portal user match for dtiurbe / d.tiurbe@beletage.ro
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:55:57 +02:00
AI Assistant f1f4dc097e fix(portal): full-screen overlay + redirect portal-only users
Portal layout: removed conflicting (portal)/layout.tsx that had
duplicate html/body tags. Portal page now uses fixed overlay
(z-[100]) that covers the entire screen including sidebar.

Middleware: portal-only users (dan.tiurbe) are automatically
redirected from any non-portal route to /portal. They can still
access /api/ and /auth/ routes normally.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:49:44 +02:00
AI Assistant e420cd4609 feat: standalone portal page for Dan Tiurbe at /portal
Dedicated external portal combining RGI documents + cadastral map,
without ArchiTools sidebar/navigation.

Layout: (portal) route group with minimal layout (no AppShell).

Tab "Documente RGI":
- Full RGI functionality (county selector, sortable/filterable table,
  expandable docs with download, batch download)

Tab "Harta":
- UAT autocomplete selector
- Sync button when UAT has no data
- Map with basic parcel/building styling (no enrichment overlay)
- Only 3 basemaps: Harta (liberty), Noapte (dark), Google satellite
- Selection tools (click/rect/freehand) + DXF/GPKG export
- Feature info panel on click
- No CF extract ordering (hidden)

URL: https://tools.beletage.ro/portal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:29:29 +02:00
AI Assistant 9df6c9f542 fix(rgi): default columns, date sort, clean filenames, green icon downloads all
Default columns: Nr. cerere, Solicitant, Termen, Status, Rezolutie, UAT
(matching user's preferred view). Obiect, Identificatori, Deponent,
Data depunere now off by default.

Date sort: dueDate and appDate columns now sort by raw timestamp
(not by DD.MM.YYYY string which sorted incorrectly).

Filenames: removed long documentPk from filename. Now uses
DocType_AppNo.pdf (e.g. Receptie_tehnica_66903.pdf). Duplicate
types get suffix: Receptie_tehnica_66903_2.pdf.

Green icon: click downloads ALL documents from that application
sequentially. Shows spinner while downloading. Tooltip shows
"Nr. 66903 — click descarca toate" + details.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 00:03:39 +02:00
AI Assistant aebe1d521c feat(rgi): download all docs button + tooltips on status icon
Download all:
- "Descarca toate" button in expanded docs panel
- Downloads each document sequentially with correct filename
  (e.g. Receptie_tehnica_66903_10183217654.pdf)
- Progress indicator: "2/5: Harti & planuri..."
- Skips blocked docs, shows summary "3 descarcate, 2 indisponibile"
- 300ms delay between downloads to avoid browser blocking

Status icon tooltip:
- Hover on green/clock icon shows: Nr cerere, Obiect, Status,
  Rezolutie, Termen, Identificatori

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:41:51 +02:00
AI Assistant 2f114d47de feat(rgi): sortable/filterable table, county selector, smart filenames, soft blocked msg
Page improvements:
- County dropdown with all 41 Romanian counties (default Cluj)
- orgUnitId auto-computed (countyId * 1000 + 2)
- Sortable columns: click header to sort asc/desc with arrow indicators
- Search input: filters across all visible columns (diacritics-insensitive)
- Soft blocked message: amber toast "Documentul nu este inca disponibil"
  auto-hides after 5s (no more redirect errors)

Download improvements:
- Meaningful filenames: {docType}_{appNo}.pdf (e.g. Harti_planuri_66903.pdf)
- Romanian diacritics stripped from filenames
- Returns { blocked: true } JSON instead of redirect when unavailable

Bug fix: replaced incorrect useState() side-effect with proper useEffect()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:37:00 +02:00
AI Assistant c1006f395c fix(rgi): remove wrong dueDate lock — always show download button
The dueDate-based lock was incorrect: some documents with future
dueDate ARE downloadable. The availability depends on eTerra internal
rules, not predictably on dueDate.

Now all documents show a download button. If server-side download
fails (fileVisibility 404), it redirects to eTerra direct URL
which works in the user's browser session.

Filters changed to: Solutionate / Confirmate / Toate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:23:48 +02:00
AI Assistant a191a684b2 feat(rgi): filter by downloadable/pending + locked document indicator
eTerra blocks document downloads until dueDate passes (new rule).
Now the page shows:

Filter modes:
- "Descarcabile acum" (default) — solved + dueDate passed
- "In asteptare" — solved + dueDate future (documents locked)
- "Toate" — no filter

UI indicators:
- Green download icon: ready to download
- Amber clock icon: solved but locked until dueDate
- Documents panel shows "Disponibile de la DD.MM.YYYY" badge when locked
- Download button replaced with date badge for locked documents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 23:12:48 +02:00
AI Assistant 3614c2fc4a fix(rgi): set application context before download attempt
Before downloading, now calls:
1. verifyCurrentActorAuthenticated — sets actor context in session
2. appdetail/details — loads application context

Then tries download regardless of fileVisibility result.
The session context might be what enables downloads that previously
returned 404.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:50:27 +02:00
AI Assistant 4beac959c8 fix(rgi): redirect to eTerra when server-side download unavailable
When fileVisibility returns OK → download server-side (fast).
When not available → HTTP 302 redirect to eTerra direct URL.
User's browser session handles authentication automatically.

This means: if logged into eTerra in browser, ALL documents download.
If not logged in, eTerra shows its own login page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:48:01 +02:00
AI Assistant b0a5918bd7 fix(rgi): fast download with fileVisibility gate + clear error message
Download route simplified:
1. fileVisibility check — if 404, returns "indisponibil" + eTerra URL
2. Single download pattern (the one that works)

When document not available server-side, response includes direct
eTerra URL as fallback. No more 7 pattern attempts = much faster.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:39:53 +02:00
AI Assistant 5966a11f7e fix(rgi): download via user's eTerra browser session (not server-side)
Server credentials can list RGI applications and docs but can't download
files (confirmOnView returns false — only the current actor/deponent
has download permission).

Download now opens the eTerra URL directly in the user's browser,
which uses their existing eTerra session cookie. Flow:
1. Hidden iframe calls confirmOnView
2. After 500ms, opens downloadFile URL in new tab

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:32:09 +02:00
AI Assistant 0e5c01839d fix(rgi): exhaustive download debug — tries 7 URL patterns + GET/POST confirmOnView
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:25:14 +02:00
AI Assistant d780c3c973 fix(rgi): diagnostic download route — tries multiple URL patterns
Download route now:
- Calls fileVisibility with documentTypeId (if provided)
- Calls confirmOnView with documentPk
- Tries 3 different download URL patterns until one works
- Add &debug=1 to see diagnostic results instead of downloading
- Page now passes documentTypeId in download link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:13:34 +02:00
AI Assistant 7a28d3ad33 fix(rgi): proper table layout with td per column for alignment
Replaced single colSpan td with flex layout → proper td per column.
Headers and data cells now align correctly. Expanded docs row uses
colSpan only for the detail panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 22:03:41 +02:00
AI Assistant 4707c6444e fix(rgi): rgiDownload handles session expiry + re-login on 401/302/404
eTerra returns 404 (not 401) when session expires during file download
because it redirects to login page. Now rgiDownload:
- Uses validateStatus to catch all statuses
- Re-logins and retries on 401/302/404
- Sets Accept: */* header for binary downloads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:59:50 +02:00
AI Assistant e5e2fabb1d fix(rgi): correct download flow — confirmOnView + downloadFile by documentPk
The download uses documentPk (not documentTypeId) as the file identifier:
1. confirmOnView/{wid}/{appId}/{documentPk} — confirm view
2. loadDocument/downloadFile/{wid}/{documentPk} — actual download

Removed fileVisibility step (not needed, was causing 404).
Updated page download links to pass documentPk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:46:37 +02:00
AI Assistant 227c363e13 fix(rgi): correct field mapping + configurable columns + download fix
Mapped all eTerra RGI fields correctly:
- App: appNo, applicationPk, appDate, dueDate, deponent, requester,
  applicationObject, identifiers, statusName, resolutionName, hasSolution
- Docs: docType, documentPk (fileId), documentTypeId (docId),
  fileExtension, digitallySigned, startDate

Features:
- Configurable columns: click "Coloane" to toggle 17 available columns
- Table layout with proper column rendering
- Click row to expand issued documents
- Documents show type name, date, extension, digital signature badge
- Download button with correct fileId/docId mapping
- Filter: hasSolution===1 && dueDate > now (not string matching)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:33:10 +02:00
AI Assistant 64f10a63ff fix(rgi): user-friendly page + 3-step download flow
Rewrote RGI test page:
- Clean card-based UI with status icons (green=solved, amber=pending)
- Click row to expand and see issued documents
- Each document has a direct "Descarca" download button
- Filter toggle "Doar solutionate cu termen viitor"
- No more raw JSON tables

Download route now follows eTerra's 3-step flow:
1. fileVisibility — check access, get fileId
2. confirmOnView — confirm document view
3. loadDocument/downloadFile — actual file download

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:18:32 +02:00
AI Assistant aa11ca389e feat(eterra): RGI API routes + test page for issued documents
New eTerra RGI (Registrul General de Intrare) integration:

API routes (/api/eterra/rgi/):
- POST /applications — list applications with workspace/year filters
- GET /details?applicationId=X — application details
- GET /issued-docs?applicationId=X&workspaceId=Y — issued documents list
- GET /download-doc?wid=X&aid=Y&did=Z — download issued document

EterraClient: added rgiPost, rgiGet, rgiDownload methods for RGI API.

Test page (/rgi-test):
- Filters: workspace, orgUnit, year
- Toggle: "Doar solutionate cu termen viitor"
- Table with application list, expandable issued docs, download links
- Raw JSON debug sections (collapsible)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:59:49 +02:00
AI Assistant 1dac5206e4 fix(parcel-sync): re-apply custom layers after basemap switch
MapViewer destroys and recreates the map when basemap changes. The
readiness polling now detects when custom sources are missing (new map
instance) and resets appliedSirutaRef + prevCheckSirutaRef, which
triggers all effects to re-run: siruta filter, enrichment overlay,
boundary mismatch GeoJSON, and fitBounds.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:10:13 +02:00
AI Assistant 3da45a4cab feat(parcel-sync): sync button on empty Harta tab + intravilan in base sync
Map tab: when UAT has no local data, shows a "Sincronizează terenuri,
clădiri și intravilan" button that triggers background base sync.

Sync background (base mode): now also syncs LIMITE_INTRAV_DYNAMIC layer
(intravilan boundaries) alongside TERENURI_ACTIVE + CLADIRI_ACTIVE.
Non-critical — if intravilan fails, the rest continues.

Also fixed remaining \u2192 unicode escapes in export/layers/epay tabs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:04:09 +02:00
AI Assistant b1fc7c84a7 fix(parcel-sync): mismatch parcels visible from zoom 13, labels from 16
Mismatch fill/line layers now have minzoom: 13 (same as normal parcels).
Labels have minzoom: 16 with text-size: 10 and text-allow-overlap: false
(same settings as the regular parcel cadastral labels).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:57:03 +02:00
AI Assistant b87c908415 fix(parcel-sync): static connection dots, legend position, mismatch labels
- ePay + eTerra pills: removed animate-ping, now show static green dot
  when connected (no more spinning appearance)
- Legend moved to top-left, hides when FeatureInfoPanel is open
  (no more overlap)
- Boundary mismatch parcels now show cadastral numbers as labels
  (orange for foreign, purple for edge parcels)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:39:01 +02:00
AI Assistant ab35fc4df7 fix(parcel-sync): red parcel fill for buildings without legal docs
Instead of trying to color buildings directly (which requires an
unreliable cadastralRef join), the parcel itself gets a strong red fill
(opacity 0.45) when has_building=1 AND build_legal=0. Buildings sitting
on these parcels are visually on a red background.

Color scheme:
- Red fill: building without legal docs
- Light blue fill: building with legal
- Green fill: enriched, no building
- Yellow/amber fill: no enrichment

Removed broken gis_cladiri_status overlay. Simplified legend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:33:57 +02:00
AI Assistant 3f5eed25f4 fix(geoportal): DROP enrichment views before recreate (column change)
PostgreSQL CREATE OR REPLACE VIEW fails when column structure changes.
Now drops views first, then recreates them.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:21:57 +02:00
AI Assistant 0dc5e58b55 fix(geoportal): use subquery instead of JOIN for gis_cladiri_status view
LEFT JOIN caused duplicate rows and column conflicts. Replaced with a
correlated subquery (LIMIT 1) to safely look up BUILD_LEGAL from the
parent parcel's enrichment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:16:06 +02:00
AI Assistant ba71ca3ef5 feat(parcel-sync): fix click, color styling, UAT boundary cross-check
Click fix:
- Keep l-terenuri-fill visible but transparent (opacity 0) so it still
  catches click events for FeatureInfoPanel. Enrichment overlay renders
  underneath.

Color changes:
- No enrichment: amber/yellow fill (was light green)
- With enrichment: green fill
- Buildings: red fill = no legal docs, blue = legal, gray = unknown
- Parcel outline: red = building no legal, blue = building legal

Boundary cross-check (/api/geoportal/boundary-check?siruta=X):
- Finds "foreign" parcels: registered in other UATs but geometrically
  within this UAT boundary (orange dashed)
- Finds "edge" parcels: registered here but centroid outside boundary
  (purple dashed)
- Alert banner shows count, legend updated with mismatch indicators

Martin config: added gis_cladiri_status source with build_legal property.
Enrichment views: gis_cladiri_status now JOINs parent parcel's BUILD_LEGAL.

Requires: docker restart martin + POST /api/geoportal/setup-enrichment-views

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:05:12 +02:00
AI Assistant 2848868263 fix(parcel-sync): fitBounds zoom + Martin config for enrichment tiles
- Map tab now uses fitBounds (not flyTo with fixed zoom) to show entire
  UAT extent when selected. Bounds are fetched and applied after map ready.
- Added gis_terenuri_status to martin.yaml so Martin serves enrichment
  tiles (has_enrichment, has_building, build_legal properties).
- Removed center/zoom props from MapViewer — use fitBounds via handle.
- Requires `docker restart martin` on server for Martin to reload config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:38:15 +02:00
AI Assistant 2b8d144924 fix(parcel-sync): replace Unicode escapes with actual Romanian diacritics
The \u0103, \u00ee etc. escape sequences were rendering literally in JSX
text nodes instead of displaying ă, î, ț, ș characters.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:24:11 +02:00
AI Assistant d48a2bbf5d refactor(parcel-sync): split 4800-line module into 9 files + Harta tab + enrichment views
Split parcel-sync-module.tsx (4800 lines) into modular files:
- Orchestrator (452 lines): shared state (session, UAT, sync) + tab routing
- Types + helpers, ConnectionPill, 6 tab components (search, layers, export, database, cf, map)

New ParcelSync Harta tab:
- UAT-scoped map: zoom to extent, filter parcels/buildings by siruta
- Data-driven styling via gis_terenuri_status enrichment overlay
  (green=no enrichment, dark green=enriched, blue outline=building, red=no legal docs)
- Reuses Geoportal components (MapViewer, SelectionToolbar, FeatureInfoPanel, BasemapSwitcher)
- Export DXF/GPKG for selection, legend

New PostGIS views (gis_terenuri_status, gis_cladiri_status):
- has_enrichment, has_building, build_legal columns from enrichment JSON
- Auto-created via /api/geoportal/setup-enrichment-views
- Does not modify existing Geoportal views

New API: /api/geoportal/uat-bounds (WGS84 bbox from PostGIS geometry)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:02:01 +02:00
AI Assistant 3fcf7e2a67 fix(geoportal): Google satellite, ESC/right-click exit, no UAT fill, ANCPI bbox fix
Basemaps: added Google Satellite option
ANCPI ortofoto: fixed bbox conversion (all 4 corners, not just SW/NE)
Selection: ESC key and right-click exit selection mode, tooltips updated
UAT layers: removed fill (only lines + labels), less visual clutter
Proprietari vechi: greyed out (opacity-50) so current owners stand out

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:19:02 +02:00
AI Assistant 1cc73a3033 fix(geoportal): enrichment now calls proven /api/eterra/search internally
Instead of reimplementing eTerra search logic (which missed most fields),
now calls the existing /api/eterra/search endpoint that already works
perfectly in ParcelSync. Same data, same format: proprietari, CF, CFvechi,
topo, intravilan, categorie, adresa, solicitant.

Per-parcel, 2-5 seconds, persists in DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:05:07 +02:00
AI Assistant 024ee0f21a fix(geoportal): layer toggle + enrichment update + refresh button
1. Layer toggle fix: removed isStyleLoaded() check that silently blocked
   visibility changes when OpenFreeMap style has pending sprite/font loads
2. Enrichment: "Actualizeaza" button always visible (re-fetch from eTerra)
   replaces "Enrichment" button when data already exists
3. Panel updates with returned enrichment data immediately

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:53:18 +02:00
AI Assistant 19bed6724b fix(geoportal): enrichment panel update + force-hide all layers + boundary filter
1. Enrichment: panel now updates immediately with returned data (was only showing message)
2. Layers: ALL data layers set to visibility:none immediately after creation,
   then only enabled ones are shown. Fixes cladiri appearing when only terenuri toggled.
3. OpenFreeMap boundaries: also filter by source-layer="boundary" (more reliable)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:45:29 +02:00
AI Assistant 48fe47d2c0 fix(geoportal): per-parcel enrichment via searchImmovableByIdentifier
Replaces background UAT-wide enrichment with instant per-parcel search.
Uses eTerra searchImmovableByIdentifier (cadastral number lookup) which
returns in 1-3 seconds instead of minutes.

Extracts: NR_CF, proprietari (with shares), intravilan, categorie,
adresa, has_building, build_legal. Persists in DB immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:35:03 +02:00
AI Assistant 5ff7d4cdd7 fix(geoportal): hide oneway arrows from OpenFreeMap basemap
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:24:41 +02:00
AI Assistant 91034c41ee fix(geoportal): background enrichment using proven enrichFeatures()
Previous single-parcel enrichment wrote empty data (couldn't match in eTerra).
Now uses the original enrichFeatures() which properly fetches owners, CF, etc.

Changes:
- Enrichment runs in BACKGROUND (returns immediately with message)
- Clears bad enrichment data before re-running
- Tracks running enrichments to avoid duplicates
- GET /api/geoportal/enrich?siruta=... checks if enrichment is running
- Panel: hasRealEnrichment checks for CF/PROPRIETARI/CATEGORIE (not just NR_CAD)
- Enrichment button stays visible until real data exists
- Message: "Enrichment pornit in background. Datele vor aparea in 1-3 minute."

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:23:44 +02:00
AI Assistant d9c247fee2 fix(geoportal): force all layers hidden on map load (fixes terenuri/cladiri showing when toggled off)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:20:13 +02:00
AI Assistant 7ae23aebf4 fix(geoportal): hide OpenFreeMap built-in boundary layers on load
OpenFreeMap Liberty/Dark styles include admin boundary layers that show
even when our UAT toggle is off. Now hides all boundary/admin/border
layers from the basemap style on map load.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:18:44 +02:00
AI Assistant d2b69d5ec6 fix(geoportal): all layers OFF by default + full enrichment display
Layers:
- ALL layers OFF by default (just basemap on load)
- User activates what they need

Feature panel:
- Shows ALL enrichment fields: proprietari (full text, wrapping),
  CF vechi, nr topo, adresa, solicitant, intravilan, categorie
- Building info with icon (cu acte / fara acte warning)
- hasEnrichment check relaxed (any non-empty field counts)
- Panel scrollable (max-h 60vh) for long data
- WrapRow for multi-line text (proprietari, adresa)
- Enrichment button visible when no enrichment data
- Enrichment auto-updates panel on success

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:09:41 +02:00
AI Assistant dfa4815d75 fix(geoportal): layers off by default + bulk enrichment feedback
- UAT + Intravilan layers OFF by default (user activates when needed)
- Terenuri/Cladiri listed first in panel (most used)
- Bulk enrichment: per-feature with progress counter (3/10), success summary
- Progress text shown in toolbar during enrichment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:58:24 +02:00
AI Assistant 903dc67ac4 fix: drop views before recreating slim versions (cannot drop columns from view)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:43:56 +02:00
AI Assistant 60919122d9 feat(geoportal): one-click optimize-tiles + unified setup banner
New endpoint POST /api/geoportal/optimize-tiles:
- Slims gis_features view (drops attributes, enrichment, timestamps)
- Cascades to gis_terenuri, gis_cladiri, gis_administrativ, gis_documentatii
- Makes vector tiles dramatically smaller

Setup banner now checks 3 optimizations:
1. UAT zoom views (gis_uats_z0/z5/z8/z12)
2. Pre-computed geometry (geom_z0/z5/z8 columns)
3. Slim tile views (no JSON columns)

One "Aplica toate" button runs all pending steps sequentially.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:26:08 +02:00
AI Assistant 32d3f30f9d fix(geoportal): auto-refresh panel after enrichment + Comanda CF always visible
- After enrichment: panel updates immediately with returned data (no reload needed)
- "Comanda CF" button visible on any parcel with cadastral ref (not just enriched ones)
- "Descarca CF" shown when CF extract already exists in DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:11:59 +02:00
AI Assistant 8ead985c7e perf(geoportal): single-parcel enrichment instead of full UAT
Previous enrichment tried to enrich ALL parcels in a UAT (minutes).
Now enriches just the clicked parcel (seconds):
1. Finds the GisFeature by ID or objectId+siruta
2. Fetches immovable data from eTerra for that specific parcel
3. Persists enrichment in DB
4. Skips if enriched < 7 days ago

Auto-uses env credentials (ETERRA_USERNAME/PASSWORD) — no manual login needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:56:04 +02:00
AI Assistant 566d7c4bb1 fix(geoportal): better enrichment error messages + login retry
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:25:55 +02:00
AI Assistant 3ffb617970 fix(geoportal): font 404s + slim tile views for performance
- Labels: add text-font ["Noto Sans Regular"] (OpenFreeMap compatible)
- Optimize views: gis_features/terenuri/cladiri/administrativ now exclude
  attributes, enrichment, timestamps (huge JSON columns that made tiles slow)
- Views only include: id, layer_id, siruta, object_id, cadastral_ref,
  area_value, is_active, geom

Run optimize-views again after deploy to apply slimmer views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:23:05 +02:00
AI Assistant 8362e3fd84 fix(geoportal): drop views before adding columns (fixes geometry type conflict)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:01:11 +02:00
AI Assistant 53c241c20f perf(geoportal): materialize simplified UAT geometries (fixes 90% CPU on PostgreSQL)
ST_SimplifyPreserveTopology in views runs on every Martin tile request,
causing constant CPU load. Fix: pre-compute simplified geometries into
dedicated columns (geom_z0, geom_z5, geom_z8) on the GisUat table.

POST /api/geoportal/optimize-views:
1. Adds geom_z0/z5/z8 columns to GisUat
2. Backfills with pre-computed simplifications (one-time cost)
3. Creates GiST spatial indexes on each
4. Replaces views to use pre-computed columns (zero CPU reads)
5. Updates trigger to auto-compute on INSERT/UPDATE

Setup banner: now checks optimization status and shows "Optimizeaza"
button if needed. One-click, then docker restart martin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:51:17 +02:00
AI Assistant c4122cea01 feat(geoportal): enrichment API + CF download + bulk enrichment
New API endpoints:
- POST /api/geoportal/enrich — enriches all parcels for a SIRUTA,
  skips already-enriched, persists in GisFeature.enrichment column
- GET /api/geoportal/cf-status?nrCad=... — checks if CF extract exists,
  returns download URL if available

Feature panel:
- No enrichment: "Enrichment" button (triggers eTerra sync for UAT)
- Has enrichment + CF available: "Descarca CF" button (direct download)
- Has enrichment + no CF: "Comanda CF" button (link to ePay tab)
- Copy button always visible
- After enrichment completes, panel auto-reloads data

Selection toolbar:
- Bulk "Enrichment" button for selected parcels (per unique SIRUTA)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:48:08 +02:00
AI Assistant 800c45916e feat(geoportal): rectangle + freehand polygon selection drawing on map
Rectangle mode (Dreptunghi):
- Mousedown starts drawing, mousemove shows amber overlay, mouseup selects
- All terenuri features in the drawn bbox are added to selection
- Map panning disabled during draw, re-enabled after
- Minimum 5px size to prevent accidental micro-selections

Freehand mode (Desen):
- Each click adds a point, polygon drawn with GeoJSON source
- Double-click closes polygon, selects features whose centroid is inside
- Ray-casting point-in-polygon algorithm for spatial filtering
- Double-click zoom disabled during freehand mode

Draw state clears when switching selection modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:19:20 +02:00
AI Assistant 3a2262edd0 feat(geoportal): feature panel with Enrichment + Extras CF buttons
- Parcele fara enrichment: buton "Enrichment" (sync magic de la eTerra)
- Parcele cu enrichment: date complete + buton "Extras CF" (link ePay)
- Buton "Copiaza" (clipboard cu NR_CAD, CF, suprafata, proprietari)
- ExportFormat: removed geojson (only dxf + gpkg)
- Tooltips pe fiecare buton

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:17:15 +02:00
AI Assistant 836d60b72f feat(geoportal): one-time setup banner for PostGIS views
- GET /api/geoportal/setup-views checks if zoom views exist
- POST creates them (idempotent)
- SetupBanner component: auto-checks on mount, shows amber banner if
  views missing, button to create them, success message with docker
  restart reminder, auto-hides when everything is ready

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 08:05:22 +02:00
AI Assistant 7d2fe4ade0 feat(geoportal): selection modes (click/rectangle/freehand) + export DXF/GPKG only
- Selection toolbar: 3 modes — Click (individual), Dreptunghi (area), Desen (freehand)
- Each mode has tooltip explaining usage
- Export: removed GeoJSON, only DXF + GPKG. GPKG labeled "cu metadata"
- DXF export fix: -s_srs + -t_srs (was -a_srs + -t_srs)

Note: rectangle and freehand drawing on map not yet implemented (UI ready,
map interaction coming next session).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 07:57:34 +02:00
AI Assistant 78625d6415 fix(geoportal): simplified info panel, preserve basemap zoom, DXF export, intravilan outline
- Feature panel: simplified (NR_CAD/NR_CF/SIRUTA/Suprafata/Proprietari),
  aligned top-right under basemap switcher, click empty space to close
- Basemap switch: preserves zoom+center via viewStateRef + moveend listener
- DXF export: use -s_srs + -t_srs (not -a_srs + -t_srs which ogr2ogr rejects)
- Intravilan: double line (black outer + orange inner), z13+, no fill
- Parcel labels: cadastral_ref shown at z16+
- UAT z12: original geometry (no simplification)
- Removed MapLibre popup (only side panel)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 07:43:11 +02:00
AI Assistant b38916229e fix(martin): add --default-srid=3844 (ST_Simplify views lose SRID metadata)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:05:19 +02:00
AI Assistant 1b679098ab fix(martin): revert to auto-discovery (Docker bind mount creates directories on Portainer)
Views already select only needed columns, so Martin auto-discovery
serves the same result without needing a config file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:55:59 +02:00
AI Assistant ba3edc3321 fix(martin): mount config as /martin-config.yaml (Docker created /config/martin.yaml as directory)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:34:28 +02:00
AI Assistant 0af3e16a2b feat(geoportal): add /api/geoportal/setup-views endpoint for creating UAT zoom views
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:08:14 +02:00
AI Assistant 4f694d4458 perf(geoportal): 4-level UAT simplification + intravilan layer + preserve view on basemap switch
UAT zoom-dependent views (read-only, original geom NEVER modified):
- gis_uats_z0 (z0-5): 2000m simplification — country outlines
- gis_uats_z5 (z5-8): 500m — regional overview
- gis_uats_z8 (z8-12): 50m — county/city level with labels
- gis_uats_z12 (z12+): 10m — near-original precision

New layers:
- gis_administrativ (intravilan, arii speciale) — orange dashed, no simplification
- Toggle in layer panel (off by default)

Basemap switching:
- Now preserves current center + zoom when switching between basemaps

Parcels + buildings: NO simplification (exact geometry needed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:34:15 +02:00
AI Assistant 76c19449f3 perf(geoportal): zoom-dependent UAT simplification + Martin config + tile cache
PostGIS:
- gis_uats view: ST_SimplifyPreserveTopology(geom, 50) + only name/county/siruta
- gis_uats_simple view: ST_SimplifyPreserveTopology(geom, 500) for z0-z9

Martin config (martin.yaml):
- Explicit source definitions (auto_publish: false)
- gis_uats_simple (z0-9): only name+siruta, 500m simplified geometry
- gis_uats (z0-14): name+siruta+county, 50m simplified
- gis_terenuri (z10-18): object_id+siruta+cadastral_ref+area_value+layer_id
- gis_cladiri (z12-18): same properties
- 24h cache headers on all tiles

MapViewer:
- Dual UAT sources: simplified (z0-9) + detailed (z9+) with seamless handoff
- Zoom-interpolated paint: thin lines at z5, thicker at z12
- UAT labels only z9+, fill opacity z-interpolated (0.03→0.08)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 20:24:38 +02:00
AI Assistant 6c55264fa3 feat(geoportal): OpenFreeMap vector basemaps + eTerra ORTO 2024 ortophoto
Basemap options:
- Liberty (OpenFreeMap vector) — default, sharp vector tiles
- Dark (OpenFreeMap) — dark theme, auto-styled
- Satellite (ESRI World Imagery) — raster
- ANCPI Ortofoto 2024 — proxied via /api/eterra/tiles/orto, converts
  Web Mercator z/x/y to EPSG:3844 bbox, authenticates with eTerra
  session, caches 24h. Requires ETERRA_USERNAME/PASSWORD env vars.

Replaces old raster OSM/OpenTopoMap with vector styles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:43:21 +02:00
AI Assistant 06932b5ddc fix(geoportal): remove .pbf extension from Martin tile URLs
Martin v0.15.0 serves tiles at /{source}/{z}/{x}/{y} without .pbf
extension. Requests with .pbf returned 404.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:30:50 +02:00
AI Assistant 2248ecc5d3 fix(geoportal): fix basemap switching + OpenTopoMap maxzoom 17
- Basemap switching now recreates the map (basemap in useEffect deps)
  instead of buggy remove/re-add that swallowed errors
- OpenTopoMap maxzoom set to 17 (was requesting z19 → 400 errors)
- Basemap maxzoom applied to both source and map maxZoom

Note: Martin vector tiles return 404 — PostGIS views or Martin container
need to be checked on the server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:24:47 +02:00
AI Assistant fff20e0cb9 fix(geoportal): use w-full h-full on map container (MapLibre overrides position to relative)
MapLibre sets position:relative on its container element, which overrides
our absolute inset-0 and causes height to collapse to 0. Using w-full h-full
instead works because the parent (absolute inset-0) has a definite computed
height from positioning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:13:55 +02:00
AI Assistant b13a038eb1 fix(geoportal): use absolute inset-0 on MapViewer wrapper (fixes 0-height canvas)
h-full (height:100%) doesn't propagate through absolutely positioned
parents. The MapLibre container had width=1065 but height=0. Using
absolute inset-0 on the wrapper fills the parent directly via positioning
instead of percentage height.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:07:48 +02:00
AI Assistant 1a9ed1ef76 fix(geoportal): use absolute positioning to fill main (fixes h-full/flex-1 chain)
height: 100% doesn't work when the parent gets its height from flex-1
(flexbox-computed, not explicit height). Fixed by adding position:relative
to main in fullscreen mode and using absolute inset-0 on the geoportal
container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:23:55 +02:00
AI Assistant 2278226ff1 fix(geoportal): fullscreen route + local CSS + proper layout
- Add /geoportal to FULLSCREEN_ROUTES (overflow-hidden, no padding)
- Copy maplibre-gl.css to public/ and load from same origin (avoids CDN/CSP)
- Simplify layout: fill parent via h-full w-full (no negative margin hack)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:14:09 +02:00
AI Assistant 437d734df6 fix(geoportal): load MapLibre CSS via CDN link injection + fullscreen layout
- Static CSS import doesn't work with next/dynamic + standalone output.
  Now injects a <link> tag to unpkg CDN at module load time (bulletproof).
- Geoportal is now fullscreen: map fills entire viewport below the header,
  no duplicate title/description, negative margins bleed to edges.
- Removed page-level CSS imports (no longer needed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:01:48 +02:00
AI Assistant 3346ec709d fix(geoportal): import MapLibre CSS at page level (fixes blank map with next/dynamic)
CSS imports inside dynamically loaded components (ssr: false) don't get
included in the production bundle. Importing maplibre-gl CSS at the page
level ensures it's always available. Applied to both geoportal and
parcel-sync pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:49:40 +02:00
AI Assistant 1b5876524a feat(geoportal): add search, basemap switcher, feature info panel, selection + export
Major geoportal enhancements:
- Basemap switcher (OSM/Satellite/Terrain) with ESRI + OpenTopoMap tiles
- Search bar with debounced lookup (UATs by name, parcels by cadastral ref, owners by name)
- Feature info panel showing enrichment data from ParcelSync (cadastru, proprietari, suprafata, folosinta)
- Parcel selection mode with amber highlight + export (GeoJSON/DXF/GPKG via ogr2ogr)
- Next.js /tiles rewrite proxying to Martin (fixes dev + avoids mixed content)
- Fixed MapLibre web worker relative URL resolution (window.location.origin)

API routes: /api/geoportal/search, /api/geoportal/feature, /api/geoportal/export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:43:01 +02:00
AI Assistant 4ea7c6dbd6 fix(geoportal): use relative /tiles URL for Martin (avoids mixed content + build-time env) 2026-03-23 15:49:37 +02:00
AI Assistant 4a144fc397 fix(geoportal): use HTTPS Martin URL via Traefik /tiles proxy 2026-03-23 15:35:40 +02:00
AI Assistant 00a691debd chore: add NEXT_PUBLIC_MARTIN_URL env var for geoportal 2026-03-23 14:52:40 +02:00
AI Assistant c297a2c5f7 feat: add Geoportal module with MapLibre GL JS + Martin vector tiles
Phase 1 of the geoportal implementation:

Infrastructure:
- Martin vector tile server in docker-compose (port 3010)
- PostGIS setup SQL for GisUat: native geom column, Esri→PostGIS
  trigger, GiST index, gis_uats view for Martin auto-discovery

Geoportal module (src/modules/geoportal/):
- map-viewer.tsx: MapLibre GL JS canvas with OSM base, Martin MVT
  sources (gis_uats, gis_terenuri, gis_cladiri), click-to-inspect,
  zoom-level-aware layer visibility, layer styling
- layer-panel.tsx: collapsible sidebar with layer toggles
- geoportal-module.tsx: standalone page wrapper
- Module registered in config/modules.ts, flags.ts, i18n

ParcelSync integration:
- 6th tab "Harta" with lazy-loaded MapViewer (ssr: false)
- Centered on selected UAT

Dependencies: maplibre-gl v5.21.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:21:37 +02:00
AI Assistant 53595fdf94 docs: add ANCPI ePay env vars to CONFIGURATION.md, bump ParcelSync to 0.6.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:02:30 +02:00
AI Assistant a52f9e7586 feat(parcel-sync): redesign PostGIS/QGIS section with clear instructions
- Renamed "Setup PostGIS" to "Activeaza compatibilitate QGIS"
- Tooltip: "Operatie sigura, reversibila. Nu modifica datele existente."
- After setup: shows step-by-step QGIS connection instructions
  (host, port, database, views, SRID)
- Button hidden after successful setup (shows instructions instead)
- Clear explanation for non-technical users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:01:35 +02:00
AI Assistant 88754250a8 docs: update CLAUDE.md + SKILLS.md with ePay integration, performance fixes
- ParcelSync version 0.6.0 with ePay CF extract ordering
- ANCPI ePay in Current Integrations table
- Static WORKSPACE_TO_COUNTY mapping documented
- GisUat geometry select optimization documented
- Feature count cache (5-min TTL) documented
- ePay endpoint gotchas, auth flow, order flow
- Cleaned outdated info, focused on actionable gotchas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:57:42 +02:00
AI Assistant 14a77dd6f7 perf: cache GisFeature counts in memory (5min TTL, stale-while-revalidate)
Feature count groupBy query is expensive but data changes rarely.
First request waits for query, subsequent ones return cached instantly.
After 5min, stale cache is returned immediately while background
refresh runs. Badge "N local" is back on UAT dropdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 13:45:33 +02:00
AI Assistant d0c1b5d48e perf: select only needed columns from GisUat, skip geometry (~100MB) 2026-03-23 13:23:23 +02:00
AI Assistant ad4c72f527 perf(parcel-sync): make GisFeature groupBy opt-in on /api/eterra/uats
The groupBy query scanning the entire GisFeature table (~30k+ rows)
was blocking the UAT list API for 25+ seconds on every page load.
Feature counts are now opt-in via ?features=true query param.
Default response is instant (just GisUat table, no joins).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:44:09 +02:00
AI Assistant 2886703d0f perf(parcel-sync): use useDeferredValue for UAT search input
React's useDeferredValue lets the input update immediately while
deferring the expensive filter (3186 items) to a lower priority.
Removes the setTimeout debounce in favor of React's built-in
concurrent rendering scheduler. Input stays responsive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:30:47 +02:00
AI Assistant 62777e9778 fix(ancpi): UAT debounce + list tooltips + expired download + ePay retry
1. UAT search: 150ms debounce prevents slow re-renders on keystroke
2. Lista mea tooltips: "Scoate Extrase CF" shows exact credit cost,
   status badges show expiry dates and clear instructions
3. Expired extracts: both Descarcă (old version) + Actualizează shown
4. ePay auto-connect: retry 2x with 3s delay, check session before
   connect, re-attempt on disconnect detection

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:14:34 +02:00
AI Assistant 5a6ab36aa7 feat(ancpi): selectable extracts with numbered ZIP download
- Checkbox on each row (ordered selection → numbered files in ZIP)
- "Descarcă selecție (N)" button appears when items selected
- Tooltip shows position in ZIP: "#1 in ZIP", "#2 in ZIP"
- Select-all checkbox in header
- Tooltips on Descarcă tot + Descarcă selecție buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:37:03 +02:00
AI Assistant 87281bc690 fix(ancpi): make Actualizeaza button prominent + add tooltips in tab
- Actualizeaza button: orange bg, white text, clearly clickable
- Tooltip: "Comandă extras CF nou (1 credit) / Extrasul actual a expirat"
- Descarca button: tooltip "Descarcă extras CF (nrCadastral)"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:23:54 +02:00
AI Assistant 7d30e28fdc fix(ancpi): parse CF numbers and solutii separately, zip by position
The nested JSON in ePay HTML breaks [^}]* regex. New approach:
find all CF.stringValues independently, find all solutii independently,
then zip them by position (they appear in same order in HTML).
This correctly maps CF number → document for batch orders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:06:20 +02:00
AI Assistant a826f45b24 feat(ancpi): re-download with CF matching + tooltips + animations
Re-download: all 7 orders re-downloaded using documentsByCadastral
for correct CF→document matching. No more hardcoded order→parcel map.

Tooltips on all CF extract UI elements:
- No extract: "Comandă extras CF (1 credit)"
- Valid: "Valid până la DD.MM.YYYY" + "Descarcă extras CF"
- Expired: "Expirat pe DD.MM.YYYY" + "Comandă extras CF nou (1 credit)"
- Processing: "Comanda în curs de procesare"

Animations: Loader2 spinner while ordering, transition to green check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:38:23 +02:00
AI Assistant 0c94af75d3 fix(ancpi): correct PDF-to-parcel matching + UAT search priority
Critical fix: batch order documents are now matched by CF number
from parsed metadateCereri (documentsByCadastral), not by index.
Prevents PDF content mismatch when ePay returns docs in different order.

UAT search: name matches shown first, county-only matches after.
Typing "cluj" now shows CLUJ-NAPOCA before county "Cluj" matches.

Cleaned MinIO + DB of incorrectly mapped old test data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:29:11 +02:00
AI Assistant a59d9bc923 feat(ancpi): complete ePay UI redesign + ZIP download + smart batch ordering
UI Redesign:
- ePay auto-connect when UAT is selected (no manual button)
- Credit badge with tooltip ("N credite ePay disponibile")
- Search result cards show CF status: Valid (green), Expirat (orange),
  Lipsă (gray), Se proceseaza (yellow pulse)
- Action buttons on each card: download/update/order CF extract
- "Lista mea" numbered rows + CF Status column + smart batch button
  "Scoate Extrase CF": skips valid, re-orders expired, orders new
- "Descarca Extrase CF" button → ZIP archive with numbered files
- Extrase CF tab simplified: clean table, filters (Toate/Valabile/
  Expirate/In procesare), search, download-all ZIP

Backend:
- GET /api/ancpi/download-zip?ids=... → JSZip streaming
- GET /api/ancpi/orders: multi-cadastral status check with statusMap
  (valid/expired/none/processing) + latestById

Data:
- Simulated expired extract for 328611 (Cluj-Napoca, expired 2026-03-17)
- Cleaned old error records from DB

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:13:51 +02:00
AI Assistant b7302d274a docs: update SKILLS.md with complete ANCPI ePay documentation 2026-03-23 04:20:37 +02:00
AI Assistant c9ecd284c7 feat(ancpi): complete ePay UI + dedup protection
UI Components (Phase 4):
- epay-connect.tsx: connection widget with credit badge, auto-connect
- epay-order-button.tsx: per-parcel "Extras CF" button with status
- epay-tab.tsx: full "Extrase CF" tab with orders table, filters,
  download/refresh actions, new order form
- Minimal changes to parcel-sync-module.tsx: 5th tab + button on
  search results + ePay connect widget

Dedup Protection:
- epay-queue.ts: batch-level dedup (60s window, canonical key from
  sorted cadastral numbers)
- order/route.ts: request nonce idempotency (60s cache)
- test/route.ts: refresh protection (30s cache)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 04:19:19 +02:00
AI Assistant fcc6f8cc20 fix(ancpi): strip diacritics from MinIO metadata headers (ASCII only) 2026-03-23 03:51:10 +02:00
AI Assistant af30088ee6 fix(ancpi): simplify document parsing, avoid catastrophic regex backtracking 2026-03-23 03:43:44 +02:00
AI Assistant 6185defa8b fix(ancpi): decode HTML entities before parsing document info from OrderDetails 2026-03-23 03:38:36 +02:00
AI Assistant e63ec4c6c8 fix(ancpi): parse Angular ng-click downloadFile pattern for document IDs 2026-03-23 03:32:21 +02:00
AI Assistant 84b862471c fix(ancpi): add multiple document parsing patterns + debug logging 2026-03-23 03:26:40 +02:00
AI Assistant 8488a53e3b feat(ancpi): batch ordering + download existing orders
Major rewrite:
- Queue now processes batches: addToCart×N → saveMetadata×N → ONE
  submitOrder → poll → download ALL documents → store in MinIO
- Removed unique constraint on orderId (shared across batch items)
- Added step=download to test endpoint: downloads PDFs from 5
  existing orders (9685480-9685484) and stores in MinIO
- step=order now uses enqueueBatch for 2 test parcels (61904, 309952)
  as ONE ePay order instead of separate orders

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 03:20:36 +02:00
AI Assistant 08cd7164cb fix(ancpi): GET CheckoutConfirmationSubmit after EditCartSubmit
EditCartSubmit returns 200 (not redirect) — Angular does client-side
redirect to CheckoutConfirmationSubmit.action. Added this step to
actually confirm the order before looking for the orderId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:49:57 +02:00
AI Assistant 6c60572a3e fix(ancpi): find NEW orderId after submit, track known IDs in queue
submitOrder now captures the previous orderId BEFORE submitting, then
searches for a NEW orderId that isn't in the knownOrderIds set. Queue
passes knownOrderIds between sequential items to prevent duplicate
orderId assignment (unique constraint violation).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:43:21 +02:00
AI Assistant c452bd9fb7 fix(ancpi): use form-data multipart for saveProductMetadataForBasketItem
Angular uses doPostAsFormMultipart — the save endpoint requires
multipart/form-data, not application/x-www-form-urlencoded.
Install form-data package and restore multipart upload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:33:30 +02:00
AI Assistant fd86910ae3 fix(ancpi): remove form-data dependency, use URLSearchParams for save
form-data package not installed — crashes at runtime. Use
URLSearchParams instead (the Angular source uses doPostAsForm
which is form-urlencoded, so this should work too).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:24:45 +02:00
AI Assistant bcb7aeac64 fix(ancpi): accept SAVE_OK as success code from saveMetadata 2026-03-23 02:19:28 +02:00
AI Assistant 7fc46f75bd fix(ancpi): ePay county IDs = WORKSPACE_IDs, UAT IDs = SIRUTA codes
Zero discovery calls needed! ePay internal county IDs are identical
to eTerra WORKSPACE_IDs (CLUJ=127, ALBA=10, etc.) and ePay UAT IDs
are SIRUTA codes (Cluj-Napoca=54975, Florești=57706). Queue now
uses workspacePk + siruta directly from GisUat DB.
Flow: AddToCart → saveMetadata → EditCartSubmit → Poll+Download.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:11:41 +02:00
AI Assistant e13a9351be fix(ancpi): complete rewrite based on Angular source code analysis
All endpoints and payloads verified against epaymentAngularApp.js:
- EpayJsonInterceptor: form-urlencoded (not JSON), uses reqType param
- County IDs: internal ANCPI IDs from judeteNom (NOT 0-41 indices)
- UAT lookup: reqType=nomenclatorUAT&countyId=<internal_ID>
- Save metadata: reqType=saveProductMetadataForBasketItem (multipart)
  with productMetadataJSON using stringValues[] arrays
- SearchEstate: field names are identificator/judet/uat (not identifier/countyId/uatId)
- Download PDF: Content-Type: application/pdf in request header
- Queue resolves county+UAT IDs dynamically via getCountyList+getUatList

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:01:39 +02:00
AI Assistant eb8cd18210 fix(ancpi): use JSON body for EpayJsonInterceptor + EditCartItemJson
Root cause from ePay Angular analysis:
- EpayJsonInterceptor needs Content-Type: application/json + {"judet": N}
- EditCartItemJson needs JSON with bigDecimalValue/stringValue structure
- SearchEstate needs basketId in body for JSON response
- Queue skips SearchEstate (data already from eTerra), uses
  configureCartItem → submitOrder flow directly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:55:30 +02:00
AI Assistant 23bddf6752 feat(ancpi): test EditCartItemJson + SearchEstate with AJAX headers 2026-03-23 01:42:43 +02:00
AI Assistant 665a51d794 feat(ancpi): extract Angular AJAX endpoints from ShowCartItems page 2026-03-23 01:37:45 +02:00
AI Assistant d367b5f736 fix(ancpi): add SearchEstate debug logging, try without uatId, add cart first
SearchEstate might need active cart and/or different headers.
Add X-Requested-With: XMLHttpRequest, make uatId optional, log raw
response (type, length, sample), and add-to-cart before searching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:27:30 +02:00
AI Assistant f92fcfd86b fix(ancpi): test SearchEstate with various uatId values 2026-03-23 01:18:58 +02:00
AI Assistant 0447908007 fix(ancpi): GET login page before POST to establish form tokens
OpenAM requires an initial GET to set session cookies before the
credentials POST. Without it, POST returns 500 and only sets
AMAuthCookie (intermediate) instead of iPlanetDirectoryPro (final SSO).
Then navigate to ePay goto URL to establish JSESSIONID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 01:06:42 +02:00
AI Assistant 887e3f423e fix(ancpi): try HTTP URL for ePay session establishment
OpenAM goto URL is http://epay.ancpi.ro:80 (HTTP, not HTTPS).
Try multiple URL variants to establish JSESSIONID after OpenAM auth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:56:24 +02:00
AI Assistant 04c74c78e4 fix(ancpi): add credit parsing debug logging 2026-03-23 00:43:37 +02:00
AI Assistant e35b50e5c2 fix(ancpi): recognize AMAuthCookie as valid OpenAM session cookie
ANCPI's OpenAM uses AMAuthCookie instead of iPlanetDirectoryPro.
Accept AMAuthCookie, iPlanetDirectoryPro, or JSESSIONID as valid
session indicators. Navigate to ePay after auth to establish JSESSIONID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:38:11 +02:00
AI Assistant b9993f0573 fix(ancpi): follow full redirect chain for OpenAM login, add cookie debug
Let axios follow all redirects (maxRedirects=10) so cookie jar captures
iPlanetDirectoryPro from the chain. Explicitly navigate to ePay after
login to ensure JSESSIONID. Log all cookies for debugging. Last resort:
verify login by checking if credit info is visible on ePay page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:32:30 +02:00
AI Assistant 259f56396b fix(ancpi): use full OpenAM login URL with module + goto params
OpenAM requires module=SelfRegistration and goto= redirect URL.
Also handle 302 manually to capture iPlanetDirectoryPro cookie,
then follow redirect to ePay for JSESSIONID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:27:54 +02:00
AI Assistant b61cd71044 feat(ancpi): add test endpoint for step-by-step ePay verification
GET /api/ancpi/test?step=login|uats|search|order
Temporary diagnostic route to test ePay integration before building UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:20:11 +02:00
AI Assistant 336c46ff8e chore: hardcode ANCPI ePay credentials in docker-compose
Portainer CE can't inject env vars, so credentials must be hardcoded
in the compose file (same pattern as all other secrets in the project).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:18:27 +02:00
AI Assistant 3921852eb5 feat(parcel-sync): add ANCPI ePay CF extract ordering backend
Foundation (Phase 1):
- CfExtract Prisma model with version tracking, expiry, MinIO path
- epay-types.ts: all ePay API response types
- epay-counties.ts: WORKSPACE_ID → ePay county index mapping (42 counties)
- epay-storage.ts: MinIO helpers (bucket, naming, upload, download)
- docker-compose.yml: ANCPI env vars

ePay Client (Phase 2):
- epay-client.ts: full HTTP client (login, credits, cart, search estate,
  submit order, poll status, download PDF) with cookie jar + auto-relogin
- epay-session-store.ts: separate session from eTerra

Queue + API (Phase 3):
- epay-queue.ts: sequential FIFO queue (global cart constraint),
  10-step workflow per order with DB status updates at each step
- POST /api/ancpi/session: connect/disconnect
- POST /api/ancpi/order: create single or bulk orders
- GET /api/ancpi/orders: list all extracts
- GET /api/ancpi/credits: live credit balance
- GET /api/ancpi/download: stream PDF from MinIO

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 00:09:52 +02:00
AI Assistant f6781ab851 feat(parcel-sync): store UAT geometries from LIMITE_UAT in local DB
- Add geometry (Json), areaValue (Float), lastUpdatedDtm (String) to
  GisUat model for local caching of UAT boundaries
- County refresh now fetches LIMITE_UAT with returnGeometry=true and
  stores EsriGeometry rings per UAT in EPSG:3844
- Uses LAST_UPDATED_DTM from eTerra for future incremental sync
- Skips geometry fetch if >50% already have geometry stored

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:14:52 +02:00
AI Assistant 86e43cecae fix(parcel-sync): show 'jud.' prefix before county name in UAT dropdown
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 23:03:05 +02:00
AI Assistant 7b10f1e533 fix(parcel-sync): use verified WORKSPACE_ID → county mapping from eTerra
LIMITE_UAT provides SIRUTA + WORKSPACE_ID for all 3186 UATs across 42
workspaces. eTerra nomenclature APIs all return 404, and immovable list
returns empty for small communes. Use verified workspace→county mapping
derived from eTerra data (cross-referenced sample UATs + DB confirmations).
Logs unknown workspaces if eTerra ever adds new ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:52:46 +02:00
AI Assistant 61a44525bf fix(parcel-sync): resolve county names 100% from eTerra, zero hardcoding
LIMITE_UAT gives SIRUTA + WORKSPACE_ID for all 3186 UATs. For each of
the 42 unique workspaces, fetch 1 immovable via fetchImmovableListBy
AdminUnit — the response includes workspace.name = county name.
No static mappings, no nomenclature endpoints (they 404).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:39:02 +02:00
AI Assistant ce49b9e536 fix(parcel-sync): resolve counties via LIMITE_UAT WORKSPACE_ID + known UAT seats
eTerra nomenclature endpoints (fetchCounties, fetchNomenByPk) return
404. New approach: LIMITE_UAT gives ADMIN_UNIT_ID + WORKSPACE_ID for
all 3186 UATs across 42 workspaces. Use a static mapping of county
seat SIRUTAs to identify which workspace belongs to which county.
Logs unresolved workspaces for debugging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:36:29 +02:00
AI Assistant f9a2f6f82a fix(parcel-sync): use LIMITE_UAT + fetchNomenByPk for county data
fetchCounties() returns 404 — endpoint doesn't exist on eTerra.
New approach: query LIMITE_UAT layer for all features (no geometry)
to discover SIRUTA + WORKSPACE_ID per UAT, then resolve each unique
WORKSPACE_ID to county name via fetchNomenByPk(). Fallback: resolve
county for UATs that already have workspacePk in DB.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:29:56 +02:00
AI Assistant 899b5c4cf7 fix(parcel-sync): populate county data during login, not via PATCH
Root cause: PATCH endpoint created a new EterraClient which tried
to re-login with expired session → 401. Now county refresh runs
immediately after successful login in the session route, using the
same authenticated client (fire-and-forget). Component reloads UAT
data 5s after connection to pick up fresh county info.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:23:46 +02:00
AI Assistant 379e7e4d3f feat(parcel-sync): add diagnostic endpoint for county debugging
GET /api/eterra/uats/test-counties returns raw eTerra nomenclature
response structure — shows exact field names and data format for
fetchCounties() and fetchAdminUnitsByCounty(). Temporary diagnostic
to fix county population issue.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:04:37 +02:00
AI Assistant 8fa89a7675 fix(parcel-sync): restore SIRUTA in dropdown, add county debug output
- Restore SIRUTA code display in parentheses next to UAT name
- PATCH response now includes debug samples (sampleUat keys, county
  raw data) visible in browser console for diagnosing matching issues
- POST endpoint now supports resync (upsert mode, safe to call again)
- Client logs full PATCH result to browser console for debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:34:29 +02:00
AI Assistant 431291c410 fix(parcel-sync): robust county population + local feature count in dropdown
- PATCH /api/eterra/uats: handle nested responses (unwrapArray), try
  multiple field names (extractName/extractCode), log sample UAT for
  debugging, match by code first then by name
- GET /api/eterra/uats: include localFeatures count per SIRUTA via
  GisFeature groupBy query
- Dropdown: show green badge with local feature count, county with dash
- Add SKILLS.md for ParcelSync/eTerra/GIS module context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 21:18:22 +02:00
AI Assistant 79750b2a4a fix(parcel-sync): use eTerra nomenclature API for county population
LIMITE_UAT layer lacks WORKSPACE_ID field, so the previous approach
failed silently. Now uses fetchCounties() + fetchAdminUnitsByCounty()
nomenclature API: Phase 1 fills county for UATs with existing
workspacePk, Phase 2 enumerates counties and matches by name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:56:43 +02:00
AI Assistant 86c39473a5 feat(parcel-sync): show county in UAT search dropdown via eTerra data
PATCH /api/eterra/uats fetches counties from eTerra nomenclature and
LIMITE_UAT layer, then batch-updates GisUat records with county name
and workspacePk. Auto-triggers on first eTerra connection when county
data is missing. Helps distinguish same-name UATs in different counties.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 20:46:13 +02:00
AI Assistant 2a25e4b160 fix(registratura): replace parentheses with en-dash in contact display
"Name – Company" instead of "Name (Company)" for sender/recipient.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 04:15:37 +02:00
AI Assistant c8aee1b58e fix(registratura): remove parentheses from institution-only contacts, add live contact sync
- Fix display format: institutions without a person name no longer show
  as "(Company)" — now shows just "Company"
- Three-case formatting: name+company → "Name (Company)", only company
  → "Company", only name → "Name"
- Registry table now resolves sender/recipient live from address book
  via contactMap — edits in address book reflect immediately in registry
- New contacts created via quick-contact are added to contactMap on the fly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 04:04:17 +02:00
AI Assistant a3ab539197 feat: add read-only /api/projects endpoint for external tools
Returns project tags from tag-manager (category=project).
Supports search (?q=), company filter (?company=), single by ID (?id=).
Same Bearer token auth as address-book API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 01:08:59 +02:00
AI Assistant aab38d909c feat: add dedicated /api/address-book REST endpoint for inter-service access
Bearer token auth (ADDRESSBOOK_API_KEY) for external tools like avizare.
Supports GET (list/search/filter/by-id), POST (create), PUT (update), DELETE.
Middleware exclusion so it bypasses NextAuth session requirement.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:33:00 +02:00
AI Assistant de52b5dced docs: update CLAUDE.md with infrastructure, PDF compression, address book changes
- Two-server architecture: satra (app) + proxy (Traefik)
- Traefik config details (timeouts, dynamic config paths)
- Portainer CE deploy workflow (manual Pull and redeploy)
- PDF compression dual-mode docs (qpdf local + iLovePDF cloud)
- Streaming upload architecture (zero-memory parsing)
- Middleware exclusion pattern for large upload routes
- Address Book flexible contact model (name OR company)
- ContactPerson department field
- Updated module versions and integration table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:23:09 +02:00
AI Assistant 8e2534ebe3 feat: quick contact dialog from registratura supports name OR company
- QuickContactDialog now has Company/Organization field
- Either name or company is required (same logic as address book)
- Auto-sets type to "institution" when only company is provided
- Display name in registry form uses company as fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:10:34 +02:00
AI Assistant 9d73697fb0 feat: address book - require name OR company, add department to contact persons
- Either name or company/organization is now required (not just name)
- When only company is set, it shows as primary display name
- Added department field to ContactPerson sub-entities
- Department shown as badge in card and detail views
- Updated vCard export to handle nameless contacts and department field
- Sort contacts by name or company (whichever is set)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:07:06 +02:00
AI Assistant 87ac81c6c9 fix: encode unicode filenames in Content-Disposition headers
Filenames with Romanian characters (Ș, Ț, etc.) caused ByteString errors.
Also pass original filename through to extreme mode response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:50:37 +02:00
AI Assistant 4b5d3bd498 fix(pdf-compress): bypass middleware body buffering for upload routes
Next.js middleware buffers the entire request body (10MB default limit)
before the route handler runs. middlewareClientMaxBodySize experimental
flag doesn't work reliably with standalone output.

Solution: exclude api/compress-pdf from middleware matcher so the body
streams directly to the route handler. Auth check moved to a shared
helper (auth-check.ts) called at the start of each route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:54:28 +02:00
AI Assistant 003a2821fd fix(pdf-compress): zero-memory multipart parsing + streamed response
Previous approach loaded entire raw body (287MB) into RAM via readFile,
then extracted PDF (another 287MB), then read output (287MB) = ~860MB peak.
Docker container OOM killed silently -> 500.

New approach:
- parse-upload.ts: scan raw file on disk using 64KB buffer reads (findInFile),
  then stream-copy just the PDF portion. Peak memory: ~64KB.
- extreme/route.ts: stream qpdf output directly from disk via Readable.toWeb.
  Never loads result into memory.

Total peak memory: ~64KB + qpdf process memory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:44:06 +02:00
AI Assistant e070aedae5 fix: increase middleware body size limit to 500MB for PDF uploads
Next.js 16 truncates request bodies at 10MB in middleware layer,
causing ECONNRESET for large PDF uploads. Set middlewareClientMaxBodySize
to 500mb to allow large file uploads to reach the route handlers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:23:06 +02:00
AI Assistant f032cf0e4a fix(pdf-compress): replace busboy with manual multipart parsing
Busboy's file event never fires in Next.js Turbopack despite the
stream being read correctly (CJS/ESM interop issue). Replace with
manual boundary parsing: stream body to disk chunk-by-chunk, then
extract the PDF part using simple boundary scanning. Tested working
with 1MB+ payloads — streams to disk so memory usage stays constant
regardless of file size.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:47:37 +02:00
AI Assistant 5a7de39f6a fix(pdf-compress): stream large uploads via busboy instead of arrayBuffer
req.arrayBuffer() fails with 502 on files >100MB because it tries to
buffer the entire body in memory before the route handler runs.

New approach: busboy streams the multipart body directly to a temp file
on disk — never buffers the whole request in memory. Works for any size.

Shared helper: parse-upload.ts (busboy streaming, 500MB limit, fields).
Both local (qpdf) and cloud (iLovePDF) routes refactored to use it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:07:16 +02:00
AI Assistant f5deccd8ea refactor(pdf-compress): replace Ghostscript with qpdf + iLovePDF API
Ghostscript -sDEVICE=pdfwrite fundamentally re-encodes fonts, causing
garbled text regardless of parameters. This cannot be fixed.

New approach:
- Local: qpdf-only lossless structural optimization (5-30% savings,
  zero corruption risk — fonts and images completely untouched)
- Cloud: iLovePDF API integration (auth → start → upload → process →
  download) with 3 levels (recommended/extreme/low), proper image
  recompression without font corruption

Frontend: 3 modes (cloud recommended, cloud extreme, local lossless).
Docker: ILOVEPDF_PUBLIC_KEY env var added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:50:46 +02:00
AI Assistant d75fcb1d1c fix(pdf-compress): remove /screen preset that destroys font encoding
The -dPDFSETTINGS=/screen GS preset overwrites font encoding tables,
producing garbled text in output PDFs. Replace with individual params
that ONLY compress images while preserving fonts intact.

Three quality levels via GS (no Stirling dependency):
- extreme: 100 DPI, QFactor 1.2 (~quality 35)
- high: 150 DPI, QFactor 0.76 (~quality 50)
- balanced: 200 DPI, QFactor 0.4 (~quality 70)

Route all UI modes through the GS endpoint with level parameter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:19:42 +02:00
AI Assistant 9e73dc3cb9 fix(pdf-compress): use arrayBuffer() instead of formData() for large files
formData() fails with "Failed to parse body as FormData" on large PDFs
in Next.js route handlers. Switch to req.arrayBuffer() which reliably
reads the full body, then manually extract the PDF from multipart.

Extreme mode: arrayBuffer + multipart extraction + GS + qpdf pipeline.
Stirling mode: arrayBuffer forwarding to Stirling with proper headers.

Revert serverActions.bodySizeLimit (doesn't apply to route handlers).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:32:05 +02:00
AI Assistant 194ddf0849 fix(pdf-compress): fix broken multipart parsing + add body size limit
Extreme mode: replace fragile manual multipart boundary parsing (which
extracted only a fraction of large files, producing empty PDFs) with
standard req.formData(). Add GS output validation + stderr capture.

Stirling mode: parse formData first then build fresh FormData for
Stirling instead of raw body passthrough (which lost data on large
files). Add 5min timeout + original/compressed size headers.

next.config: add 250MB body size limit for server actions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 16:18:34 +02:00
AI Assistant 81c61d8411 fix(registratura): show expiry/AC alerts regardless of entry status
Status "inchis" means the correspondence is resolved, not that the
document validity stopped mattering. Remove status filters from both
ImminentActions dashboard and detail panel Remindere section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 15:49:48 +02:00
AI Assistant 0cd28de733 refactor(registratura): focus imminent actions on expiry/AC only, remove deadlines
Strip institutional legal deadlines from the dashboard — show only documents
expiring and AC validity within 60-day horizon. Rename to "De reînnoit".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:47:19 +02:00
AI Assistant 8275ed1d95 feat(registratura): add reminders section, expiry helpers, imminent actions dashboard
- Add "Remindere & Alerte" section in entry detail panel showing AC validity,
  expiry date, and tracked deadline status with color-coded indicators
- Add quick expiry date buttons (6/12/24 months from document date) in entry form
- Default dosare view to show only active threads
- Add ImminentActions dashboard component showing urgent items (expired docs,
  AC validity warnings, overdue/imminent deadlines) sorted by urgency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 09:42:54 +02:00
AI Assistant 22eb9a4383 feat(scale): add mm/cm/m/km unit switcher for real dimensions
Scale calculator now supports all 4 real-world units:
- mm, cm, m, km — toggle buttons next to mode selector
- Formula adapts via unit→mm multiplier (mm=1, cm=10, m=1000, km=1M)
- Real→Desen: input in chosen unit, output always mm on drawing
- Desen→Real: output in chosen unit, secondary line shows all other units
- Switching unit clears input to avoid confusion
- step attribute adapts per unit (km=0.001, m=0.01, others=0.5)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:57:28 +02:00
AI Assistant 75a7ab91ca fix(scale)+docs: fix scale calculator units (cm not m), update CLAUDE.md+ROADMAP.md
Scale calculator:
- Real input now in cm (not m) — more natural for architects
- Drawing output in mm (unchanged)
- Formula: drawing_mm = real_cm * 10 / scale (was val/scale, wrong)
- Reverse: real_cm = drawing_mm * scale / 10 (was val*scale, wrong)
- Secondary display: real in m; drawing in cm; reverse also shows mm
- Added 1:20 preset (useful for detail drawings)
- Removed Camere tab (not useful)

Docs:
- CLAUDE.md: update Mini Utilities 0.3.0 + Password Vault 0.4.0 descriptions
- ROADMAP.md: add task 8.04 (this session), bump module versions, renumber 8.05-8.08

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:52:54 +02:00
AI Assistant e583fdecc9 fix(utilities): fix scale calculator logic, remove rooms tab
Scale calculator:
- Real→Desen: val(m) × 1000 ÷ scale = mm pe desen (ex: 5m la 1:100 → 50mm)
- Desen→Real: val(mm) × scale ÷ 1000 = m real (ex: 50mm la 1:100 → 5m)
- Previously both formulas were wrong (missing ×1000/÷1000 unit conversion)

Remove RoomAreaCalculator (not useful) and its tab/imports

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 22:38:23 +02:00
AI Assistant 06b3a820de feat(utilities+vault): TVA configurable rate, scale/rooms calculators, multi-user vault
Mini Utilities:
- TVA calculator: rate now configurable (5%/9%/19%/21% presets + custom input)
  replaces hardcoded 19% constant; displays effective rate in results
- New tab: Calculator scară desen — real↔drawing conversion with 6 scale presets
  (1:50..1:5000) + custom, shows cm equivalent for drawing→real
- New tab: Calculator suprafețe camere — multi-room area accumulator
  (name + W×L×H), live m²/m³ per room, running total, copy-all

Password Vault:
- New VaultUser type: { username, password, email?, notes? }
- VaultEntry.additionalUsers: VaultUser[] — backward compat (defaults to [])
- VaultForm: collapsible "Utilizatori suplimentari" section, add/remove rows
- Card list: badge showing count of additional users when present
- useVault: normalizes legacy entries (additionalUsers ?? [])

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 20:51:34 +02:00
AI Assistant b0f27053ae feat(registratura): AC validity 12/24 months + reminder config in Ghid termene
- Add ACValidityPeriod type (12 | 24) and validityMonths field to ACValidityTracking
- Replace hardcoded 12-month validity with configurable dropdown (12/24 luni)
- Update computed dates, reminder counter, and tooltip to use selected period
- Add "Configurare remindere si alerte" section in Ghid termene with:
  - Threshold table (urgent 5z, depasit 0z, CU alert 30z, AC monthly, prelungire 45z lucr, anuntare 10z cal, transmitere 1z)
  - Pause/resume explanation for clarification requests
- Update AC expiry description to mention configurable 12/24 month validity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:38:15 +02:00
AI Assistant 55c807dd1b feat: inline resolve for sub-deadlines + milestone date tooltips
- DeadlineTimeline gains onResolveInline callback prop
- Milestone labels show small green checkmark button for resolvable items
- Clicking opens inline text input (motiv + OK/Cancel, Enter/Escape)
- RegistryEntryDetail wires resolve via onResolveDeadline prop
- Milestone date labels show "Data maximă: ..." on hover
- Auto-refreshes viewed entry after inline resolve

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:27:03 +02:00
AI Assistant 0f928b08e9 fix: dashboard stats exclude closed entries + auto-tracked deadlines
- aggregateDeadlines() now skips entries with status "inchis"
- Auto-tracked/background deadlines excluded from active/urgent/overdue counts
- Only user-created deadlines affect badge numbers
- Milestone dots vertically centered on progress bar (top-[5px], h-2.5)
- Milestone tooltips now show full date ("Data maximă: 15 februarie 2026")
- Countdown text shows date on hover too

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 19:13:49 +02:00
AI Assistant c892e8d820 feat: deadline pause/resume on clarifications + enhanced timeline UX
- TrackedDeadline gains pausedAt + totalPausedDays fields
- pauseDeadline() / resumeDeadline() in deadline-service with audit log
- Auto-pause: when incoming conex linked to parent with active deadlines
- Auto-resume: when outgoing conex linked to parent with paused deadlines
  (shifts dueDate forward by paused days)
- Timeline shows "Suspendat" state with blue pulsing progress bar
- Milestone tooltips now show exact dates (hover: "Data maximă: ...")
- ISC warning text on expired emission deadlines
- Verification expired text matches PDF spec
- DeadlineAuditEntry gains "paused" | "resumed" action types
- getDeadlineDisplayStatus returns "Suspendat" (blue) for paused deadlines

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:54:19 +02:00
AI Assistant 1361534c98 fix: closed entries no longer show in deadline dashboard
- groupDeadlinesByEntry skips entries with status "inchis"
- closeEntry auto-resolves all pending deadlines on close (main + linked)
- Fixes S-2026-00001 showing as overdue despite being closed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 17:26:20 +02:00
AI Assistant d8a10fadc0 fix: React error #310 — useMemo after early return in detail panel
Move threadChain useMemo before the `if (!entry) return null` early
return to keep hook call order stable between renders. When entry was
null, the hook was skipped, causing "Rendered more hooks than during
the previous render" crash on subsequent renders with entry set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:23:15 +02:00
AI Assistant 0cc14a96e9 feat: milestone dots on dashboard progress bar with legend
- Progress bar now shows auto-tracked sub-deadline milestones as dots
- Passed milestones: grayed out dot + strikethrough label
- Active milestones: amber dot with descriptive label
- Each milestone has a tooltip with context-aware text (e.g.,
  "Verificare expirata — nu se mai pot solicita clarificari")
- Legend below progress bar shows all milestone labels
- Wider progress bar (w-32) to accommodate milestone dots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:12:26 +02:00
AI Assistant f6fc63a40c feat: add deadline system guide/overview page (Ghid termene)
- New DeadlineConfigOverview component: read-only reference page showing all
  18 deadline types organized by category with chain flow diagrams
- Accessible via "Ghid termene" toggle in the "Termene legale" tab
- Shows: summary stats, color legend, collapsible category sections,
  chain flow diagrams (fan-in + sequential), notification overview,
  document expiry settings, and legal reference index
- All data derived from existing catalog (zero API calls)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 00:10:23 +02:00
AI Assistant c5112dbb3d feat: timeline milestones for deadlines, auto-close reply entries, cleanup
- Entry created via "Inchide" flow now gets status "inchis" with closureInfo
- New DeadlineTimeline component: main deadlines as cards with progress bar,
  auto-tracked sub-deadlines as milestone dots on horizontal timeline
- Auto-tracked deadlines hidden from dashboard when user deadlines exist
- Verification milestone shows "Expirat — nu se mai pot solicita clarificari"
- Parent closureInfo now includes linkedEntryId/Number of the closing act
- Removed orphaned deadline-table.tsx and use-deadline-filters.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:51:27 +02:00
AI Assistant 5b18cce5a3 feat: simplify deadline dashboard + add flow diagrams for document chains
Major UX overhaul of the "Termene legale" and thread tabs:

Deadline dashboard:
- Replace 6 KPI cards with simple summary bar (active/urgent/overdue)
- Replace flat table with grouped list by entry (cards with progress bars)
- Chain deadlines collapsed by default with expand toggle
- Auto-tracked/background deadlines hidden from main list

Flow diagram (new component):
- CSS-only horizontal flow diagram showing document chains
- Nodes with direction bar (blue=intrat, orange=iesit), number, subject, status
- Solid arrows for thread links, dashed for conex/linked entries
- Used in both "Dosare" tab (full) and detail panel (compact, max 5 nodes)

Thread explorer → Dosare:
- Renamed tab "Fire conversatie" → "Dosare"
- Each thread shown as a card with flow diagram inside
- Simplified stats (just active/finalized count)

Background tracking:
- comunicare-aviz-beneficiar marked as backgroundOnly (not shown in dashboard)
- Transmission status computed and shown in detail panel (on-time/late)

Auto-resolution:
- When closing entry via reply, matching parent deadlines auto-resolve
- Resolution note includes the reply entry number

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 23:25:08 +02:00
AI Assistant 34024404a5 fix: prevent deleting registry entries that would create sequence gaps
Only the last entry in a company+year sequence can be deleted. Trying
to delete an earlier number (e.g. #2 when #3 exists) returns a 409
error with a Romanian message explaining the restriction.

Also routes UI deletes through the API (like create/update) so they
get proper audit logging and sequence recalculation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:38:51 +02:00
AI Assistant 5cb438ef67 fix: JSONB space-after-colon in all registry LIKE/regex patterns
PostgreSQL JSONB value::text serializes JSON with spaces after colons
("number": "B-2026-00001") but all LIKE patterns searched for the
no-space format ("number":"B-2026-00001"), causing zero matches and
every new entry getting sequence #1.

Fixed in allocateSequenceNumber, recalculateSequence, and debug-sequences.
Added PATCH handler to migrate old-format entries (BTG/SDT/USW/GRP)
to new single-letter format (B/S/U/G).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:22:33 +02:00
AI Assistant 4b61d07ffd debug: add raw value snippet to debug-sequences for regex diagnostics
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 22:04:25 +02:00
AI Assistant 39d64b033e fix: replace \d with [0-9] in all PostgreSQL regex patterns
PostgreSQL POSIX regex on the server does not support \d shorthand,
causing SUBSTRING to return NULL and every entry to get sequence 1.

Replaced all \d with [0-9] in:
- allocateSequenceNumber (new + old format queries)
- recalculateSequence (new + old format queries)
- debug-sequences endpoint (GET + POST queries)

Also added samples field to debug GET for raw number diagnostics,
and POST now handles old-format entries (BTG→B mapping) with
ON CONFLICT GREATEST for proper counter merging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:59:04 +02:00
AI Assistant 0f555c55ee feat: simplify registry number format to B-2026-00001
New format: single-letter prefix + year + 5-digit sequence.
No direction code (IN/OUT) in the number — shown via arrow icon.
Sequence is shared across directions within the same company+year.

Changes:
- REGISTRY_COMPANY_PREFIX: BTG→B, USW→U, SDT→S, GRP→G
- OLD_COMPANY_PREFIX map for backward compat with existing entries
- allocateSequenceNumber: searches both old and new format entries
  to find the actual max sequence (backward compat)
- recalculateSequence: same dual-format search
- parseRegistryNumber: supports 3 formats (current, v1, legacy)
- isNewFormat: updated regex for B-2026-00001
- CompactNumber: already used single-letter badges, just updated comment
- debug-sequences endpoint: updated for new format
- Notification test data: updated to new format
- RegistrySequence.type: now "SEQ" (shared) instead of "IN"/"OUT"

After deploy: POST /api/registratura/debug-sequences to clean up
old counters, then recreate test entries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:49:35 +02:00
AI Assistant eb39024548 fix: debug-sequences endpoint regex escaping with $queryRawUnsafe
Prisma tagged template literals were mangling regex backslashes.
Switch to $queryRawUnsafe for the complex regex queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:38:40 +02:00
AI Assistant f7190bb98e perf: upgrade to Node 22 + BuildKit cache mount for faster Docker builds
- Node 22 (from 20): ~15% faster TypeScript compilation
- BuildKit cache mount on /root/.npm: npm ci reuses cached packages
  between builds instead of re-downloading from registry
- NODE_OPTIONS --max-old-space-size=2048: prevents OOM on VMs with
  limited RAM during Next.js build

Requires BuildKit enabled (default in Docker 23+, or set
DOCKER_BUILDKIT=1). Portainer CE uses BuildKit by default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:23:56 +02:00
AI Assistant 46de088423 fix: use only actual entries as source of truth for registry numbers
The previous fix still used MAX(actualMax, counterVal) which meant a
stale counter (from entries deleted before the fix was deployed) would
override the actual entry count. Changed to use ONLY actualMax + 1.

The RegistrySequence counter is now just a cache that gets synced —
it never overrides the actual entries count.

Also added /api/registratura/debug-sequences endpoint:
- GET: shows all counters vs actual entry max (for diagnostics)
- POST: resets all counters to match actual entries (one-time fix)

After deploy, call POST /api/registratura/debug-sequences to reset
the stale counters, then delete the BTG-2026-OUT-00004 entry and
recreate it — it will get 00001.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:20:58 +02:00
AI Assistant 28bb395b06 perf: optimize Docker build for faster deployments
- Move prisma generate before COPY . . so it's cached when only
  source files change (saves ~10-15s per build)
- Use npm ci --ignore-scripts in deps stage (postinstall scripts
  not needed, prisma generate runs explicitly)
- Combine apk add + addgroup + adduser into single RUN (fewer layers)
- Expand .dockerignore to exclude .claude/, .vscode/, logs, tsbuildinfo

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:15:41 +02:00
AI Assistant dbed7105b7 fix: bulletproof registry number allocation using actual DB entries as source of truth
The old allocateSequenceNumber blindly incremented a counter in
RegistrySequence, which drifted out of sync when entries were deleted
or moved between companies — producing wrong numbers (e.g., #6 for
the first entry of a company).

New approach:
- Uses pg_advisory_xact_lock inside a Prisma interactive transaction
  to serialize concurrent allocations
- Always queries the actual MAX sequence from KeyValueStore entries
  (the source of truth) before allocating the next number
- Takes MAX(actual entries, counter) + 1 so the counter can never
  produce a stale/duplicate number
- Upserts the counter to the new value for consistency
- Also adds recalculateSequence to DELETE handler so the counter
  stays in sync after entry deletions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 21:11:52 +02:00
AI Assistant 8e56aa7b89 fix: detail panel scroll and missing TooltipProvider in status badge
- Add min-h-0 + overflow-hidden on ScrollArea to enable scrolling
  in the detail side panel (flex child needs bounded height)
- Wrap external status badge Tooltip in TooltipProvider to fix
  "Tooltip must be used within TooltipProvider" runtime crash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:55:15 +02:00
AI Assistant 2739c6af6f fix: extreme PDF compression producing empty output on large files
The multipart body parser was using the first \r\n\r\n as the file
content start, but this could miss the actual file part. Now properly
iterates through parts to find the one with filename= header, and
uses lastIndexOf for the closing boundary to avoid false matches
inside PDF binary data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:48:36 +02:00
AI Assistant d7bd1a7f5d feat: external status monitor for registratura (Primaria Cluj-Napoca)
- Add ExternalStatusTracking types + ExternalDocStatus semantic states
- Authority catalog with Primaria Cluj-Napoca (POST scraper + HTML parser)
- Status check service: batch + single entry, change detection via hash
- API routes: cron-triggered batch (/api/registratura/status-check) +
  user-triggered single (/api/registratura/status-check/single)
- Add "status-change" notification type with instant email on change
- Table badge: Radio icon color-coded by status (amber/blue/green/red)
- Detail panel: full monitoring section with status, history, manual check
- Auto-detection: prompt when recipient matches known authority
- Activation dialog: configure petitioner name + confirm registration data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:42:21 +02:00
AI Assistant 1c51236c31 fix: registry number re-allocation on company change + extreme PDF large file support
- Registratura: re-allocate number when company/direction changes on update,
  recalculate old company's sequence counter from actual entries
- Extreme PDF: stream body to temp file instead of req.formData() to support large files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 14:05:13 +02:00
AI Assistant ed504bd1de fix: stream PDF body to Stirling to avoid FormData parse failure on large files
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 13:48:05 +02:00
AI Assistant 0958238b25 Update docs: compact numbers, Alerte Termene sender, notifications in repo structure
- CLAUDE.md: add compact registry numbers feature, sender name, test mode
- ROADMAP.md: expand 8.03 with compact numbers, icon-only toolbar, test mode
- REPO-STRUCTURE.md: add src/core/notifications/ directory + description
- SYSTEM-ARCHITECTURE.md: add sender name, test mode, group company behavior
- CONFIGURATION.md + DOCKER-DEPLOYMENT.md: NOTIFICATION_FROM_NAME=Alerte Termene

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:14:39 +02:00
AI Assistant dff0bbe97c Shorten company badges to single letters and remove intern direction
Company badges: US→U, SDT→S (B and G already single letter).
Direction: only intrat (↓ green) and iesit (↑ orange), removed intern since no longer used.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:10:50 +02:00
AI Assistant 98c6fcb619 ui: compact registry numbers with company badge + direction icon
Replace full "BTG-0042/2026" with compact [B] ↓ 0042/2026 format:
- Colored company badge (B=blue, US=violet, SDT=green, G=gray)
- Direction arrow icon (↓ green=intrat, ↑ orange=iesit, ↔ gray=intern)
- Plain number without prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 09:07:12 +02:00
AI Assistant b079683a46 ui: rename email sender and subject to 'Alerte Termene' instead of ArchiTools
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:59:19 +02:00
AI Assistant f10a112de6 ui: make toolbar buttons icon-only with title tooltip (Bune practici, Notificari)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:52:12 +02:00
AI Assistant 9d58f1b705 feat: add test digest mode (?test=true) + group company sees all entries
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:47:17 +02:00
AI Assistant 479afb1039 fix: exclude /api/notifications/digest from auth middleware (N8N cron uses Bearer token)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:34:23 +02:00
AI Assistant d07d8a8381 fix: replace em dash with ASCII dash in email subject to fix SMTP header error
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:29:52 +02:00
AI Assistant 1cbdf13145 chore: add Brevo SMTP credentials and cron secret to docker-compose
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 08:22:06 +02:00
AI Assistant 974d06fff8 feat: add email notification system (Brevo SMTP + N8N daily digest)
- Add core notification service: types, email-service (nodemailer/Brevo SMTP), notification-service (digest builder, preference CRUD, HTML renderer)
- Add API routes: POST /api/notifications/digest (N8N cron, Bearer auth), GET/PUT /api/notifications/preferences (session auth)
- Add NotificationPreferences UI component (Bell button + dialog with per-type toggles) in Registratura toolbar
- Add 7 Brevo SMTP env vars to docker-compose.yml
- Update CLAUDE.md, ROADMAP.md, DATA-MODEL.md, SYSTEM-ARCHITECTURE.md, CONFIGURATION.md, DOCKER-DEPLOYMENT.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 01:12:36 +02:00
AI Assistant 6941074106 fix: copy button uses plain number format (nr. 42 din 11.03.2026)
Strips company prefix and leading zeros from registry number.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:37:53 +02:00
AI Assistant 8e9753fd29 feat: add copy button next to registry number in table
Copies "nr. BTG-0042/2026 din 11.03.2026" to clipboard on click.
Small icon, subtle until hover, green check feedback on copy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:35:12 +02:00
AI Assistant 7094114c36 fix: hooks order violation in DeadlineResolveDialog causing crash
useMemo was called after early return (when deadline=null), violating
React Rules of Hooks. Moved all hooks before the conditional return.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:30:38 +02:00
AI Assistant 959590acfe feat: add Convocare CTATU document type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:21:36 +02:00
AI Assistant 1c5ad7c988 feat: restructure Completari + rename Contestatie → Litigii + remove ac-prelungire
- Remove ac-prelungire backward deadline (redundant with AC validity tracker)
- Completari: now 2 beneficiary reminders (L350=60z, L50=3luni) instead of 4 mixed entries
- Rename contestatie → litigii ("Litigii / Sanctiuni / Contestatii")
- Add new litigii deadlines: prescriptie contraventie (3 ani), plangere PV (15z), CNSC (10z)
- Update existing: plangere prealabila, actiune instanta, atacare urbanism labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-11 00:08:24 +02:00
AI Assistant a2b9ff75b5 feat(registratura): restructure Autorizare deadlines — no tacit approval
- ac-verificare (5z lucr.) now auto-track, created automatically with
  any AC emitere type. Informational: authority notifies if incomplete.
- ac-emitere (30z cal.) now chains to ac-emitere-dupa-completari when
  interrupted — term recalculates from completion submission date.
- ac-emitere-urgenta (7z lucr.) and ac-emitere-anexe (15z cal.) kept.
- New: ac-prelungire-emitere (15z lucr.) — authority communicates
  decision on AC extension within 15 working days.
- Info box in DeadlineAddDialog for autorizare category explaining
  auto-tracked verification + interruption mechanism.
- None of the autorizare deadlines have tacit approval.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:55:39 +02:00
AI Assistant a96dce56a2 feat(registratura): revert intrat full categories + add urbanism deadlines
- Revert: only iesit+cerere/aviz gets full permitting categories
- Urbanism: furnizare date retele (15z cal, doar autoritati publice)
- Urbanism: aviz oportunitate PUZ — verificare (30z) + convocare CTATU
  (30z, chain) + emitere dupa comisie (15z, auto-track)
- Urbanism: aviz arhitect sef PUD/PUZ — convocare CTATU (30z, chain)
  + emitere (15z, auto-track)
- Urbanism: promovare CL (30z) + vot CL (45z dupa dezbatere publica)
- None of the urbanism deadlines have tacit approval

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:45:25 +02:00
AI Assistant 8bcb0bcc81 fix: show all permitting deadline categories for cerere/aviz regardless of direction
Previously DIRECTION_CATEGORIES limited intrat to only completari+contestatie,
so cerere/aviz on intrat direction lost certificat/avize/urbanism/autorizare.
Now cerere/aviz doc types unlock all 6 categories on both directions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:24:02 +02:00
AI Assistant 4467e70973 fix: tooltip {proiect} hint visibility — use amber-300 on dark tooltip bg
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 23:00:10 +02:00
AI Assistant f50ad5e020 feat(registratura): auto-detect {proiect} placeholder in subject and switch to template mode
Typing {proiect}, {nr}, {an}, {detalii} or {text} in the subject field
now auto-transforms to template mode with the appropriate input widgets
(project dropdown, number fields, etc.).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:52:31 +02:00
AI Assistant 442a1565fd feat: avize deadline restructure with interruption mechanism + comisie toggle
- Add "Necesita analiza in comisie" toggle for avize category (mirrors CJ toggle)
  - When OFF: auto-creates 5-day working limit for completari requests
  - When ON: no limit (institution can request completions anytime)
- Add interruption mechanism: resolve aviz as "intrerupt" when institution
  requests completions → auto-creates new 15-day deadline from completions date
- New resolution type "intrerupt" with yellow badge + chain support
- Restructure avize catalog entries:
  - aviz-ac-15 (L50) and aviz-urbanism-30 (L350) now have chain to
    aviz-emitere-dupa-completari for interruption flow
  - aviz-mediu: updated hints about procedure closure prerequisite
  - aviz-cultura-comisie: 2-phase with auto-track depunere la comisie (30 days)
  - aeronautica, ISU, transport-eu: all get interruption chain
- 3 new auto-track entries: aviz-completari-limit (5zl), aviz-emitere-dupa-completari
  (15zc), aviz-cultura-depunere-comisie (30zc)
- New document type: "Convocare sedinta"
- Info boxes in dialog explaining auto-track behavior + interruption mechanism

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:41:14 +02:00
AI Assistant 31565b418a fix: doc type persistence on edit + filter deadlines by document type
- Fix doc type showing "altele" after edit: preserve initial documentType
  in allDocTypes map even if not in defaults or Tag Manager
- Filter deadline categories by document type: only cerere/aviz unlock
  full permitting categories (CU, avize, urbanism, autorizare)
- Other doc types (scrisoare, notificare, etc.) only get completari +
  contestatie as deadline categories
- Add completari to intrat direction (was missing)
- Pass documentType to DeadlineAddDialog for category filtering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:58:06 +02:00
AI Assistant 4ac4a48cad fix: differentiate Conex (linkedEntryIds) vs Inchide (threadParentId) semantics
- Conex button now adds to linkedEntryIds (for clarificari/solicitari)
  instead of setting threadParentId
- Inchide button sets threadParentId (direct reply) + auto-closes parent
- Fix Sterge button persistence bug: threadParentId now saves as empty
  string instead of undefined (which was stripped in JSON serialization)
- Card headers: "Conex cu X" (amber) vs "Raspuns la X" (blue + green)
- Add conexTo prop to RegistryEntryForm for linked entry pre-fill

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:29:56 +02:00
AI Assistant 85077251f3 feat(registratura): fix thread clear, close via conex entry
- Fix threadParentId clear: always show "Sterge" button even when parent not found in allEntries
- Bigger clear button with text label instead of tiny X icon
- Fallback display when parent not in current list (shows truncated ID)
- Close via conex: table/detail "Inchide" now creates a new reply entry that closes the original
- Header shows "Conex la BTG-XXX" + "Inchide originala" badge when closing via conex
- After saving the new conex entry, parent is auto-closed with resolution "finalizat"
- onClose signature changed from (id: string) to (entry: RegistryEntry) across table + detail

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:15:40 +02:00
AI Assistant f5e19ce3d1 feat(registratura): add Conex (reply) + Inchide buttons, reorder completari last
- Conex button on table rows (Reply icon, blue) — opens new entry with threadParentId pre-set + flipped direction
- Conex button on detail panel — same behavior
- Inchide button on table rows (CheckCircle2 icon, green) — only for open entries
- replyTo prop on RegistryEntryForm: pre-sets threadParentId + direction flip (intrat→iesit, iesit→intrat)
- Card header shows "Conex la BTG-0042/2026" with blue badge when replying
- Completari moved to last position in deadline category order

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 20:40:12 +02:00
AI Assistant f01fe47af4 feat(registratura): remove publicitate category, auto-track comunicare, late receipt badge, AC validity conditional
- Remove publicitate/comunicare category entirely (AC publicity handled by AC validity tracker)
- comunicare-aviz-beneficiar moved to auto-track: created alongside any iesit deadline
- Late receipt badge on incoming aviz entries: shows "Primit cu X zile intarziere" when document date < today
- Valabilitate document + AC Validity Tracker visible only when documentType is "aviz" (act administrativ)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 19:26:05 +02:00
AI Assistant b2519a3b9c feat(registratura): redesign CU deadline tracking — direction filtering, CJ toggle, auto-track, verification badge
- CU has NO tacit approval on any entry
- Direction-dependent categories: iesiri (CU, Avize, Completari, Urbanism, Autorizare), intrari (Contestatie)
- Rename: Analiza → Urbanism (PUD/PUZ/PUG), Autorizare (AC) → Autorizare (AD/AC)
- Auto-track deadlines: cu-verificare (10zl) created automatically with CU emitere
- CJ toggle: auto-creates arhitect-sef solicita aviz (3zc) + primar emite aviz (5zc)
- Verification badge: after 10 days shows "Nu mai pot returna documentatia"
- Prelungire helper: CU issue date + 6/12/24 month calculator
- cu-prelungire-emitere changed to 30zc (practica administrativa)
- New DeadlineTypeDef fields: autoTrack, directionFilter

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 18:58:50 +02:00
AI Assistant f5ffce2e23 feat(registratura): restructure legal deadline catalog — 35 deadlines from Legea 50/1991 & 350/2001
Complete rewrite of deadline-catalog.ts based on comprehensive legislative extracts:

Certificat de Urbanism (6): verificare 10zl, emitere L50 15zl, emitere L350 30zc,
  suport tehnic 10zl, prelungire depunere 15zc backward, prelungire emitere 15zc

Avize (15): AC standard 15zc, urbanism 30zc, Mediu 15zc, Cultura comisie 30zc,
  Min.Culturii 30zl, Aeronautica 30zc, ISU 15zc, transport EU 10zc, comisie
  agenda 30zc, comisie emitere 15zc, oportunitate analiza/emitere (fara tacit!),
  reconfirmare 5zl, primar 5zc, monument fara AC 30zc

Completari (4): notificare 5zl, beneficiar 60zc, emitere 15zc, AC beneficiar 90zc

Autorizare (5): verificare 5zl, emitere 30zc, urgenta 7zl, agricol 15zc,
  prelungire 45zl backward

Publicitate (2): AC 30zc, comunicare aviz 1zc

Contestatie (4): plangere prealabila 30zc, contestare AC 60zc,
  contestare urbanism 5 ani, plangere contraventionala 15zc

Each deadline now includes legalReference field displayed in the dialog.
Dialog shows legal reference, scroll for long lists, contestatie category added.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:24:02 +02:00
AI Assistant eb7c28ca14 feat(registratura): smart defaults per direction, new doc types, expanded cerere templates
- Default doc type: aviz (intrat) / cerere (iesit); auto-switches on direction toggle
- New default doc types: Proces verbal, Notificare, Comunicare (with full seed templates)
- Cerere templates rewritten: emiterea CU/AC, prelungirea valabilitatii, completare
  documentatie, indreptarea erorilor materiale, inaintare dispozitie de santier,
  eliberarea certificatului, aviz, racordare
- Aviz label renamed to "Aviz / Act administrativ"
- Scrisoare label renamed to "Scrisoare / Adresa", raport to "Raport / Studiu"
- Moved PV/notificare/comunicare templates from scrisoare/altele to their own types
- Cleaned up duplicate templates across categories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 14:02:46 +02:00
AI Assistant b62e01b153 feat(registratura): expand seed templates — memoriu justificativ, comunicare, deviz, borderou
- Add Comunicare, Instiintare under scrisoare
- Add Memoriu justificativ, Studiu de fezabilitate, Audit energetic, Caiet de sarcini under raport
- Add Aviz racordare, Acord unic under aviz
- Add Contract proiectare, PV predare-primire under contract
- Add Borderou, Fisa tehnica, Tema de proiectare, Deviz general, Conventie under altele
- Total seed templates: ~55 across 11 document types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:31:36 +02:00
AI Assistant 8cec9646c3 feat(registratura): add administrative acts seed templates (CU, AC, avize concrete)
- Add CU/AC/Prelungire CU/AC templates under aviz type for received acts
- Add Aviz ISU/DSP/Mediu/APM concrete templates
- Add PV receptie, Proces verbal, Referat verificare, Expertiza tehnica, RTE, Memoriu tehnic
- Add Cerere completari, Raspuns completari, Somatie templates
- Update dynamic placeholders for intrat (CU/AC examples) and iesit (Cerere CU)
- Update tooltip examples: intrat shows CU/AC/Aviz, iesit shows Cerere/Solicitare

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:05:45 +02:00
AI Assistant e30b437dce feat(registratura): smart subject autocomplete v2 — seed templates, project linking, dynamic placeholders
- Add SEED_TEMPLATES catalog (11 doc types x 2-4 templates = ~30 predefined patterns)
- Add {proiect} field type with mini-autocomplete from Tag Manager projects
- Pre-fill {an} fields with current year on template selection
- Dynamic placeholder changes based on documentType + direction (22 combinations)
- Dropdown on empty focus: "Sabloane recomandate" + "Recente" sections
- Direction-aware tooltip on Subiect field (intrat vs iesit examples)
- getRecommendedTemplates() merges seeds + DB-learned, DB takes priority

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 12:46:05 +02:00
AI Assistant 3a3db3f366 fix(registratura): lower subject template min length from 8 to 3 chars
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:48:34 +02:00
AI Assistant b3b585e7c8 feat(registratura): subject autocomplete with inline template fields
- New subject-template-service: extracts reusable templates from existing
  subjects by detecting variable parts (numbers, years, text after separators)
- Template input component: inline editable fields within static text
  (e.g., "Cerere CU nr. [___]/[____] — [___________]")
- Two-tier autocomplete dropdown: templates sorted by frequency (top) +
  matching existing subjects (bottom)
- Learns from database: more entries = better suggestions
- Follows existing contact autocomplete pattern (focus/blur, onMouseDown)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:40:37 +02:00
AI Assistant eb96af3e4b feat(registratura): add best practices popover + contextual tooltips on form fields
- "Bune practici" button in registry header opens a popover with internal rules
  (numerotare, completare, termene, atașamente, închidere)
- Info tooltips on form labels: Direcție, Tip document, Subiect, Expeditor,
  Destinatar, Atașamente (consistent with existing pattern on Data document)
- Install shadcn/ui Popover component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:21:54 +02:00
AI Assistant 6786ac07d1 fix(registratura): remove intern direction — only intrat/iesit are valid
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 08:01:29 +02:00
AI Assistant a0dd35a066 feat(registratura): atomic numbering, reserved slots, audit trail, API endpoints + theme toggle animation
Registratura module:
- Atomic sequence numbering (BTG-2026-IN-00125 format) via PostgreSQL upsert
- Reserved monthly slots (2/company/month) for late registrations
- Append-only audit trail with diff tracking
- REST API: /api/registratura (CRUD), /api/registratura/reserved, /api/registratura/audit
- Auth: NextAuth session + Bearer API key support
- New "intern" direction type with UI support (form, filters, table, detail panel)
- Prisma models: RegistrySequence, RegistryAudit

Theme toggle:
- SVG mask-based sun/moon morph with 360° spin animation
- Inverted logic (sun in dark mode, moon in light mode)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 07:54:32 +02:00
AI Assistant f94529c380 Merge branch 'claude/elastic-chaplygin' 2026-03-09 13:16:45 +02:00
AI Assistant 179dc306bb fix(auth): replace client-side signin page with server-side route handler
The client page rendered inside AppShell layout, causing a flash of the
full app UI before redirecting to Authentik. The new route handler
initiates the OAuth flow server-side (CSRF token + POST to NextAuth
provider signin) and redirects instantly — no visible page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 13:16:45 +02:00
AI Assistant f1ab165139 Merge branch 'claude/elastic-chaplygin' 2026-03-09 12:48:03 +02:00
AI Assistant acfec5abe5 fix(auth): move loading check after hooks to fix Rules of Hooks violation
Early return before useCallback/useMemo caused React error #310
(different hook count between renders). Loading spinner now renders
after all hooks are called unconditionally.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:48:03 +02:00
AI Assistant c09598f93d Merge branch 'claude/elastic-chaplygin' 2026-03-09 12:39:57 +02:00
AI Assistant bb3673b4aa fix(auth): correct callbackUrl and auto-redirect to Authentik
- Use NEXTAUTH_URL instead of request.url for callbackUrl (was 0.0.0.0:3000)
- Add custom /auth/signin page that auto-calls signIn("authentik")
- Skip the intermediate "Sign in with Authentik" button page
- Exclude /auth/signin from middleware matcher

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 12:39:50 +02:00
207 changed files with 38868 additions and 7772 deletions
+5
View File
@@ -10,3 +10,8 @@ docs/
legacy/
dwg2dxf-api/
.DS_Store
.claude/
.vscode/
.idea/
*.log
*.tsbuildinfo
+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
+197 -344
View File
@@ -1,344 +1,197 @@
# 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
```
---
## 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 an on-premise Ubuntu server at `10.10.10.166`, containerized with Docker, managed via Portainer, served by Nginx Proxy Manager.
### 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 |
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
| Deploy | Docker multi-stage, Portainer CE, Nginx Proxy Manager |
| Repo | Gitea at `https://git.beletage.ro/gitadmin/ArchiTools` |
| Language | Code in **English**, UI in **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
---
## Repository Structure
```
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
```
---
## Implemented Modules (16 total — 14 original + 2 new)
| # | 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.4.0 | CRUD registry, dynamic doc types, bidirectional Address Book, threads, backdating, **legal deadline tracking**, 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) |
| 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.1.1 | CRUD contacts, card grid, vCard export, Registratura reverse lookup, **dynamic types (creatable)**, **alphabetically sorted type dropdown** |
| 8 | **Password Vault** | `/password-vault` | 0.3.0 | CRUD credentials, 9 categorii cu iconițe, **WiFi QR code real**, context-aware form, strength meter, company scope, **AES-256-GCM encryption** |
| 9 | **Mini Utilities** | `/mini-utilities` | 0.2.0 | Text case, char counter, percentage, **TVA calculator (19%)**, area converter, U→R, num→text, artifact cleaner, MDLPA, **extreme PDF compression (GS+qpdf)**, PDF unlock, **DWG→DXF**, OCR, color palette, **paste on all drop zones**, **thermal drag-drop reorder** |
| 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.5.0 | eTerra ANCPI integration, **PostGIS database**, background sync, 23-layer catalog, enrichment pipeline, owner search, **per-UAT analytics dashboard**, **health check + maintenance detection** |
| 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 |
### Registratura — Legal Deadline Tracking (Termene Legale)
The Registratura module includes a full legal deadline tracking engine for Romanian construction permitting:
- **16 deadline types** across 5 categories (Avize, Completări, Analiză, Autorizare, Publicitate)
- **Working days vs calendar days** with Romanian public holiday support (including Orthodox Easter via Meeus algorithm)
- **Backward deadlines** (e.g., AC extension: 45 working days BEFORE expiry)
- **Chain deadlines** (resolving one prompts adding the next)
- **Tacit approval** (auto-detected when overdue + applicable type)
- **Tabbed UI**: "Registru" tab (existing registry) + "Termene legale" tab (deadline dashboard)
Key files:
- `services/working-days.ts` — Romanian holidays, `addWorkingDays()`, `isWorkingDay()`
- `services/deadline-catalog.ts` — 16 `DeadlineTypeDef` entries
- `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)
### 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
- **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
- `components/parcel-sync-module.tsx` — Main UI (~4100 lines), 4 tabs (Export/Layers/Search/DB)
- `components/uat-dashboard.tsx` — Per-UAT analytics dashboard (CSS-only charts)
---
## Infrastructure
### Server: `10.10.10.166` (Ubuntu)
| Service | Port | Purpose |
| ----------------------- | ---------------------- | ----------------------------------- |
| **ArchiTools** | 3000 | This app (tools.beletage.ro) |
| **Gitea** | 3002 | Git hosting (git.beletage.ro) |
| **PostgreSQL** | 5432 | App database (Prisma ORM) |
| **Portainer** | 9000 | Docker management |
| **Nginx Proxy Manager** | 81 (admin) | Reverse proxy + SSL termination |
| **Uptime Kuma** | 3001 | Service monitoring |
| **MinIO** | 9002 (API) / 9003 (UI) | Object storage |
| **Authentik** | 9100 | SSO (auth.beletage.ro) — **active** |
| **N8N** | 5678 | Workflow automation (future) |
| **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 |
### Deployment Pipeline
```
git push origin main
→ Gitea webhook fires
→ Portainer CE detects new commit
→ Manual "Pull and redeploy" in Portainer (CE doesn't auto-rebuild)
→ Docker multi-stage build (~1-2 min)
→ Container starts on :3000
→ Nginx Proxy Manager routes to tools.beletage.ro
```
### Docker
- `Dockerfile`: 3-stage build (deps → builder → runner), `node:20-alpine`, non-root user
- `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
---
## Development Rules
### 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
- 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
### 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
### Storage Performance Rules
- **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
### Module Development Pattern
Every module follows:
```
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 / 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
### Before Pushing
1. `npx next build` — must pass with zero errors
2. Test the feature manually on `localhost:3000`
3. Commit with descriptive message
4. `git push origin main` — Portainer auto-deploys
---
## Company IDs
| ID | Name | Prefix |
| ----------------- | --------------- | ------ |
| `beletage` | Beletage | B |
| `urban-switch` | Urban Switch | US |
| `studii-de-teren` | Studii de Teren | SDT |
| `group` | Grup | G |
---
## Current Integrations
| 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 |
| **PostGIS** | ✅ Active | `GisFeature` model, geometry storage, spatial queries, used by ParcelSync |
| **N8N automations** | Webhook URL configured | For notifications, backups, workflows |
---
## 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 |
# ArchiTools — Project Context for AI Assistants
## Quick Start
```bash
npm install
npm run dev # http://localhost:3000
npx next build # verify zero errors before pushing
git push origin main # manual redeploy via Portainer UI
```
---
## Project Overview
**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 + PostGIS via Prisma v6 ORM |
| Storage | `DatabaseStorageAdapter` (Prisma), localStorage fallback |
| Files | MinIO (S3-compatible object storage) |
| Auth | NextAuth v4 + Authentik OIDC (auth.beletage.ro) |
| 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** — 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
---
## Repository Structure
```
src/
├── 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
```
---
## Modules (17 total)
| 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 |
See `docs/MODULE-MAP.md` for entry points, API routes, and cross-module deps.
---
## Development Rules
### TypeScript Strict Mode Gotchas
- `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 }>`
- Prisma `$queryRaw` returns `unknown[]` — cast with `as Array<{ field: type }>`
- `?? ""` on `{}` field produces `{}` not `string` — use `typeof` check
### Conventions
- **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 (CRITICAL)
- **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
### Middleware & Large Uploads
- 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()`
### eTerra / ANCPI Rules
- 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` — zero errors
2. Test on `localhost:3000`
3. Commit with descriptive message
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 |
| ----------------- | --------------- | ------ |
| `beletage` | Beletage | B |
| `urban-switch` | Urban Switch | US |
| `studii-de-teren` | Studii de Teren | SDT |
| `group` | Grup | G |
---
## Documentation
| 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` |
For module-specific deep dives, see `docs/modules/`.
+26 -10
View File
@@ -1,39 +1,55 @@
# syntax=docker/dockerfile:1
FROM node:20-alpine AS deps
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# BuildKit cache mount keeps npm's global cache between builds —
# subsequent npm ci only downloads changed/new packages instead of
# re-fetching everything from the registry (~30-60s saving).
RUN --mount=type=cache,target=/root/.npm \
npm ci --ignore-scripts
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# Copy prisma schema first — cached layer for prisma generate
COPY prisma ./prisma
RUN npx prisma generate
# Now copy the rest of the source
COPY . .
# Build args for NEXT_PUBLIC_* vars (inlined at build time)
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}
# Generate Prisma client before building
RUN npx prisma generate
# Increase memory for Next.js build if VM has limited RAM
ENV NODE_OPTIONS="--max-old-space-size=2048"
RUN npm run build
FROM node:20-alpine AS runner
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV TZ=Europe/Bucharest
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf
# Install system deps + create user in a single layer
RUN apk add --no-cache gdal gdal-tools ghostscript qpdf tzdata \
&& addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# Note: DWG→DXF conversion handled by dwg2dxf sidecar container (see docker-compose.yml)
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
+35 -9
View File
@@ -32,17 +32,17 @@
| # | Module | Version | Status | Remaining Gaps | Future Enhancements |
| --- | ------------------ | ------- | --------- | --------------------------------------------------- | ------------------------------------------------- |
| 1 | Registratura | 0.4.0 | HARDENING | Legal deadline workflow gaps, chain logic | Workflow automation, email integration, OCR |
| 1 | Registratura | 0.5.0 | HARDENING | | Workflow automation, OCR, print/PDF export |
| 2 | Email Signature | 0.1.0 | COMPLETE | US/SDT addresses may need update | AD sync, branding packs, promo banners |
| 3 | Word XML | 0.1.0 | COMPLETE | — | Schema validator, visual mapper |
| 4 | Digital Signatures | 0.1.0 | COMPLETE | — | Permission layers, document insertion |
| 5 | Password Vault | 0.3.0 | COMPLETE | — | Hardware key, rotation reminders, Passbolt |
| 5 | Password Vault | 0.4.0 | COMPLETE | — | Hardware key, rotation reminders, Passbolt |
| 6 | IT Inventory | 0.2.0 | COMPLETE | — | Network scan import |
| 7 | Address Book | 0.1.1 | COMPLETE | — | Email sync, deduplication |
| 8 | Prompt Generator | 0.2.0 | HARDENING | Bug fixes, new idea TBD | Prompt scoring, more image templates |
| 9 | Word Templates | 0.1.0 | COMPLETE | No clause library; no Word generation | Diff compare, document generator |
| 10 | Tag Manager | 0.2.0 | HARDENING | Logic/workflow fix, ERP API exposure needed | Smart suggestions |
| 11 | Mini Utilities | 0.2.0 | COMPLETE | — | More converters, more tools TBD |
| 11 | Mini Utilities | 0.3.0 | COMPLETE | — | More converters, more tools TBD |
| 12 | Dashboard | 0.1.0 | COMPLETE | — | Custom dashboards per role |
| 13 | AI Chat | 0.2.0 | COMPLETE | Needs API key env vars for real AI | Streaming, model selector, conversation templates |
| 14 | Hot Desk | 0.1.1 | COMPLETE | — | — |
@@ -826,31 +826,57 @@ Env vars (hardcoded in docker-compose.yml for Portainer CE):
---
### 8.03 `[STANDARD]` Notification System
### 8.03 `[STANDARD]` Notification System + Registratura UI Polish (2026-03-11)
**What:** Bell icon in header. Deadline alerts, overdue warnings, tacit approval triggers.
**What:** Email notification system with daily digest via Brevo SMTP + N8N cron. Plus Registratura toolbar and registry number UX improvements.
**Implemented:**
- Brevo SMTP relay (nodemailer, port 587 STARTTLS), sender "Alerte Termene" &lt;noreply@beletage.ro&gt;
- Daily digest email: urgent deadlines, overdue deadlines, expiring documents
- Per-user notification preferences (3 types + global opt-out) stored in KeyValueStore
- API routes: POST `/api/notifications/digest` (N8N Bearer auth), GET/PUT `/api/notifications/preferences` (session auth)
- Test mode via `?test=true` query param on digest endpoint
- "group" company users see ALL entries across companies in digest
- UI: Bell icon button "Notificari" in Registratura toolbar → dialog with toggles
- Icon-only toolbar buttons (Bune practici + Notificari) with native `title` tooltips
- HTML email: inline-styled tables, color-coded rows (red/yellow/blue), per-company grouping
- N8N cron: `0 8 * * 1-5` (weekdays 8:00)
- **Compact registry numbers**: single-letter company badge (B=blue, U=violet, S=green, G=gray) + direction arrow (↓ green=intrat, ↑ orange=iesit) + plain number — `CompactNumber` component in `registry-table.tsx`
**Files:** `src/core/notifications/`, `src/app/api/notifications/`, `components/notification-preferences.tsx`, `components/registry-table.tsx`
---
### 8.04 `[STANDARD]` Registratura — Print/PDF Export
### 8.04 `[STANDARD]` Mini Utilities + Password Vault — UX Improvements (2026-03-12)
**What:** TVA calculator with configurable rate, new scale calculator for drawings, multi-user support in Password Vault.
**Implemented:**
- **TVA cotă configurabilă**: preseturi 5%/9%/19%/21% + câmp custom; rata efectivă afișată în rezultate; titlu card actualizat dinamic
- **Calculator scară desen** (tab nou "Scară"): modul Real→Desen (input cm, output mm) și Desen→Real (input mm, output cm); 7 preseturi 1:20..1:5000 + scară custom 1:X; afișare secundară în m/cm; copy button pe rezultat
- Logică: `drawing_mm = real_cm × 10 / scale` / `real_cm = drawing_mm × scale / 10`
- **Password Vault — utilizatori multipli**: tip `VaultUser { username, password, email?, notes? }`; câmp `additionalUsers: VaultUser[]` pe `VaultEntry`; secțiune colapsibilă "Utilizatori suplimentari" în form; badge cu numărul de utilizatori în lista de intrări; normalizare backward-compat în `useVault` hook
**Files:** `src/modules/mini-utilities/components/mini-utilities-module.tsx`, `src/modules/password-vault/types.ts`, `src/modules/password-vault/components/password-vault-module.tsx`, `src/modules/password-vault/hooks/use-vault.ts`
**Versions:** Mini Utilities 0.2.0→0.3.0, Password Vault 0.3.0→0.4.0
---
### 8.05 `[STANDARD]` Registratura — Print/PDF Export
**What:** Export registry as formatted PDF. Options: full registry, single entry, deadline summary.
---
### 8.05 `[STANDARD]` Word Templates — Clause Library + Document Generator
### 8.06 `[STANDARD]` Word Templates — Clause Library + Document Generator
**What:** In-app clause composition, template preview, simple Word generation from templates.
---
### 8.06 `[STANDARD]` N8N Webhook Integration
### 8.07 `[STANDARD]` N8N Webhook Integration
**What:** Fire webhooks on events (new entry, deadline approaching, status change). N8N at http://10.10.10.166:5678.
---
### 8.07 `[STANDARD]` Mobile Responsiveness Audit
### 8.08 `[STANDARD]` Mobile Responsiveness Audit
**What:** Test all modules on 375px/768px. Fix overflowing tables, forms, sidebar.
+146 -68
View File
@@ -1,68 +1,146 @@
version: "3.8"
services:
architools:
build:
context: .
args:
- 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}
container_name: architools
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
# Database
- DATABASE_URL=postgresql://architools_user:stictMyFon34!_gonY@10.10.10.166:5432/architools_db?schema=public
# MinIO
- MINIO_ENDPOINT=10.10.10.166
- MINIO_PORT=9002
- MINIO_USE_SSL=false
- MINIO_ACCESS_KEY=admin
- MINIO_SECRET_KEY=MinioStrongPass123
- MINIO_BUCKET_NAME=tools
# Authentication (Authentik OIDC)
- NEXTAUTH_URL=https://tools.beletage.ro
- NEXTAUTH_SECRET=8IL9Kpipj0EZwZPNvekbNRPhV6a2/UY4cGVzE3n0pUY=
- AUTHENTIK_CLIENT_ID=V59GMiYle87yd9VZOgUmdSmzYQALqNsKVAUR6QMi
- AUTHENTIK_CLIENT_SECRET=TMeewkusUro0hQ2DMwS0Z5lNpNMdmziO9WXywNAGlK3Y6Y8HYULZBEtMtm53lioIkszWbpPRQcv1cxHMtwftMvsaSnbliDsL1f707wmUJhMFKjeZ0ypIFKFG4dJkp7Jr
- AUTHENTIK_ISSUER=https://auth.beletage.ro/application/o/architools/
# Vault encryption
- ENCRYPTION_SECRET=ArchiTools-Vault-2025!SecureKey@AES256
# ManicTime Tags.txt sync (SMB mount path)
- MANICTIME_TAGS_PATH=/mnt/manictime/Tags.txt
# AI Chat (set AI_PROVIDER to openai/anthropic/ollama; demo if no key)
- AI_PROVIDER=${AI_PROVIDER:-demo}
- AI_API_KEY=${AI_API_KEY:-}
- AI_MODEL=${AI_MODEL:-}
- AI_BASE_URL=${AI_BASE_URL:-}
- AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048}
# Visual CoPilot (at-vim)
- VIM_URL=${VIM_URL:-}
# eTerra ANCPI (parcel-sync module)
- ETERRA_USERNAME=${ETERRA_USERNAME:-}
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
# DWG-to-DXF sidecar
- DWG2DXF_URL=http://dwg2dxf:5001
depends_on:
dwg2dxf:
condition: service_healthy
volumes:
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
- /mnt/manictime:/mnt/manictime
labels:
- "com.centurylinklabs.watchtower.enable=true"
dwg2dxf:
build:
context: ./dwg2dxf-api
container_name: dwg2dxf
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5001/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
services:
architools:
build:
context: .
args:
- 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=${NEXT_PUBLIC_MARTIN_URL}
- NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
container_name: architools
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=${NODE_ENV}
# Database
- DATABASE_URL=${DATABASE_URL}
# MinIO
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
- MINIO_PORT=${MINIO_PORT}
- MINIO_USE_SSL=${MINIO_USE_SSL}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
- MINIO_BUCKET_NAME=${MINIO_BUCKET_NAME}
# Authentication (Authentik OIDC)
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID}
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET}
- AUTHENTIK_ISSUER=${AUTHENTIK_ISSUER}
# Vault encryption
- ENCRYPTION_SECRET=${ENCRYPTION_SECRET}
# ManicTime Tags.txt sync (SMB mount path)
- MANICTIME_TAGS_PATH=${MANICTIME_TAGS_PATH}
# AI Chat (set AI_PROVIDER to openai/anthropic/ollama; demo if no key)
- AI_PROVIDER=${AI_PROVIDER:-demo}
- AI_API_KEY=${AI_API_KEY:-}
- AI_MODEL=${AI_MODEL:-}
- AI_BASE_URL=${AI_BASE_URL:-}
- AI_MAX_TOKENS=${AI_MAX_TOKENS:-2048}
# Visual CoPilot (at-vim)
- VIM_URL=${VIM_URL:-}
# eTerra ANCPI (parcel-sync module)
- ETERRA_USERNAME=${ETERRA_USERNAME:-}
- ETERRA_PASSWORD=${ETERRA_PASSWORD:-}
# ANCPI ePay (CF extract ordering)
- ANCPI_USERNAME=${ANCPI_USERNAME}
- ANCPI_PASSWORD=${ANCPI_PASSWORD}
- ANCPI_BASE_URL=${ANCPI_BASE_URL}
- ANCPI_LOGIN_URL=${ANCPI_LOGIN_URL}
- ANCPI_DEFAULT_SOLICITANT_ID=${ANCPI_DEFAULT_SOLICITANT_ID}
- MINIO_BUCKET_ANCPI=${MINIO_BUCKET_ANCPI}
# Stirling PDF (local PDF tools)
- STIRLING_PDF_URL=${STIRLING_PDF_URL}
- STIRLING_PDF_API_KEY=${STIRLING_PDF_API_KEY}
# iLovePDF cloud compression (free: 250 files/month)
- ILOVEPDF_PUBLIC_KEY=${ILOVEPDF_PUBLIC_KEY:-}
# Martin vector tile server (geoportal)
- NEXT_PUBLIC_MARTIN_URL=${NEXT_PUBLIC_MARTIN_URL}
# PMTiles overview tiles — proxied through tile-cache nginx (HTTPS, no mixed-content)
- NEXT_PUBLIC_PMTILES_URL=${NEXT_PUBLIC_PMTILES_URL}
# DWG-to-DXF sidecar
- DWG2DXF_URL=${DWG2DXF_URL}
# Email notifications (Brevo REST API)
- BREVO_API_KEY=${BREVO_API_KEY}
- NOTIFICATION_FROM_EMAIL=${NOTIFICATION_FROM_EMAIL}
- NOTIFICATION_FROM_NAME=${NOTIFICATION_FROM_NAME}
- NOTIFICATION_CRON_SECRET=${NOTIFICATION_CRON_SECRET}
# 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=${PORTAL_ONLY_USERS}
# Address Book API (inter-service auth for external tools)
- ADDRESSBOOK_API_KEY=${ADDRESSBOOK_API_KEY}
depends_on:
dwg2dxf:
condition: service_healthy
volumes:
# SMB share for ManicTime Tags.txt (mount on host: //time/tags → /mnt/manictime)
- /mnt/manictime:/mnt/manictime
labels:
- "com.centurylinklabs.watchtower.enable=true"
dwg2dxf:
build:
context: ./dwg2dxf-api
container_name: dwg2dxf
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"python3",
"-c",
"import urllib.request; urllib.request.urlopen('http://localhost:5001/health')",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
martin:
build:
context: .
dockerfile: martin.Dockerfile
container_name: martin
restart: unless-stopped
# No host port — only accessible via tile-cache nginx proxy
command: ["--config", "/config/martin.yaml"]
environment:
- DATABASE_URL=${DATABASE_URL}
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=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_NAME=${DB_NAME}
- DB_USER=${DB_USER}
- DB_PASS=${DB_PASS}
- MINIO_ENDPOINT=${MINIO_ENDPOINT}
- MINIO_ACCESS_KEY=${MINIO_ACCESS_KEY}
- MINIO_SECRET_KEY=${MINIO_SECRET_KEY}
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 |
+34
View File
@@ -443,6 +443,40 @@ interface WordTemplate extends BaseEntity {
}
```
### Email Notifications (platform service)
```typescript
// src/core/notifications/types.ts
type NotificationType = "deadline-urgent" | "deadline-overdue" | "document-expiry";
interface NotificationPreference {
userId: string;
email: string;
name: string;
company: CompanyId;
enabledTypes: NotificationType[];
globalOptOut: boolean;
}
interface DigestItem {
entryNumber: string;
subject: string;
label: string;
dueDate: string; // YYYY-MM-DD
daysRemaining: number; // negative = overdue
color: "red" | "yellow" | "blue";
}
interface DigestSection {
type: NotificationType;
title: string;
items: DigestItem[];
}
```
> **Storage:** Preferences stored in `KeyValueStore` (namespace `notifications`, key `pref:<userId>`). No separate Prisma model needed.
---
## Naming Conventions
+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
+7
View File
@@ -99,6 +99,11 @@ ArchiTools/
│ │ │ ├── use-theme.ts # Hook for theme access
│ │ │ ├── tokens.ts # Design token definitions
│ │ │ └── index.ts # Public API
│ │ ├── notifications/
│ │ │ ├── types.ts # NotificationType, NotificationPreference, DigestSection
│ │ │ ├── email-service.ts # Nodemailer transport singleton (Brevo SMTP)
│ │ │ ├── notification-service.ts # runDigest(), buildCompanyDigest(), preference CRUD
│ │ │ └── index.ts # Public API
│ │ └── auth/
│ │ ├── auth-provider.tsx # Auth context provider (stub)
│ │ ├── use-auth.ts # Hook for auth state
@@ -324,6 +329,8 @@ Platform core systems. These are infrastructure services used by all modules. Co
- **`theme/`** — Dark/light theme system. Provides the theme context, toggle hook, and design token definitions. Theme preference is persisted in storage. Tokens define colors, spacing, and typography values consumed by Tailwind and component styles.
- **`notifications/`** — Email notification service. Daily digest via Brevo SMTP relay (nodemailer, port 587 STARTTLS). Includes `runDigest()` orchestrator, `buildCompanyDigest()` for per-company deadline aggregation, `renderDigestHtml()` for inline-styled email, and preference CRUD via KeyValueStore (`notifications` namespace). API routes: POST `/api/notifications/digest` (N8N cron, Bearer auth), GET/PUT `/api/notifications/preferences` (user session auth).
- **`auth/`** — Authentication and authorization stub. Defines the `AuthContext` interface (`user`, `role`, `permissions`, `company`). Currently returns a default admin user. When Authentik SSO integration is implemented, this module will resolve real identity from OIDC tokens. The interface remains stable; only the provider implementation changes.
### `src/modules/`
+6 -3
View File
@@ -430,7 +430,8 @@ ArchiTools runs alongside existing services on the internal network:
|---------|-----------------|---------|
| **Authentik** | Future SSO provider | User authentication and role assignment |
| **MinIO** | Future storage adapter | Object/file storage for documents, signatures, templates |
| **N8N** | Future webhook/API | Workflow automation (document processing, notifications) |
| **N8N** | ✅ Active (cron) | Daily digest cron (`0 8 * * 1-5`), future: backups, workflows |
| **Brevo SMTP** | ✅ Active | Email relay for notification digests (port 587, STARTTLS) |
| **Gitea** | Development | Source code hosting |
| **Stirling PDF** | Dashboard link | PDF manipulation (external tool link) |
| **IT-Tools** | Dashboard link | Technical utilities (external tool link) |
@@ -446,9 +447,11 @@ ArchiTools runs alongside existing services on the internal network:
**Storage integration (MinIO):** When the MinIO adapter is implemented, modules that manage files (Digital Signatures, Word Templates) will store binary assets in MinIO buckets while keeping metadata in the primary storage.
**Automation integration (N8N):** Modules can trigger N8N webhooks for automated workflows. Example: Registratura creates a new entry, triggering an N8N workflow that sends a notification or generates a document.
**Automation integration (N8N):** N8N triggers scheduled workflows via API endpoints. Active: daily digest cron calls `POST /api/notifications/digest` with Bearer token auth. Future: document processing, backups.
**SSO integration (Authentik):** The auth stub will be replaced with an Authentik OIDC client. The middleware layer will validate tokens and populate `AuthContext`. No module code changes required.
**Email notifications (Brevo SMTP):** Platform service in `src/core/notifications/`. Nodemailer transport singleton connects to Brevo SMTP relay. Sender: "Alerte Termene" &lt;noreply@beletage.ro&gt;. `runDigest()` loads all registry entries, groups by company, builds digest per subscriber filtering by their preference types (urgent, overdue, expiry), renders inline-styled HTML, sends via SMTP. Test mode via `?test=true` query param. "group" company users receive digest with ALL entries. Preferences stored in KeyValueStore (namespace `notifications`).
**SSO integration (Authentik):** Authentik OIDC provides user identity. NextAuth v4 JWT/session callbacks map Authentik groups to roles and companies. Notification preferences auto-refresh user email/name/company from session on each save.
---
+28
View File
@@ -57,6 +57,34 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# Example: NEXT_PUBLIC_FLAGS_OVERRIDE=module_ai_chat=true,module_password_vault=false
NEXT_PUBLIC_FLAGS_OVERRIDE=
# -----------------------------------------------------------------------------
# Email Notifications (Brevo SMTP)
# -----------------------------------------------------------------------------
# SMTP relay for daily digest emails (deadline alerts, document expiry)
BREVO_SMTP_HOST=smtp-relay.brevo.com
BREVO_SMTP_PORT=587
BREVO_SMTP_USER= # Brevo SMTP login (from Brevo dashboard)
BREVO_SMTP_PASS= # Brevo SMTP key (from Brevo dashboard)
NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
NOTIFICATION_FROM_NAME=Alerte Termene
NOTIFICATION_CRON_SECRET= # Random Bearer token for N8N → digest API auth
# -----------------------------------------------------------------------------
# eTerra ANCPI (ParcelSync module — GIS data)
# -----------------------------------------------------------------------------
ETERRA_USERNAME= # eTerra login email
ETERRA_PASSWORD= # eTerra login password
# -----------------------------------------------------------------------------
# ANCPI ePay (CF extract ordering)
# -----------------------------------------------------------------------------
ANCPI_USERNAME= # ePay login email (separate from eTerra)
ANCPI_PASSWORD= # ePay login password
ANCPI_BASE_URL=https://epay.ancpi.ro/epay
ANCPI_LOGIN_URL=https://oassl.ancpi.ro/openam/UI/Login
ANCPI_DEFAULT_SOLICITANT_ID=14452 # Beletage persoana juridica
MINIO_BUCKET_ANCPI=ancpi-documente # MinIO bucket for CF extract PDFs
# -----------------------------------------------------------------------------
# External Services
# -----------------------------------------------------------------------------
+14 -1
View File
@@ -200,13 +200,26 @@ NEXT_PUBLIC_STORAGE_ADAPTER=localStorage
# MINIO_BUCKET=architools
# ──────────────────────────────────────────
# Authentication (future: Authentik SSO)
# Authentication (Authentik SSO)
# ──────────────────────────────────────────
# AUTHENTIK_ISSUER=https://auth.internal
# AUTHENTIK_CLIENT_ID=architools
# AUTHENTIK_CLIENT_SECRET=<secret>
# ──────────────────────────────────────────
# Email Notifications (Brevo SMTP)
# ──────────────────────────────────────────
BREVO_SMTP_HOST=smtp-relay.brevo.com
BREVO_SMTP_PORT=587
BREVO_SMTP_USER=<brevo-login>
BREVO_SMTP_PASS=<brevo-smtp-key>
NOTIFICATION_FROM_EMAIL=noreply@beletage.ro
NOTIFICATION_FROM_NAME=Alerte Termene
NOTIFICATION_CRON_SECRET=<random-bearer-token>
```
> **N8N cron setup:** Create a workflow with Cron node (`0 8 * * 1-5`), HTTP Request node (POST `https://tools.beletage.ro/api/notifications/digest`, header `Authorization: Bearer <NOTIFICATION_CRON_SECRET>`). The endpoint returns `{ success, totalEmails, errors, companySummary }`. Add `?test=true` query param to send a test digest with sample data.
### Variable Scoping Rules
| Prefix | Available In | Notes |
+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
+149
View File
@@ -0,0 +1,149 @@
# 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:
# ── UAT boundaries: 4 zoom-dependent simplification levels ──
gis_uats_z0:
schema: public
table: gis_uats_z0
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 0
maxzoom: 5
properties:
name: text
siruta: text
gis_uats_z5:
schema: public
table: gis_uats_z5
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 5
maxzoom: 8
properties:
name: text
siruta: text
gis_uats_z8:
schema: public
table: gis_uats_z8
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 8
maxzoom: 12
properties:
name: text
siruta: text
county: text
gis_uats_z12:
schema: public
table: gis_uats_z12
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 12
maxzoom: 16
properties:
name: text
siruta: text
county: text
# ── Administrativ (intravilan, arii speciale) — NO simplification ──
gis_administrativ:
schema: public
table: gis_administrativ
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 10
maxzoom: 16
properties:
object_id: text
siruta: text
layer_id: text
cadastral_ref: text
# ── Terenuri (parcels) — NO simplification ──
gis_terenuri:
schema: public
table: gis_terenuri
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 17
maxzoom: 18
properties:
object_id: text
siruta: text
cadastral_ref: text
area_value: float8
layer_id: text
# ── Terenuri cu status enrichment (ParcelSync Harta tab) ──
gis_terenuri_status:
schema: public
table: gis_terenuri_status
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 13
maxzoom: 18
properties:
object_id: text
siruta: text
cadastral_ref: text
area_value: float8
layer_id: text
has_enrichment: int4
has_building: int4
build_legal: int4
# ── Cladiri cu status legal (ParcelSync Harta tab) ──
gis_cladiri_status:
schema: public
table: gis_cladiri_status
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 14
maxzoom: 18
properties:
object_id: text
siruta: text
cadastral_ref: text
area_value: float8
layer_id: text
build_legal: int4
# ── Cladiri (buildings) — NO simplification ──
gis_cladiri:
schema: public
table: gis_cladiri
geometry_column: geom
srid: 3844
bounds: [20.2, 43.5, 30.0, 48.3]
minzoom: 17
maxzoom: 18
properties:
object_id: text
siruta: text
cadastral_ref: text
area_value: float8
layer_id: text
+26
View File
@@ -2,6 +2,32 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: ['busboy'],
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 [
{
source: '/tiles/:path*',
destination: `${martinUrl}/:path*`,
},
];
},
};
export default nextConfig;
+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;
}
}
+298 -22
View File
@@ -11,16 +11,20 @@
"@prisma/client": "^6.19.2",
"axios": "^1.13.6",
"axios-cookiejar-support": "^6.0.5",
"busboy": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"docx": "^9.6.0",
"form-data": "^4.0.5",
"jspdf": "^4.2.0",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",
"maplibre-gl": "^5.21.0",
"minio": "^8.0.6",
"next": "16.1.6",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"pmtiles": "^4.4.0",
"proj4": "^2.20.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
@@ -34,6 +38,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/busboy": "^1.5.4",
"@types/jszip": "^3.4.0",
"@types/node": "^20",
"@types/proj4": "^2.5.6",
@@ -117,7 +122,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -686,7 +690,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -1636,6 +1639,111 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/jsonlint-lines-primitives": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/@mapbox/point-geometry": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
"license": "ISC"
},
"node_modules/@mapbox/tiny-sdf": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz",
"integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/unitbezier": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
"license": "BSD-2-Clause"
},
"node_modules/@mapbox/vector-tile": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/point-geometry": "~1.1.0",
"@types/geojson": "^7946.0.16",
"pbf": "^4.0.1"
}
},
"node_modules/@mapbox/whoots-js": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@maplibre/geojson-vt": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz",
"integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/@maplibre/maplibre-gl-style-spec": {
"version": "24.7.0",
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz",
"integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==",
"license": "ISC",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
"@mapbox/unitbezier": "^0.0.1",
"json-stringify-pretty-compact": "^4.0.0",
"minimist": "^1.2.8",
"quickselect": "^3.0.0",
"rw": "^1.3.3",
"tinyqueue": "^3.0.0"
},
"bin": {
"gl-style-format": "dist/gl-style-format.mjs",
"gl-style-migrate": "dist/gl-style-migrate.mjs",
"gl-style-validate": "dist/gl-style-validate.mjs"
}
},
"node_modules/@maplibre/mlt": {
"version": "1.1.8",
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz",
"integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==",
"license": "(MIT OR Apache-2.0)",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0"
}
},
"node_modules/@maplibre/vt-pbf": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
"license": "MIT",
"dependencies": {
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/vector-tile": "^2.0.4",
"@maplibre/geojson-vt": "^5.0.4",
"@types/geojson": "^7946.0.16",
"@types/supercluster": "^7.1.3",
"pbf": "^4.0.1",
"supercluster": "^8.0.1"
}
},
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
"license": "ISC"
},
"node_modules/@modelcontextprotocol/sdk": {
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
@@ -1882,7 +1990,6 @@
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -3993,6 +4100,16 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/busboy": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/@types/busboy/-/busboy-1.5.4.tgz",
"integrity": "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -4000,6 +4117,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -4070,7 +4193,6 @@
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -4081,7 +4203,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4093,6 +4214,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/supercluster": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@@ -4166,7 +4296,6 @@
"integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.56.0",
"@typescript-eslint/types": "8.56.0",
@@ -4700,7 +4829,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5085,7 +5213,6 @@
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
@@ -5251,7 +5378,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -5291,6 +5417,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -6257,6 +6394,12 @@
"node": ">= 0.4"
}
},
"node_modules/earcut": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
"license": "ISC"
},
"node_modules/eciesjs": {
"version": "0.4.17",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.17.tgz",
@@ -6570,7 +6713,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6756,7 +6898,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7076,7 +7217,6 @@
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -7758,6 +7898,12 @@
"giget": "dist/cli.mjs"
}
},
"node_modules/gl-matrix": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
"license": "MIT"
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -7960,7 +8106,6 @@
"integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -8902,6 +9047,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/json-stringify-pretty-compact": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -8973,6 +9124,12 @@
"setimmediate": "^1.0.5"
}
},
"node_modules/kdbush": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
"license": "ISC"
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -9418,6 +9575,40 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/maplibre-gl": {
"version": "5.21.0",
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.0.tgz",
"integrity": "sha512-n0v4J/Ge0EG8ix/z3TY3ragtJYMqzbtSnj1riOC0OwQbzwp0lUF2maS1ve1z8HhitQCKtZZiZJhb8to36aMMfQ==",
"license": "BSD-3-Clause",
"dependencies": {
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
"@mapbox/point-geometry": "^1.1.0",
"@mapbox/tiny-sdf": "^2.0.7",
"@mapbox/unitbezier": "^0.0.1",
"@mapbox/vector-tile": "^2.0.4",
"@mapbox/whoots-js": "^3.1.0",
"@maplibre/geojson-vt": "^6.0.4",
"@maplibre/maplibre-gl-style-spec": "^24.7.0",
"@maplibre/mlt": "^1.1.8",
"@maplibre/vt-pbf": "^4.3.0",
"@types/geojson": "^7946.0.16",
"earcut": "^3.0.2",
"gl-matrix": "^3.4.4",
"kdbush": "^4.0.2",
"murmurhash-js": "^1.0.0",
"pbf": "^4.0.1",
"potpack": "^2.1.0",
"quickselect": "^3.0.0",
"tinyqueue": "^3.0.0"
},
"engines": {
"node": ">=16.14.0",
"npm": ">=8.1.0"
},
"funding": {
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -9560,7 +9751,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -9687,6 +9877,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/murmurhash-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
"license": "MIT"
},
"node_modules/mute-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
@@ -9943,6 +10139,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"optional": true,
"peer": true,
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/npm-run-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",
@@ -10509,6 +10716,18 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/pbf": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
"license": "BSD-3-Clause",
"dependencies": {
"resolve-protobuf-schema": "^2.1.0"
},
"bin": {
"pbf": "bin/pbf"
}
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -10564,6 +10783,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",
@@ -10625,6 +10853,12 @@
"node": ">=4"
}
},
"node_modules/potpack": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
"license": "ISC"
},
"node_modules/powershell-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz",
@@ -10643,7 +10877,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.4.tgz",
"integrity": "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -10700,7 +10933,6 @@
"devOptional": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@prisma/config": "6.19.2",
"@prisma/engines": "6.19.2"
@@ -10775,6 +11007,12 @@
"react-is": "^16.13.1"
}
},
"node_modules/protocol-buffers-schema": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
"integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -11039,6 +11277,12 @@
],
"license": "MIT"
},
"node_modules/quickselect": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
"license": "ISC"
},
"node_modules/radix-ui": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz",
@@ -11168,7 +11412,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11178,7 +11421,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -11430,6 +11672,15 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/resolve-protobuf-schema": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
"license": "MIT",
"dependencies": {
"protocol-buffers-schema": "^3.3.1"
}
},
"node_modules/restore-cursor": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
@@ -11540,6 +11791,12 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rw": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause"
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -12122,6 +12379,14 @@
"stream-chain": "^2.2.5"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/strict-event-emitter": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz",
@@ -12390,6 +12655,15 @@
}
}
},
"node_modules/supercluster": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
"license": "ISC",
"dependencies": {
"kdbush": "^4.0.2"
}
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -12605,7 +12879,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -12613,6 +12886,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/tinyqueue": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
"license": "ISC"
},
"node_modules/tldts": {
"version": "7.0.23",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz",
@@ -12659,7 +12938,6 @@
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"tldts": "^7.0.5"
},
@@ -12867,7 +13145,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13602,7 +13879,6 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+5
View File
@@ -12,16 +12,20 @@
"@prisma/client": "^6.19.2",
"axios": "^1.13.6",
"axios-cookiejar-support": "^6.0.5",
"busboy": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"docx": "^9.6.0",
"form-data": "^4.0.5",
"jspdf": "^4.2.0",
"jszip": "^3.10.1",
"lucide-react": "^0.564.0",
"maplibre-gl": "^5.21.0",
"minio": "^8.0.6",
"next": "16.1.6",
"next-auth": "^4.24.13",
"next-themes": "^0.4.6",
"pmtiles": "^4.4.0",
"proj4": "^2.20.3",
"qrcode": "^1.5.4",
"radix-ui": "^1.4.3",
@@ -35,6 +39,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/busboy": "^1.5.4",
"@types/jszip": "^3.4.0",
"@types/node": "^20",
"@types/proj4": "^2.5.6",
+205
View File
@@ -0,0 +1,205 @@
-- =============================================================================
-- PostGIS native geometry setup for GisUat (UAT boundaries)
-- Run once manually: PGPASSWORD='...' psql -h 10.10.10.166 -p 5432 \
-- -U architools_user -d architools_db -f prisma/gisuat-postgis-setup.sql
--
-- Idempotent — safe to re-run.
--
-- What this does:
-- 1. Ensures PostGIS extension
-- 2. Adds native geometry column (geom) if missing
-- 3. Creates function to convert Esri ring JSON -> PostGIS geometry
-- 4. Creates trigger to auto-convert on INSERT/UPDATE
-- 5. Backfills existing rows
-- 6. Creates GiST spatial index
-- 7. Creates Martin/QGIS-friendly view 'gis_uats'
--
-- After running both SQL scripts (postgis-setup.sql + this file), Martin
-- will auto-discover these views (any table/view with a 'geom' geometry column):
-- - gis_features (master: all GisFeature rows with geometry)
-- - gis_terenuri (parcels from GisFeature)
-- - gis_cladiri (buildings from GisFeature)
-- - gis_documentatii (expertize/zone/receptii from GisFeature)
-- - gis_administrativ (limite UAT/intravilan/arii speciale from GisFeature)
-- - gis_uats (UAT boundaries from GisUat) <-- this script
--
-- All geometries are in EPSG:3844 (Stereo70).
-- =============================================================================
-- 1. Ensure PostGIS extension
CREATE EXTENSION IF NOT EXISTS postgis;
-- 2. Add native geometry column (idempotent)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'GisUat' AND column_name = 'geom'
) THEN
ALTER TABLE "GisUat" ADD COLUMN geom geometry(Geometry, 3844);
END IF;
END $$;
-- 3. Function: convert Esri ring JSON { rings: number[][][] } -> PostGIS geometry
-- Esri rings format: each ring is an array of [x, y] coordinate pairs.
-- First ring = exterior, subsequent rings = holes.
-- Multiple outer rings (non-holes) would need MultiPolygon, but UAT boundaries
-- from eTerra typically have a single polygon with possible holes.
--
-- Strategy: build WKT POLYGON/MULTIPOLYGON from the rings array, then
-- use ST_GeomFromText with SRID 3844.
CREATE OR REPLACE FUNCTION gis_uat_esri_to_geom(geom_json jsonb)
RETURNS geometry AS $$
DECLARE
rings jsonb;
ring jsonb;
coord jsonb;
ring_count int;
coord_count int;
i int;
j int;
wkt_ring text;
wkt text;
first_x double precision;
first_y double precision;
last_x double precision;
last_y double precision;
BEGIN
-- Extract the rings array from the JSON
rings := geom_json -> 'rings';
IF rings IS NULL OR jsonb_array_length(rings) = 0 THEN
RETURN NULL;
END IF;
ring_count := jsonb_array_length(rings);
-- Build WKT POLYGON with all rings (first = exterior, rest = holes)
wkt := 'POLYGON(';
FOR i IN 0 .. ring_count - 1 LOOP
ring := rings -> i;
coord_count := jsonb_array_length(ring);
IF coord_count < 3 THEN
CONTINUE; -- skip degenerate rings
END IF;
IF i > 0 THEN
wkt := wkt || ', ';
END IF;
wkt_ring := '(';
FOR j IN 0 .. coord_count - 1 LOOP
coord := ring -> j;
IF j > 0 THEN
wkt_ring := wkt_ring || ', ';
END IF;
wkt_ring := wkt_ring || (coord ->> 0) || ' ' || (coord ->> 1);
-- Track first and last coordinates to check ring closure
IF j = 0 THEN
first_x := (coord ->> 0)::double precision;
first_y := (coord ->> 1)::double precision;
END IF;
IF j = coord_count - 1 THEN
last_x := (coord ->> 0)::double precision;
last_y := (coord ->> 1)::double precision;
END IF;
END LOOP;
-- Close the ring if not already closed
IF first_x != last_x OR first_y != last_y THEN
wkt_ring := wkt_ring || ', ' || first_x::text || ' ' || first_y::text;
END IF;
wkt_ring := wkt_ring || ')';
wkt := wkt || wkt_ring;
END LOOP;
wkt := wkt || ')';
RETURN ST_GeomFromText(wkt, 3844);
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- 4. Trigger function: auto-convert Esri JSON -> native PostGIS on INSERT/UPDATE
CREATE OR REPLACE FUNCTION gis_uat_sync_geom()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.geometry IS NOT NULL THEN
BEGIN
NEW.geom := gis_uat_esri_to_geom(NEW.geometry::jsonb);
EXCEPTION WHEN OTHERS THEN
-- Invalid geometry JSON -> leave geom NULL rather than fail the write
NEW.geom := NULL;
END;
ELSE
NEW.geom := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- 5. Attach trigger (drop + recreate for idempotency)
DROP TRIGGER IF EXISTS trg_gis_uat_sync_geom ON "GisUat";
CREATE TRIGGER trg_gis_uat_sync_geom
BEFORE INSERT OR UPDATE OF geometry ON "GisUat"
FOR EACH ROW
EXECUTE FUNCTION gis_uat_sync_geom();
-- 6. Backfill: convert existing Esri JSON geometries to native PostGIS
UPDATE "GisUat"
SET geom = gis_uat_esri_to_geom(geometry::jsonb)
WHERE geometry IS NOT NULL AND geom IS NULL;
-- 7. GiST spatial index for fast spatial queries
CREATE INDEX IF NOT EXISTS gis_uat_geom_idx
ON "GisUat" USING GIST (geom);
-- =============================================================================
-- 8. Zoom-dependent views for Martin vector tiles
-- 4 levels of geometry simplification for progressive loading.
-- SAFE: these are read-only views — original geom column is NEVER modified.
-- =============================================================================
-- z0-5: Very coarse overview (2000m tolerance) — country-level outlines
CREATE OR REPLACE VIEW gis_uats_z0 AS
SELECT siruta, name,
ST_SimplifyPreserveTopology(geom, 2000) AS geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- z5-8: Coarse (500m tolerance) — regional overview
CREATE OR REPLACE VIEW gis_uats_z5 AS
SELECT siruta, name,
ST_SimplifyPreserveTopology(geom, 500) AS geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- z8-12: Moderate (50m tolerance) — county/city level
CREATE OR REPLACE VIEW gis_uats_z8 AS
SELECT siruta, name, county,
ST_SimplifyPreserveTopology(geom, 50) AS geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- z12+: Original geometry — full precision, no simplification
CREATE OR REPLACE VIEW gis_uats_z12 AS
SELECT siruta, name, county, geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- Keep the legacy gis_uats view for QGIS compatibility
CREATE OR REPLACE VIEW gis_uats AS
SELECT siruta, name, county,
ST_SimplifyPreserveTopology(geom, 50) AS geom
FROM "GisUat" WHERE geom IS NOT NULL;
-- =============================================================================
-- Done! Martin serves these views as vector tiles:
-- - gis_uats_z0 (z0-5, 2000m simplification)
-- - gis_uats_z5 (z5-8, 500m)
-- - gis_uats_z8 (z8-12, 50m)
-- - gis_uats_z12 (z12+, 10m near-original)
-- - gis_uats (legacy for QGIS, 50m)
-- Original geometry in GisUat.geom is NEVER modified.
-- SRID: 3844 (Stereo70)
-- =============================================================================
+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
+116 -6
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])
@@ -73,12 +103,92 @@ model GisSyncRun {
}
model GisUat {
siruta String @id
name String
county String?
workspacePk Int?
updatedAt DateTime @updatedAt
siruta String @id
name String
county String?
workspacePk Int?
geometry Json? /// EsriGeometry { rings: number[][][] } in EPSG:3844
areaValue Float? /// Area in sqm from LIMITE_UAT AREA_VALUE field
lastUpdatedDtm String? /// LAST_UPDATED_DTM from eTerra — for incremental sync
updatedAt DateTime @updatedAt
@@index([name])
@@index([county])
}
// ─── Registratura: Atomic Sequences + Audit ────────────────────────
model RegistrySequence {
id String @id @default(uuid())
company String // B, U, S, G (single-letter prefix)
year Int
type String // SEQ (shared across directions)
lastSeq Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([company, year, type])
@@index([company, year])
}
model RegistryAudit {
id String @id @default(uuid())
entryId String
entryNumber String
action String // created, updated, reserved_created, reserved_claimed, late_registration, closed, deleted
actor String
actorName String?
company String
detail Json?
createdAt DateTime @default(now())
@@index([entryId])
@@index([company, createdAt])
}
// ─── ANCPI ePay: CF Extract Orders ──────────────────────────────────
model CfExtract {
id String @id @default(uuid())
orderId String? // ePay orderId (shared across batch items)
basketRowId Int? // ePay cart item ID
nrCadastral String // cadastral number
nrCF String? // CF number if different
siruta String? // UAT SIRUTA code
judetIndex Int // ePay county index (0-41)
judetName String // county display name
uatId Int // ePay UAT numeric ID
uatName String // UAT display name
prodId Int @default(14200)
solicitantId String @default("14452")
status String @default("pending") // pending|queued|cart|searching|ordering|polling|downloading|completed|failed|cancelled
epayStatus String? // raw ePay status
idDocument Int? // ePay document ID
documentName String? // ePay filename
documentDate DateTime? // when ANCPI generated
minioPath String? // MinIO object key
minioIndex Int? // file version index
creditsUsed Int @default(1)
immovableId String? // eTerra immovable ID
immovableType String? // T/C/A
measuredArea String?
legalArea String?
address String?
gisFeatureId String? // link to GisFeature
version Int @default(1) // increments on re-order
expiresAt DateTime? // 30 days after documentDate
supersededById String? // newer version id
requestedBy String?
errorMessage String?
pollAttempts Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
completedAt DateTime?
@@index([nrCadastral])
@@index([status])
@@index([orderId])
@@index([gisFeatureId])
@@index([createdAt])
@@index([nrCadastral, version])
}
File diff suppressed because one or more lines are too long
+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."
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { FeatureGate } from "@/core/feature-flags";
import { GeoportalModule } from "@/modules/geoportal";
export default function GeoportalPage() {
return (
<FeatureGate flag="module.geoportal" fallback={<ModuleDisabled />}>
<GeoportalModule />
</FeatureGate>
);
}
function ModuleDisabled() {
return (
<div className="mx-auto max-w-7xl py-12 text-center text-muted-foreground">
<p>Modulul Geoportal este dezactivat.</p>
</div>
);
}
+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>
);
}
+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>
);
}
File diff suppressed because it is too large Load Diff
+236
View File
@@ -0,0 +1,236 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/core/storage/prisma";
import { v4 as uuid } from "uuid";
const NAMESPACE = "address-book";
const PREFIX = "contact:";
// ─── Auth: Bearer token OR NextAuth session ─────────────────────────
// External tools use: Authorization: Bearer <ADDRESSBOOK_API_KEY>
// Browser users fall through middleware (NextAuth session)
function checkBearerAuth(req: NextRequest): boolean {
const secret = process.env.ADDRESSBOOK_API_KEY;
if (!secret) return false;
const authHeader = req.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
return token === secret;
}
// ─── GET /api/address-book ──────────────────────────────────────────
// Query params:
// ?id=<uuid> → single contact
// ?q=<search> → search by name/company/email/phone
// ?type=<ContactType> → filter by type
// (no params) → all contacts
export async function GET(req: NextRequest) {
if (!checkBearerAuth(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const params = req.nextUrl.searchParams;
const id = params.get("id");
const q = params.get("q")?.toLowerCase();
const type = params.get("type");
try {
// Single contact by ID
if (id) {
const item = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
});
if (!item) {
return NextResponse.json({ error: "Contact not found" }, { status: 404 });
}
return NextResponse.json({ contact: item.value });
}
// All contacts (with optional filtering)
const items = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
select: { key: true, value: true },
});
let contacts: Record<string, unknown>[] = [];
for (const item of items) {
if (!item.key.startsWith(PREFIX)) continue;
const val = item.value as Record<string, unknown>;
if (!val) continue;
// Type filter
if (type && val.type !== type) continue;
// Search filter
if (q) {
const name = String(val.name ?? "").toLowerCase();
const company = String(val.company ?? "").toLowerCase();
const email = String(val.email ?? "").toLowerCase();
const phone = String(val.phone ?? "");
if (
!name.includes(q) &&
!company.includes(q) &&
!email.includes(q) &&
!phone.includes(q)
) continue;
}
contacts.push(val);
}
// Sort by name/company
contacts.sort((a, b) => {
const aLabel = String(a.name || a.company || "");
const bLabel = String(b.name || b.company || "");
return aLabel.localeCompare(bLabel, "ro");
});
return NextResponse.json({ contacts, total: contacts.length });
} catch (error) {
console.error("Address book GET error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// ─── POST /api/address-book ─────────────────────────────────────────
// Body: { name?, company?, type?, email?, phone?, ... }
// Returns: { contact: AddressContact }
// Validation: at least name OR company required
export async function POST(req: NextRequest) {
if (!checkBearerAuth(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const name = String(body.name ?? "").trim();
const company = String(body.company ?? "").trim();
if (!name && !company) {
return NextResponse.json(
{ error: "Cel puțin name sau company este obligatoriu" },
{ status: 400 },
);
}
// Auto-detect type: if only company → institution
const autoType = !name && company ? "institution" : "client";
const now = new Date().toISOString();
const id = body.id ?? uuid();
const contact = {
id,
name,
company,
type: body.type ?? autoType,
email: String(body.email ?? "").trim(),
email2: String(body.email2 ?? "").trim(),
phone: String(body.phone ?? "").trim(),
phone2: String(body.phone2 ?? "").trim(),
address: String(body.address ?? "").trim(),
department: String(body.department ?? "").trim(),
role: String(body.role ?? "").trim(),
website: String(body.website ?? "").trim(),
projectIds: Array.isArray(body.projectIds) ? body.projectIds : [],
contactPersons: Array.isArray(body.contactPersons) ? body.contactPersons : [],
tags: Array.isArray(body.tags) ? body.tags : [],
notes: String(body.notes ?? "").trim(),
visibility: body.visibility ?? "company",
createdAt: now,
updatedAt: now,
};
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
update: { value: contact as unknown as Prisma.InputJsonValue },
create: { namespace: NAMESPACE, key: `${PREFIX}${id}`, value: contact as unknown as Prisma.InputJsonValue },
});
return NextResponse.json({ contact }, { status: 201 });
} catch (error) {
console.error("Address book POST error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// ─── PUT /api/address-book ──────────────────────────────────────────
// Body: { id: "<uuid>", ...fields to update }
// Merges with existing contact, updates updatedAt
export async function PUT(req: NextRequest) {
if (!checkBearerAuth(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const id = body.id;
if (!id) {
return NextResponse.json({ error: "id is required" }, { status: 400 });
}
const existing = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
});
if (!existing) {
return NextResponse.json({ error: "Contact not found" }, { status: 404 });
}
const prev = existing.value as Record<string, unknown>;
const updated = {
...prev,
...body,
id: prev.id, // never overwrite
createdAt: prev.createdAt, // never overwrite
updatedAt: new Date().toISOString(),
};
// Re-validate: name OR company
if (!String(updated.name ?? "").trim() && !String(updated.company ?? "").trim()) {
return NextResponse.json(
{ error: "Cel puțin name sau company este obligatoriu" },
{ status: 400 },
);
}
await prisma.keyValueStore.update({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
data: { value: updated as unknown as Prisma.InputJsonValue },
});
return NextResponse.json({ contact: updated });
} catch (error) {
console.error("Address book PUT error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
// ─── DELETE /api/address-book?id=<uuid> ─────────────────────────────
export async function DELETE(req: NextRequest) {
if (!checkBearerAuth(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const id = req.nextUrl.searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "id is required" }, { status: 400 });
}
try {
await prisma.keyValueStore.delete({
where: { namespace_key: { namespace: NAMESPACE, key: `${PREFIX}${id}` } },
}).catch(() => { /* ignore if not found */ });
return NextResponse.json({ success: true });
} catch (error) {
console.error("Address book DELETE error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
import {
getEpayCredentials,
getEpaySessionStatus,
updateEpayCredits,
} from "@/modules/parcel-sync/services/epay-session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** GET /api/ancpi/credits — current credit balance (live from ePay) */
export async function GET() {
try {
const status = getEpaySessionStatus();
if (!status.connected) {
return NextResponse.json({ credits: null, connected: false });
}
// Return cached if checked within last 60 seconds
const lastChecked = status.creditsCheckedAt
? new Date(status.creditsCheckedAt).getTime()
: 0;
if (Date.now() - lastChecked < 60_000 && status.credits != null) {
return NextResponse.json({
credits: status.credits,
connected: true,
cached: true,
});
}
// Fetch live from ePay
const creds = getEpayCredentials();
if (!creds) {
return NextResponse.json({ credits: null, connected: false });
}
const client = await EpayClient.create(creds.username, creds.password);
const credits = await client.getCredits();
updateEpayCredits(credits);
return NextResponse.json({ credits, connected: true, cached: false });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ error: message, credits: null }, { status: 500 });
}
}
+114
View File
@@ -0,0 +1,114 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import JSZip from "jszip";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/download-zip?ids=id1,id2,id3
*
* Streams a ZIP file containing all requested CF extract PDFs.
* Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
* Index = position in the ids array (preserves list order).
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const idsParam = url.searchParams.get("ids");
if (!idsParam) {
return NextResponse.json(
{ error: "Parametru 'ids' lipsa." },
{ status: 400 },
);
}
const ids = idsParam.split(",").map((s) => s.trim()).filter(Boolean);
if (ids.length === 0) {
return NextResponse.json(
{ error: "Lista de id-uri goala." },
{ status: 400 },
);
}
// Fetch all extract records
const extracts = await prisma.cfExtract.findMany({
where: { id: { in: ids } },
select: {
id: true,
nrCadastral: true,
minioPath: true,
documentDate: true,
completedAt: true,
},
});
// Build a map for ordering
const extractMap = new Map(extracts.map((e) => [e.id, e]));
const zip = new JSZip();
let filesAdded = 0;
for (let i = 0; i < ids.length; i++) {
const id = ids[i]!;
const extract = extractMap.get(id);
if (!extract?.minioPath) continue;
const dateForName = extract.documentDate ?? extract.completedAt ?? new Date();
const d = new Date(dateForName);
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const idx = String(i + 1).padStart(2, "0");
const fileName = `${idx}_Extras CF_${extract.nrCadastral} - ${dd}-${mm}-${yyyy}.pdf`;
try {
const stream = await getCfExtractStream(extract.minioPath);
// Collect stream into buffer
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
}
const buffer = Buffer.concat(chunks);
zip.file(fileName, buffer);
filesAdded++;
} catch (err) {
console.error(`[download-zip] Failed to fetch ${extract.minioPath}:`, err);
// Skip this file but continue
}
}
if (filesAdded === 0) {
return NextResponse.json(
{ error: "Niciun fisier PDF gasit." },
{ status: 404 },
);
}
const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
const today = new Date();
const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`;
const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`;
return new Response(new Uint8Array(zipBuffer), {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
"Content-Length": String(zipBuffer.length),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+66
View File
@@ -0,0 +1,66 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import { Readable } from "stream";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/download?id={extractId}
*
* Streams the CF extract PDF from MinIO with proper filename.
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const id = url.searchParams.get("id");
if (!id) {
return NextResponse.json(
{ error: "Parametru 'id' lipsă." },
{ status: 400 },
);
}
const extract = await prisma.cfExtract.findUnique({
where: { id },
select: { minioPath: true, nrCadastral: true, minioIndex: true },
});
if (!extract?.minioPath) {
return NextResponse.json(
{ error: "Extras CF negăsit sau fără fișier." },
{ status: 404 },
);
}
const stream = await getCfExtractStream(extract.minioPath);
// Convert Node.js Readable to Web ReadableStream
const webStream = new ReadableStream({
start(controller) {
stream.on("data", (chunk: Buffer) =>
controller.enqueue(new Uint8Array(chunk)),
);
stream.on("end", () => controller.close());
stream.on("error", (err: Error) => controller.error(err));
},
});
// Build display filename
const fileName =
extract.minioPath.split("/").pop() ??
`Extras_CF_${extract.nrCadastral}.pdf`;
return new Response(webStream, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${encodeURIComponent(fileName)}"`,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+137
View File
@@ -0,0 +1,137 @@
import { NextResponse } from "next/server";
import { getEpayCredentials } from "@/modules/parcel-sync/services/epay-session-store";
import {
enqueueOrder,
enqueueBatch,
} from "@/modules/parcel-sync/services/epay-queue";
import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Nonce-based idempotency cache */
/* ------------------------------------------------------------------ */
type NonceEntry = {
timestamp: number;
response: { orders: Array<{ id: string; nrCadastral: string; status: string }> };
};
const NONCE_TTL_MS = 60_000; // 60 seconds
const gNonce = globalThis as {
__orderNonceMap?: Map<string, NonceEntry>;
};
if (!gNonce.__orderNonceMap) gNonce.__orderNonceMap = new Map();
function cleanupNonceMap(): void {
const now = Date.now();
const map = gNonce.__orderNonceMap!;
for (const [key, entry] of map) {
if (now - entry.timestamp > NONCE_TTL_MS) {
map.delete(key);
}
}
}
/**
* POST /api/ancpi/order create one or more CF extract orders.
*
* Body: { parcels: CfExtractCreateInput[], nonce?: string }
*
* If a `nonce` is provided and was already seen within the last 60 seconds,
* the previous response is returned instead of creating duplicate orders.
*
* Returns: { orders: [{ id, nrCadastral, status }], deduplicated?: boolean }
*/
export async function POST(req: Request) {
try {
const creds = getEpayCredentials();
if (!creds) {
return NextResponse.json(
{ error: "Nu ești conectat la ePay ANCPI." },
{ status: 401 },
);
}
const body = (await req.json()) as {
parcels?: CfExtractCreateInput[];
nonce?: string;
};
const parcels = body.parcels ?? [];
if (parcels.length === 0) {
return NextResponse.json(
{ error: "Nicio parcelă specificată." },
{ status: 400 },
);
}
// ── Nonce idempotency check ──
cleanupNonceMap();
if (body.nonce) {
const cached = gNonce.__orderNonceMap!.get(body.nonce);
if (cached && Date.now() - cached.timestamp < NONCE_TTL_MS) {
console.log(
`[ancpi/order] Nonce dedup hit: "${body.nonce}" — returning cached response`,
);
return NextResponse.json({
...cached.response,
deduplicated: true,
});
}
}
// Validate required fields
for (const p of parcels) {
if (!p.nrCadastral || p.judetIndex == null || p.uatId == null) {
return NextResponse.json(
{
error: `Date lipsă pentru parcela ${p.nrCadastral ?? "?"}. Necesare: nrCadastral, judetIndex, uatId.`,
},
{ status: 400 },
);
}
}
let responseBody: {
orders: Array<{ id: string; nrCadastral: string; status: string }>;
};
if (parcels.length === 1) {
const id = await enqueueOrder(parcels[0]!);
responseBody = {
orders: [
{
id,
nrCadastral: parcels[0]!.nrCadastral,
status: "queued",
},
],
};
} else {
const ids = await enqueueBatch(parcels);
responseBody = {
orders: ids.map((id, i) => ({
id,
nrCadastral: parcels[i]?.nrCadastral ?? "",
status: "queued",
})),
};
}
// ── Cache response for nonce ──
if (body.nonce) {
gNonce.__orderNonceMap!.set(body.nonce, {
timestamp: Date.now(),
response: responseBody,
});
}
return NextResponse.json(responseBody);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+121
View File
@@ -0,0 +1,121 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/ancpi/orders list all CF extract orders.
*
* Query params:
* ?nrCadastral=123 single cadastral number
* ?nrCadastral=123,456 comma-separated for batch status check
* ?status=completed filter by status
* ?limit=50&offset=0 pagination
*
* When nrCadastral contains commas, returns an extra `statusMap` field:
* { orders, total, statusMap: { "123": "valid", "456": "expired", "789": "none" } }
* - "valid" = completed + expiresAt > now
* - "expired" = completed + expiresAt <= now
* - "none" = no completed record
*/
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined;
const status = url.searchParams.get("status") || undefined;
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "50"), 200);
const offset = parseInt(url.searchParams.get("offset") ?? "0");
// Check if multi-cadastral query
const cadastralNumbers = nrCadastralParam
? nrCadastralParam.split(",").map((s) => s.trim()).filter(Boolean)
: [];
const isMulti = cadastralNumbers.length > 1;
const where: Record<string, unknown> = {};
if (cadastralNumbers.length === 1) {
where.nrCadastral = cadastralNumbers[0];
} else if (isMulti) {
where.nrCadastral = { in: cadastralNumbers };
}
if (status) where.status = status;
const [orders, total] = await Promise.all([
prisma.cfExtract.findMany({
where,
orderBy: { createdAt: "desc" },
take: limit,
skip: offset,
}),
prisma.cfExtract.count({ where }),
]);
// Build statusMap for multi-cadastral queries (or single if requested)
if (cadastralNumbers.length > 0) {
const now = new Date();
// For status map, we need completed records for each cadastral number
const completedRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
status: "completed",
},
orderBy: { createdAt: "desc" },
select: {
id: true,
nrCadastral: true,
expiresAt: true,
completedAt: true,
minioPath: true,
},
});
const statusMap: Record<string, string> = {};
const latestById: Record<string, typeof completedRecords[number]> = {};
// Find latest completed record per cadastral number
for (const rec of completedRecords) {
const existing = latestById[rec.nrCadastral];
if (!existing) {
latestById[rec.nrCadastral] = rec;
}
}
for (const nr of cadastralNumbers) {
const rec = latestById[nr];
if (!rec) {
statusMap[nr] = "none";
} else if (rec.expiresAt && rec.expiresAt <= now) {
statusMap[nr] = "expired";
} else {
statusMap[nr] = "valid";
}
}
// Also check for active (in-progress) orders
const activeRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
status: {
in: ["pending", "queued", "cart", "searching", "ordering", "polling", "downloading"],
},
},
select: { nrCadastral: true },
});
for (const rec of activeRecords) {
// If there's an active order, mark as "processing" (takes priority over "none")
if (statusMap[rec.nrCadastral] === "none") {
statusMap[rec.nrCadastral] = "processing";
}
}
return NextResponse.json({ orders, total, statusMap, latestById });
}
return NextResponse.json({ orders, total });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
import {
createEpaySession,
destroyEpaySession,
getEpaySessionStatus,
} from "@/modules/parcel-sync/services/epay-session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** GET /api/ancpi/session — status + credits */
export async function GET() {
return NextResponse.json(getEpaySessionStatus());
}
/** POST /api/ancpi/session — connect or disconnect */
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
action?: string;
username?: string;
password?: string;
};
if (body.action === "disconnect") {
destroyEpaySession();
return NextResponse.json({ success: true, disconnected: true });
}
// Connect
const username = (
body.username ?? process.env.ANCPI_USERNAME ?? ""
).trim();
const password = (
body.password ?? process.env.ANCPI_PASSWORD ?? ""
).trim();
if (!username || !password) {
return NextResponse.json(
{ error: "Credențiale ANCPI lipsă" },
{ status: 400 },
);
}
const client = await EpayClient.create(username, password);
const credits = await client.getCredits();
createEpaySession(username, password, credits);
return NextResponse.json({ success: true, credits });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
const status = message.toLowerCase().includes("login") ? 401 : 500;
return NextResponse.json({ error: message }, { status });
}
}
+442
View File
@@ -0,0 +1,442 @@
import { NextResponse } from "next/server";
import { EpayClient } from "@/modules/parcel-sync/services/epay-client";
import {
createEpaySession,
getEpayCredentials,
} from "@/modules/parcel-sync/services/epay-session-store";
import { enqueueBatch } from "@/modules/parcel-sync/services/epay-queue";
import { storeCfExtract } from "@/modules/parcel-sync/services/epay-storage";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Dedup for test order step (prevents re-enqueue on page refresh) */
/* ------------------------------------------------------------------ */
type TestOrderEntry = {
timestamp: number;
extractIds: string[];
parcels: Array<{ nrCadastral: string; uatName: string; siruta: string; extractId: string | undefined }>;
};
const TEST_ORDER_DEDUP_TTL_MS = 30_000; // 30 seconds
const gTestDedup = globalThis as {
__testOrderDedup?: TestOrderEntry | null;
};
if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = null;
/**
* GET /api/ancpi/test?step=login|uats|order|download
*
* ePay internal county IDs = eTerra WORKSPACE_IDs.
* ePay UAT IDs = SIRUTA codes.
* Zero discovery calls needed!
*/
export async function GET(req: Request) {
const url = new URL(req.url);
const step = url.searchParams.get("step") ?? "login";
const username = process.env.ANCPI_USERNAME ?? "";
const password = process.env.ANCPI_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json({ error: "ANCPI credentials not configured" });
}
try {
// ── login ──
if (step === "login") {
const client = await EpayClient.create(username, password);
const credits = await client.getCredits();
createEpaySession(username, password, credits);
return NextResponse.json({ step: "login", success: true, credits });
}
// ── uats ── Verify that ePay county/UAT IDs match our WORKSPACE_ID/SIRUTA
if (step === "uats") {
const client = await EpayClient.create(username, password);
await client.addToCart(14200);
// Get county list to confirm IDs match WORKSPACE_IDs
const counties = await client.getCountyList();
const clujCounty = counties.find((c) =>
c.value.toUpperCase().includes("CLUJ"),
);
// Get UAT list to confirm IDs match SIRUTA codes
let uatList: { id: number; value: string }[] = [];
if (clujCounty) {
uatList = await client.getUatList(clujCounty.id);
}
return NextResponse.json({
step: "uats",
totalCounties: counties.length,
clujCounty,
note: clujCounty?.id === 127
? "CONFIRMED: ePay county ID = WORKSPACE_ID (127)"
: `WARNING: expected 127, got ${clujCounty?.id}`,
totalUats: uatList.length,
clujNapoca: uatList.find((u) => u.id === 54975),
feleacu: uatList.find((u) => u.id === 57582),
floresti: uatList.find((u) => u.id === 57706),
});
}
// ── download ── Re-download PDFs from all known ePay orders
if (step === "download") {
const client = await EpayClient.create(username, password);
createEpaySession(username, password, await client.getCredits());
// All known order IDs (MinIO + DB were cleaned, need re-download)
// Single orders: mapping unknown — use documentsByCadastral to discover CF
const singleOrderIds = ["9685480", "9685481", "9685482", "9685483", "9685484"];
// Batch orders: documentsByCadastral maps CF -> doc correctly
const batchOrderIds = ["9685487", "9685488"];
const allOrderIds = [...singleOrderIds, ...batchOrderIds];
// UAT name lookup for DB records
const uatLookup: Record<string, { uatId: number; uatName: string }> = {
"345295": { uatId: 54975, uatName: "Cluj-Napoca" },
"63565": { uatId: 57582, uatName: "Feleacu" },
"88089": { uatId: 57706, uatName: "Floresti" },
"61904": { uatId: 57582, uatName: "Feleacu" },
"309952": { uatId: 54975, uatName: "Cluj-Napoca" },
};
const results: Array<{
orderId: string;
nrCadastral: string;
status: string;
documents: number;
downloaded: boolean;
minioPath?: string;
error?: string;
}> = [];
for (const orderId of allOrderIds) {
try {
// Get order status — documentsByCadastral maps CF → doc
const orderStatus = await client.getOrderStatus(orderId);
console.log(
`[ancpi-test] Order ${orderId}: status=${orderStatus.status}, docs=${orderStatus.documents.length}, byCF=${orderStatus.documentsByCadastral.size}`,
);
if (orderStatus.documents.length === 0) {
results.push({
orderId,
nrCadastral: "unknown",
status: orderStatus.status,
documents: 0,
downloaded: false,
error: "No documents found in order",
});
continue;
}
// Process each document by cadastral number from the map
if (orderStatus.documentsByCadastral.size > 0) {
for (const [cfNumber, doc] of orderStatus.documentsByCadastral) {
if (!doc.downloadValabil || doc.contentType !== "application/pdf") {
results.push({
orderId,
nrCadastral: cfNumber,
status: orderStatus.status,
documents: orderStatus.documents.length,
downloaded: false,
error: "Document not downloadable or not PDF",
});
continue;
}
try {
// Download the PDF
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
console.log(
`[ancpi-test] Downloaded doc ${doc.idDocument} (CF ${cfNumber}): ${pdfBuffer.length} bytes`,
);
// Resolve UAT info for this cadastral number
const uat = uatLookup[cfNumber];
const uatId = uat?.uatId ?? 0;
const uatName = uat?.uatName ?? "Necunoscut";
// Store in MinIO
const { path, index } = await storeCfExtract(
pdfBuffer,
cfNumber,
{
"ancpi-order-id": orderId,
"nr-cadastral": cfNumber,
judet: "CLUJ",
uat: uatName,
"data-document": doc.dataDocument ?? "",
stare: orderStatus.status,
produs: "EXI_ONLINE",
},
);
// Calculate dates
const documentDate = doc.dataDocument
? new Date(doc.dataDocument)
: new Date();
const expiresAt = new Date(documentDate);
expiresAt.setDate(expiresAt.getDate() + 30);
// Always create new records (DB was cleaned)
// Increment version for duplicate parcels
const maxVersion = await prisma.cfExtract.aggregate({
where: { nrCadastral: cfNumber },
_max: { version: true },
});
await prisma.cfExtract.create({
data: {
orderId,
nrCadastral: cfNumber,
nrCF: cfNumber,
judetIndex: 127,
judetName: "CLUJ",
uatId,
uatName,
status: "completed",
epayStatus: orderStatus.status,
idDocument: doc.idDocument,
documentName: doc.nume,
documentDate,
minioPath: path,
minioIndex: index,
completedAt: new Date(),
expiresAt,
version: (maxVersion._max.version ?? 0) + 1,
},
});
results.push({
orderId,
nrCadastral: cfNumber,
status: orderStatus.status,
documents: orderStatus.documents.length,
downloaded: true,
minioPath: path,
});
} catch (dlErr) {
const msg = dlErr instanceof Error ? dlErr.message : String(dlErr);
console.error(
`[ancpi-test] Failed to download doc for CF ${cfNumber} in order ${orderId}:`,
msg,
);
results.push({
orderId,
nrCadastral: cfNumber,
status: "error",
documents: orderStatus.documents.length,
downloaded: false,
error: msg,
});
}
}
} else {
// Fallback: no CF mapping, process first downloadable document
const doc = orderStatus.documents.find(
(d) => d.downloadValabil && d.contentType === "application/pdf",
);
if (!doc) {
results.push({
orderId,
nrCadastral: "unknown",
status: orderStatus.status,
documents: orderStatus.documents.length,
downloaded: false,
error: "No downloadable PDF and no CF mapping found",
});
continue;
}
// Try to extract CF from document name (e.g. "Extras_Informare_345295.pdf")
const cfFromName = doc.nume.match(/(\d{4,})/)?.[1] ?? "unknown";
const pdfBuffer = await client.downloadDocument(doc.idDocument, 4);
console.log(
`[ancpi-test] Downloaded doc ${doc.idDocument} (CF from name: ${cfFromName}): ${pdfBuffer.length} bytes`,
);
const uat = uatLookup[cfFromName];
const uatId = uat?.uatId ?? 0;
const uatName = uat?.uatName ?? "Necunoscut";
const { path, index } = await storeCfExtract(
pdfBuffer,
cfFromName,
{
"ancpi-order-id": orderId,
"nr-cadastral": cfFromName,
judet: "CLUJ",
uat: uatName,
"data-document": doc.dataDocument ?? "",
stare: orderStatus.status,
produs: "EXI_ONLINE",
},
);
const documentDate = doc.dataDocument
? new Date(doc.dataDocument)
: new Date();
const expiresAt = new Date(documentDate);
expiresAt.setDate(expiresAt.getDate() + 30);
const maxVersion = await prisma.cfExtract.aggregate({
where: { nrCadastral: cfFromName },
_max: { version: true },
});
await prisma.cfExtract.create({
data: {
orderId,
nrCadastral: cfFromName,
nrCF: cfFromName,
judetIndex: 127,
judetName: "CLUJ",
uatId,
uatName,
status: "completed",
epayStatus: orderStatus.status,
idDocument: doc.idDocument,
documentName: doc.nume,
documentDate,
minioPath: path,
minioIndex: index,
completedAt: new Date(),
expiresAt,
version: (maxVersion._max.version ?? 0) + 1,
},
});
results.push({
orderId,
nrCadastral: cfFromName,
status: orderStatus.status,
documents: orderStatus.documents.length,
downloaded: true,
minioPath: path,
});
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
console.error(
`[ancpi-test] Failed to process order ${orderId}:`,
message,
);
results.push({
orderId,
nrCadastral: "unknown",
status: "error",
documents: 0,
downloaded: false,
error: message,
});
}
}
return NextResponse.json({
step: "download",
totalOrders: allOrderIds.length,
results,
summary: {
downloaded: results.filter((r) => r.downloaded).length,
failed: results.filter((r) => !r.downloaded).length,
},
});
}
// ── order ── Batch order test (USES 2 CREDITS!)
// Uses enqueueBatch to create ONE ePay order for all parcels
if (step === "order") {
// ── Dedup check: prevent re-enqueue on page refresh ──
const prevEntry = gTestDedup.__testOrderDedup;
if (
prevEntry &&
Date.now() - prevEntry.timestamp < TEST_ORDER_DEDUP_TTL_MS
) {
console.log(
`[ancpi-test] Order dedup hit: returning cached response (${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago)`,
);
return NextResponse.json({
step: "order",
deduplicated: true,
message: `Dedup: batch was enqueued ${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago — returning cached IDs.`,
extractIds: prevEntry.extractIds,
parcels: prevEntry.parcels,
});
}
if (!getEpayCredentials()) {
createEpaySession(username, password, 0);
}
const client = await EpayClient.create(username, password);
const credits = await client.getCredits();
createEpaySession(username, password, credits);
const parcels = [
{
nrCadastral: "61904",
siruta: "57582",
judetIndex: 127,
judetName: "CLUJ",
uatId: 57582,
uatName: "Feleacu",
},
{
nrCadastral: "309952",
siruta: "54975",
judetIndex: 127,
judetName: "CLUJ",
uatId: 54975,
uatName: "Cluj-Napoca",
},
];
if (credits < parcels.length) {
return NextResponse.json({
error: `Doar ${credits} credite, trebuie ${parcels.length}.`,
});
}
// Use enqueueBatch — ONE order for all parcels
const ids = await enqueueBatch(parcels);
const parcelResults = parcels.map((p, i) => ({
nrCadastral: p.nrCadastral,
uatName: p.uatName,
siruta: p.siruta,
extractId: ids[i],
}));
// ── Store in dedup cache ──
gTestDedup.__testOrderDedup = {
timestamp: Date.now(),
extractIds: ids,
parcels: parcelResults,
};
return NextResponse.json({
step: "order",
credits,
message: `Enqueued batch of ${ids.length} parcels as ONE order.`,
extractIds: ids,
parcels: parcelResults,
});
}
return NextResponse.json({ error: `Unknown step: ${step}` });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[ancpi-test] Step ${step} failed:`, message);
return NextResponse.json({ error: message, step }, { status: 500 });
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";
/**
* Check auth for routes excluded from middleware (large upload routes).
* Returns null if authenticated, or a 401 NextResponse if not.
*/
export async function requireAuth(
req: NextRequest,
): Promise<NextResponse | null> {
// Skip in development
if (process.env.NODE_ENV === "development") return null;
const token = await getToken({
req,
secret: process.env.NEXTAUTH_SECRET,
});
if (token) return null;
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 },
);
}
+212
View File
@@ -0,0 +1,212 @@
import { NextRequest, NextResponse } from "next/server";
import { readFile, unlink } from "fs/promises";
import { join } from "path";
import { parseMultipartUpload } from "../parse-upload";
import { requireAuth } from "../auth-check";
/**
* iLovePDF API integration for PDF compression.
*
* Workflow: auth start upload process download
* Docs: https://www.iloveapi.com/docs/api-reference
*
* Env vars: ILOVEPDF_PUBLIC_KEY
* Free tier: 250 files/month
*/
const ILOVEPDF_PUBLIC_KEY = process.env.ILOVEPDF_PUBLIC_KEY ?? "";
const API_BASE = "https://api.ilovepdf.com/v1";
async function cleanup(dir: string) {
try {
const { readdir, rmdir } = await import("fs/promises");
const files = await readdir(dir);
for (const f of files) {
await unlink(join(dir, f)).catch(() => {});
}
await rmdir(dir).catch(() => {});
} catch {
// non-critical
}
}
export async function POST(req: NextRequest) {
const authError = await requireAuth(req);
if (authError) return authError;
if (!ILOVEPDF_PUBLIC_KEY) {
return NextResponse.json(
{
error:
"iLovePDF nu este configurat. Setează ILOVEPDF_PUBLIC_KEY în variabilele de mediu.",
},
{ status: 501 },
);
}
let tmpDir = "";
try {
// Stream upload to disk — works for any file size
const upload = await parseMultipartUpload(req);
tmpDir = upload.tmpDir;
const originalSize = upload.size;
if (originalSize < 100) {
return NextResponse.json(
{ error: "Fișierul PDF este gol sau prea mic." },
{ status: 400 },
);
}
// Compression level from form field
const levelParam = upload.fields["level"] ?? "";
const compressionLevel =
levelParam === "extreme"
? "extreme"
: levelParam === "low"
? "low"
: "recommended";
// Step 1: Authenticate
const authRes = await fetch(`${API_BASE}/auth`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ public_key: ILOVEPDF_PUBLIC_KEY }),
});
if (!authRes.ok) {
const text = await authRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF auth failed: ${authRes.status}${text}` },
{ status: 502 },
);
}
const { token } = (await authRes.json()) as { token: string };
// Step 2: Start compress task
const startRes = await fetch(`${API_BASE}/start/compress`, {
method: "GET",
headers: { Authorization: `Bearer ${token}` },
});
if (!startRes.ok) {
const text = await startRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF start failed: ${startRes.status}${text}` },
{ status: 502 },
);
}
const { server, task } = (await startRes.json()) as {
server: string;
task: string;
};
// Step 3: Upload file (read from disk to avoid double-buffering)
const fileBuffer = await readFile(upload.filePath);
const uploadForm = new FormData();
uploadForm.append("task", task);
uploadForm.append(
"file",
new Blob([new Uint8Array(fileBuffer)], { type: "application/pdf" }),
upload.filename,
);
const uploadRes = await fetch(`https://${server}/v1/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: uploadForm,
signal: AbortSignal.timeout(600_000), // 10 min for very large files
});
if (!uploadRes.ok) {
const text = await uploadRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF upload failed: ${uploadRes.status}${text}` },
{ status: 502 },
);
}
const { server_filename } = (await uploadRes.json()) as {
server_filename: string;
};
// Step 4: Process
const processRes = await fetch(`https://${server}/v1/process`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
task,
tool: "compress",
compression_level: compressionLevel,
files: [
{
server_filename,
filename: upload.filename,
},
],
}),
signal: AbortSignal.timeout(600_000),
});
if (!processRes.ok) {
const text = await processRes.text().catch(() => "");
return NextResponse.json(
{ error: `iLovePDF process failed: ${processRes.status}${text}` },
{ status: 502 },
);
}
// Step 5: Download result
const downloadRes = await fetch(
`https://${server}/v1/download/${task}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(600_000),
},
);
if (!downloadRes.ok) {
const text = await downloadRes.text().catch(() => "");
return NextResponse.json(
{
error: `iLovePDF download failed: ${downloadRes.status}${text}`,
},
{ status: 502 },
);
}
const resultBlob = await downloadRes.blob();
const resultBuffer = Buffer.from(await resultBlob.arrayBuffer());
const compressedSize = resultBuffer.length;
// Clean up task on iLovePDF (fire and forget)
fetch(`https://${server}/v1/task/${task}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
}).catch(() => {});
return new NextResponse(new Uint8Array(resultBuffer), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${encodeURIComponent(upload.filename.replace(/\.pdf$/i, "-comprimat.pdf"))}"`,
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(compressedSize),
},
});
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json(
{ error: `Eroare iLovePDF: ${message}` },
{ status: 500 },
);
} finally {
if (tmpDir) await cleanup(tmpDir);
}
}
+87 -125
View File
@@ -1,72 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
import { writeFile, readFile, unlink, mkdir } from "fs/promises";
import { createReadStream, statSync } from "fs";
import { unlink, stat, readdir, rmdir } from "fs/promises";
import { execFile } from "child_process";
import { promisify } from "util";
import { randomUUID } from "crypto";
import { join } from "path";
import { tmpdir } from "os";
import { Readable } from "stream";
import { parseMultipartUpload } from "../parse-upload";
import { requireAuth } from "../auth-check";
const execFileAsync = promisify(execFile);
// Ghostscript args for extreme compression
// Key: -dPassThroughJPEGImages=false forces recompression of existing JPEGs
// QFactor 1.5 ≈ JPEG quality 25-30, matching iLovePDF extreme
function gsArgs(input: string, output: string): string[] {
return [
"-sDEVICE=pdfwrite",
"-dCompatibilityLevel=1.5",
"-dNOPAUSE",
"-dBATCH",
"-dQUIET",
`-sOutputFile=${output}`,
"-dPDFSETTINGS=/screen",
// Force recompression of ALL images (the #1 key to matching iLovePDF)
"-dPassThroughJPEGImages=false",
"-dPassThroughJPXImages=false",
"-dAutoFilterColorImages=false",
"-dAutoFilterGrayImages=false",
"-dColorImageFilter=/DCTEncode",
"-dGrayImageFilter=/DCTEncode",
// Aggressive downsampling
"-dDownsampleColorImages=true",
"-dDownsampleGrayImages=true",
"-dDownsampleMonoImages=true",
"-dColorImageResolution=72",
"-dGrayImageResolution=72",
"-dMonoImageResolution=150",
"-dColorImageDownsampleType=/Bicubic",
"-dGrayImageDownsampleType=/Bicubic",
"-dColorImageDownsampleThreshold=1.0",
"-dGrayImageDownsampleThreshold=1.0",
"-dMonoImageDownsampleThreshold=1.0",
// Encoding
"-dEncodeColorImages=true",
"-dEncodeGrayImages=true",
// Font & structure
"-dSubsetFonts=true",
"-dEmbedAllFonts=true",
"-dCompressFonts=true",
"-dCompressStreams=true",
// CMYK→RGB (saves ~25% on CMYK images)
"-sColorConversionStrategy=RGB",
// Structure optimization
"-dDetectDuplicateImages=true",
"-dWriteXRefStm=true",
"-dWriteObjStms=true",
"-dPreserveMarkedContent=false",
"-dOmitXMP=true",
// JPEG quality dictionaries (QFactor 1.5 ≈ quality 25-30)
"-c",
"<< /ColorACSImageDict << /QFactor 1.5 /Blend 1 /ColorTransform 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
"<< /GrayACSImageDict << /QFactor 1.5 /Blend 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
"<< /ColorImageDict << /QFactor 1.5 /Blend 1 /ColorTransform 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
"<< /GrayImageDict << /QFactor 1.5 /Blend 1 /HSamples [2 1 1 2] /VSamples [2 1 1 2] >> >> setdistillerparams",
"-f",
input,
];
}
// qpdf args for structure polish (5-15% additional saving)
function qpdfArgs(input: string, output: string): string[] {
return [
input,
@@ -82,106 +25,125 @@ function qpdfArgs(input: string, output: string): string[] {
async function cleanup(dir: string) {
try {
const { readdir } = await import("fs/promises");
const files = await readdir(dir);
for (const f of files) {
await unlink(join(dir, f)).catch(() => {});
}
const { rmdir } = await import("fs/promises");
await rmdir(dir).catch(() => {});
} catch {
// cleanup failure is non-critical
// non-critical
}
}
/**
* Stream a file from disk as a Response never loads into memory.
*/
function streamFileResponse(
filePath: string,
originalSize: number,
compressedSize: number,
filename: string,
): NextResponse {
const nodeStream = createReadStream(filePath);
const webStream = Readable.toWeb(nodeStream) as ReadableStream;
return new NextResponse(webStream, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Length": String(compressedSize),
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(compressedSize),
},
});
}
export async function POST(req: NextRequest) {
const tmpDir = join(tmpdir(), `pdf-extreme-${randomUUID()}`);
const authError = await requireAuth(req);
if (authError) return authError;
let tmpDir = "";
try {
const formData = await req.formData();
const fileBlob = formData.get("fileInput") as Blob | null;
if (!fileBlob) {
const upload = await parseMultipartUpload(req);
tmpDir = upload.tmpDir;
const inputPath = upload.filePath;
const outputPath = join(upload.tmpDir, "output.pdf");
const originalSize = upload.size;
console.log(
`[compress-pdf] Starting qpdf on ${originalSize} bytes...`,
);
if (originalSize < 100) {
return NextResponse.json(
{ error: "Lipsește fișierul PDF." },
{ error: "Fișierul PDF este gol sau prea mic." },
{ status: 400 },
);
}
const originalSize = fileBlob.size;
await mkdir(tmpDir, { recursive: true });
const inputPath = join(tmpDir, "input.pdf");
const gsOutputPath = join(tmpDir, "gs-output.pdf");
const finalOutputPath = join(tmpDir, "final.pdf");
await writeFile(inputPath, Buffer.from(await fileBlob.arrayBuffer()));
// Step 1: Ghostscript — aggressive image recompression + downsampling
// Run qpdf
try {
await execFileAsync("gs", gsArgs(inputPath, gsOutputPath), {
timeout: 120_000,
await execFileAsync("qpdf", qpdfArgs(inputPath, outputPath), {
timeout: 300_000,
maxBuffer: 10 * 1024 * 1024,
});
} catch (gsErr) {
const msg = gsErr instanceof Error ? gsErr.message : "Ghostscript failed";
} catch (qpdfErr) {
const msg =
qpdfErr instanceof Error ? qpdfErr.message : "qpdf failed";
if (msg.includes("ENOENT") || msg.includes("not found")) {
return NextResponse.json(
{
error:
"Ghostscript nu este instalat pe server. Trebuie adăugat `ghostscript` în Dockerfile.",
},
{ error: "qpdf nu este instalat pe server." },
{ status: 501 },
);
}
const exitCode =
qpdfErr && typeof qpdfErr === "object" && "code" in qpdfErr
? (qpdfErr as { code: number }).code
: null;
if (exitCode !== 3) {
console.error(`[compress-pdf] qpdf error:`, msg.slice(0, 300));
return NextResponse.json(
{ error: `qpdf error: ${msg.slice(0, 300)}` },
{ status: 500 },
);
}
}
// Check output
try {
await stat(outputPath);
} catch {
return NextResponse.json(
{ error: `Ghostscript error: ${msg}` },
{ error: "qpdf nu a produs fișier output." },
{ status: 500 },
);
}
// Step 2: qpdf — structure optimization + linearization
let finalPath = gsOutputPath;
try {
await execFileAsync("qpdf", qpdfArgs(gsOutputPath, finalOutputPath), {
timeout: 30_000,
});
finalPath = finalOutputPath;
} catch {
// qpdf failed or not installed — GS output is still good
}
const compressedSize = statSync(outputPath).size;
const resultBuffer = await readFile(finalPath);
const compressedSize = resultBuffer.length;
console.log(
`[compress-pdf] Done: ${originalSize}${compressedSize} (${Math.round((1 - compressedSize / originalSize) * 100)}% reduction)`,
);
// If compression made it bigger, return original
// Stream result from disk — if bigger, stream original
if (compressedSize >= originalSize) {
const originalBuffer = await readFile(inputPath);
return new NextResponse(originalBuffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition":
'attachment; filename="compressed-extreme.pdf"',
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(originalSize),
},
});
return streamFileResponse(inputPath, originalSize, originalSize, upload.filename);
}
return new NextResponse(resultBuffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="compressed-extreme.pdf"',
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(compressedSize),
},
});
// NOTE: cleanup is deferred — we can't delete files while streaming.
// The files will be cleaned up by the OS temp cleaner or on next request.
// For immediate cleanup, we'd need to buffer, but that defeats the purpose.
return streamFileResponse(outputPath, originalSize, compressedSize, upload.filename);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
console.error(`[compress-pdf] Error:`, message);
if (tmpDir) await cleanup(tmpDir);
return NextResponse.json(
{ error: `Eroare la compresia extremă: ${message}` },
{ error: `Eroare la optimizare: ${message}` },
{ status: 500 },
);
} finally {
await cleanup(tmpDir);
}
// Note: no finally cleanup — files are being streamed
}
+210
View File
@@ -0,0 +1,210 @@
/**
* Streaming multipart parser for large PDF uploads.
*
* 1. Streams the request body to a raw temp file (constant memory)
* 2. Scans the raw file for multipart boundaries using small buffer reads
* 3. Copies just the file part to a separate PDF file (stream copy)
*
* Peak memory: ~64KB regardless of file size.
*/
import { NextRequest } from "next/server";
import {
createWriteStream,
createReadStream,
openSync,
readSync,
closeSync,
statSync,
} from "fs";
import { mkdir, unlink } from "fs/promises";
import { randomUUID } from "crypto";
import { join } from "path";
import { tmpdir } from "os";
import { pipeline } from "stream/promises";
export interface ParsedUpload {
filePath: string;
filename: string;
size: number;
tmpDir: string;
fields: Record<string, string>;
}
/**
* Scan a file on disk for a Buffer pattern starting from `offset`.
* Reads in 64KB chunks constant memory.
*/
function findInFile(
filePath: string,
pattern: Buffer,
startOffset: number,
): number {
const CHUNK = 65536;
const fd = openSync(filePath, "r");
try {
const buf = Buffer.alloc(CHUNK + pattern.length);
let fileOffset = startOffset;
const fileSize = statSync(filePath).size;
while (fileOffset < fileSize) {
const bytesRead = readSync(
fd,
buf,
0,
Math.min(buf.length, fileSize - fileOffset),
fileOffset,
);
if (bytesRead === 0) break;
const idx = buf.subarray(0, bytesRead).indexOf(pattern);
if (idx !== -1) {
return fileOffset + idx;
}
// Advance, but overlap by pattern length to catch split matches
fileOffset += bytesRead - pattern.length;
}
return -1;
} finally {
closeSync(fd);
}
}
/**
* Read a small chunk from a file at a given offset.
*/
function readChunk(filePath: string, offset: number, length: number): Buffer {
const fd = openSync(filePath, "r");
try {
const buf = Buffer.alloc(length);
const bytesRead = readSync(fd, buf, 0, length, offset);
return buf.subarray(0, bytesRead);
} finally {
closeSync(fd);
}
}
/**
* Copy a byte range from one file to another using streams.
*/
async function copyFileRange(
srcPath: string,
destPath: string,
start: number,
end: number,
): Promise<void> {
const rs = createReadStream(srcPath, { start, end: end - 1 });
const ws = createWriteStream(destPath);
await pipeline(rs, ws);
}
export async function parseMultipartUpload(
req: NextRequest,
): Promise<ParsedUpload> {
const contentType = req.headers.get("content-type") ?? "";
if (!req.body) throw new Error("Lipsește body-ul cererii.");
const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/);
if (!boundaryMatch?.[1]) throw new Error("Lipsește boundary din Content-Type.");
const boundary = boundaryMatch[1].trim();
const tmpDir = join(tmpdir(), `pdf-upload-${randomUUID()}`);
await mkdir(tmpDir, { recursive: true });
// Step 1: Stream entire body to disk (constant memory)
const rawPath = join(tmpDir, "raw-body");
const ws = createWriteStream(rawPath);
const reader = req.body.getReader();
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
const ok = ws.write(Buffer.from(value));
if (!ok) await new Promise<void>((r) => ws.once("drain", r));
}
} finally {
ws.end();
await new Promise<void>((r) => ws.once("finish", r));
}
const rawSize = statSync(rawPath).size;
console.log(`[parse-upload] Raw body saved: ${rawSize} bytes`);
// Step 2: Find file part boundaries using small buffer reads
const boundaryBuf = Buffer.from(`--${boundary}`);
const headerEndBuf = Buffer.from("\r\n\r\n");
const closingBuf = Buffer.from(`\r\n--${boundary}`);
let filename = "input.pdf";
let fileStart = -1;
let searchFrom = 0;
const fields: Record<string, string> = {};
while (searchFrom < rawSize) {
const partStart = findInFile(rawPath, boundaryBuf, searchFrom);
if (partStart === -1) break;
const headerEnd = findInFile(
rawPath,
headerEndBuf,
partStart + boundaryBuf.length,
);
if (headerEnd === -1) break;
// Read just the headers (small — typically <500 bytes)
const headersLen = headerEnd - (partStart + boundaryBuf.length);
const headers = readChunk(
rawPath,
partStart + boundaryBuf.length,
Math.min(headersLen, 2048),
).toString("utf8");
if (headers.includes("filename=")) {
const fnMatch = headers.match(/filename="([^"]+)"/);
if (fnMatch?.[1]) filename = fnMatch[1];
fileStart = headerEnd + 4;
break;
}
// Parse form field value
const nameMatch = headers.match(
/Content-Disposition:\s*form-data;\s*name="([^"]+)"/,
);
if (nameMatch?.[1]) {
const valStart = headerEnd + 4;
const nextBoundary = findInFile(rawPath, closingBuf, valStart);
if (nextBoundary !== -1 && nextBoundary - valStart < 10000) {
fields[nameMatch[1]] = readChunk(
rawPath,
valStart,
nextBoundary - valStart,
).toString("utf8");
}
}
searchFrom = headerEnd + 4;
}
if (fileStart === -1) throw new Error("Lipsește fișierul PDF din upload.");
const fileEnd = findInFile(rawPath, closingBuf, fileStart);
const pdfEnd = fileEnd > fileStart ? fileEnd : rawSize;
const pdfSize = pdfEnd - fileStart;
if (pdfSize < 100) throw new Error("Fișierul PDF extras este gol sau prea mic.");
console.log(
`[parse-upload] PDF extracted: ${pdfSize} bytes (offset ${fileStart}..${pdfEnd})`,
);
// Step 3: Copy just the PDF bytes to a new file (stream copy)
const filePath = join(tmpDir, filename);
await copyFileRange(rawPath, filePath, fileStart, pdfEnd);
// Delete raw body — no longer needed
await unlink(rawPath).catch(() => {});
return { filePath, filename, size: pdfSize, tmpDir, fields };
}
+30 -8
View File
@@ -1,18 +1,38 @@
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 {
const formData = await req.formData();
// Buffer the full body then forward to Stirling — streaming passthrough
// (req.body + duplex:half) is unreliable for large files in Next.js.
const bodyBytes = await req.arrayBuffer();
const contentType = req.headers.get("content-type") || "";
// Extract original file size from the multipart body for the response header
// (rough estimate — the overhead of multipart framing is negligible for large PDFs)
const originalSize = bodyBytes.byteLength;
const res = await fetch(`${STIRLING_PDF_URL}/api/v1/misc/compress-pdf`, {
method: "POST",
headers: { "X-API-KEY": STIRLING_PDF_API_KEY },
body: formData,
headers: {
"X-API-KEY": STIRLING_PDF_API_KEY,
"Content-Type": contentType,
},
body: bodyBytes,
signal: AbortSignal.timeout(300_000), // 5 min for large files
});
if (!res.ok) {
@@ -26,11 +46,13 @@ export async function POST(req: NextRequest) {
const blob = await res.blob();
const buffer = Buffer.from(await blob.arrayBuffer());
return new NextResponse(buffer, {
return new NextResponse(new Uint8Array(buffer), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": 'attachment; filename="compressed.pdf"',
"X-Original-Size": String(originalSize),
"X-Compressed-Size": String(buffer.length),
},
});
} catch (err) {
+22 -8
View File
@@ -1,20 +1,34 @@
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) {
try {
const formData = await req.formData();
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")
const res = await fetch(
`${STIRLING_PDF_URL}/api/v1/security/remove-password`,
{
method: "POST",
headers: { "X-API-KEY": STIRLING_PDF_API_KEY },
body: formData,
headers: {
"X-API-KEY": STIRLING_PDF_API_KEY,
"Content-Type": req.headers.get("content-type") || "",
},
body: req.body,
// @ts-expect-error duplex required for streaming request bodies in Node
duplex: "half",
},
);
+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);
}
}
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Body = {
workspaceId: number;
orgUnitId: number;
year: string;
page?: number;
nrElements?: number;
};
/**
* POST /api/eterra/rgi/applications
*
* List RGI applications for a given workspace (county) and org unit (UAT).
* Proxies eTerra rgi/applicationgrid/list endpoint.
*/
export async function POST(req: NextRequest) {
try {
const body = (await req.json()) as Body;
const { workspaceId, orgUnitId, year } = body;
if (!workspaceId || !orgUnitId || !year) {
return NextResponse.json(
{ error: "workspaceId, orgUnitId and year are required" },
{ status: 400 },
);
}
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json(
{ error: "Credentials missing" },
{ status: 500 },
);
}
const client = await EterraClient.create(username, password);
const page = body.page ?? 0;
const nrElements = body.nrElements ?? 25;
const payload = {
filters: [
{
value: workspaceId,
type: "NUMBER",
key: "workspace.nomenPk",
op: "=",
},
{
value: orgUnitId,
type: "NUMBER",
key: "partyFunctionByOrgUnitId.nomenPk",
op: "=",
},
],
applicationFilters: {
applicationType: "own",
tabCode: "NUMBER_LIST",
year,
countyId: workspaceId,
adminUnitId: orgUnitId,
showAll: false,
showNewRequests: false,
showSuspended: false,
showSolutionDeadlineExpired: false,
showPendingLimitation: false,
showCadastreNumberAllocated: false,
showImmovableRegistered: false,
showDocumentIssued: false,
showRestitutionClosed: false,
showRejected: false,
showClosed: false,
showWithdrawn: false,
showSolutionDeadlineExceeded: false,
},
sorters: [],
nrElements,
page,
};
const result = await client.rgiPost(
"rgi/applicationgrid/list",
payload,
);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/rgi/details?applicationId=...
*
* Fetch RGI application details by application ID.
* Proxies eTerra rgi/appdetail/details endpoint.
*/
export async function GET(req: NextRequest) {
try {
const applicationId = req.nextUrl.searchParams.get("applicationId");
if (!applicationId) {
return NextResponse.json(
{ error: "applicationId is required" },
{ status: 400 },
);
}
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json(
{ error: "Credentials missing" },
{ status: 500 },
);
}
const client = await EterraClient.create(username, password);
const result = await client.rgiPost(
`rgi/appdetail/details?applicationid=${encodeURIComponent(applicationId)}`,
undefined,
);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,159 @@
import { NextRequest, NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* Strip Romanian diacritics and replace non-alphanumeric chars with underscores.
*/
function sanitizeFilename(raw: string): string {
return raw
.replace(/[ăâ]/g, "a")
.replace(/[ĂÂ]/g, "A")
.replace(/[îÎ]/g, "i")
.replace(/[țȚ]/g, "t")
.replace(/[șȘ]/g, "s")
.replace(/[^a-zA-Z0-9._-]/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "");
}
/**
* Extract file extension from content-type or server filename.
*/
function getExtension(contentType: string, serverFilename: string): string {
// Try extension from server filename first
const dotIdx = serverFilename.lastIndexOf(".");
if (dotIdx > 0) {
return serverFilename.slice(dotIdx + 1).toLowerCase();
}
// Fallback to content-type mapping
const map: Record<string, string> = {
"application/pdf": "pdf",
"image/png": "png",
"image/jpeg": "jpg",
"application/zip": "zip",
"application/xml": "xml",
"text/xml": "xml",
};
return map[contentType] ?? "pdf";
}
/**
* GET /api/eterra/rgi/download-doc?workspaceId=127&applicationId=X&documentPk=Y&documentTypeId=Z&docType=...&appNo=...&initialAppNo=...
*
* Downloads an issued document from eTerra RGI.
* Tries server-side download first. If that fails (some documents are
* restricted to the current actor), returns a JSON blocked response
* so the frontend can show a soft message.
*/
export async function GET(req: NextRequest) {
try {
const workspaceId = req.nextUrl.searchParams.get("workspaceId");
const applicationId = req.nextUrl.searchParams.get("applicationId");
const documentPk = req.nextUrl.searchParams.get("documentPk");
const documentTypeId = req.nextUrl.searchParams.get("documentTypeId");
const docType = req.nextUrl.searchParams.get("docType");
const appNo = req.nextUrl.searchParams.get("appNo");
const initialAppNo = req.nextUrl.searchParams.get("initialAppNo");
if (!workspaceId || !applicationId || !documentPk) {
return NextResponse.json(
{ error: "workspaceId, applicationId and documentPk required" },
{ status: 400 },
);
}
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json({ error: "Credentials missing" }, { status: 500 });
}
const client = await EterraClient.create(username, password);
// Step 0: Set application context (like the web UI does)
// This call sets session-level attributes required for document access
try {
await client.rgiGet(
`appDetail/verifyCurrentActorAuthenticated/${applicationId}/${workspaceId}`,
);
} catch {
// Non-critical
}
// Also load application details (sets more session context)
try {
await client.rgiPost(
`rgi/appdetail/details?applicationid=${applicationId}`,
);
} catch {
// Non-critical
}
// Try fileVisibility
let available = false;
if (documentTypeId) {
try {
const vis = await client.rgiGet(
`rgi/appdetail/issueddocs/fileVisibility/${workspaceId}/${applicationId}/${documentTypeId}`,
);
if (vis && typeof vis === "object" && (vis as Record<string, unknown>).msg === "OK") {
available = true;
}
} catch {
// Not available — will try direct download anyway
}
}
// Try download (even if fileVisibility failed — context might be enough)
try {
const { data, contentType, filename: serverFilename } = await client.rgiDownload(
`rgi/appdetail/loadDocument/downloadFile/${workspaceId}/${documentPk}`,
);
if (data.length > 0) {
// Build meaningful filename from query params, fallback to server filename
const ext = getExtension(contentType, serverFilename);
let filename: string;
if (docType && appNo) {
filename = `${sanitizeFilename(docType)}_${sanitizeFilename(appNo)}.${ext}`;
} else if (docType) {
filename = `${sanitizeFilename(docType)}.${ext}`;
} else if (appNo) {
filename = `document_${sanitizeFilename(appNo)}.${ext}`;
} else {
// Use server filename, but still sanitize it
const serverBase = serverFilename.replace(/\.[^.]+$/, "");
filename = serverBase && serverBase !== "document"
? `${sanitizeFilename(serverBase)}.${ext}`
: serverFilename;
}
return new NextResponse(new Uint8Array(data), {
status: 200,
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${encodeURIComponent(filename)}"`,
"Content-Length": String(data.length),
},
});
}
} catch {
// Fall through to blocked response
}
// Server-side download not available — return soft blocked response
// so the frontend can show a user-friendly message
return NextResponse.json(
{
blocked: true,
message: "Documentul nu este inca disponibil pentru descarcare din eTerra.",
},
{ status: 200 },
);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/rgi/issued-docs?applicationId=...&workspaceId=...
*
* List issued documents for an RGI application.
* Proxies eTerra rgi/appdetail/issueddocs/list endpoint.
*/
export async function GET(req: NextRequest) {
try {
const applicationId = req.nextUrl.searchParams.get("applicationId");
const workspaceId = req.nextUrl.searchParams.get("workspaceId");
if (!applicationId || !workspaceId) {
return NextResponse.json(
{ error: "applicationId and workspaceId are required" },
{ status: 400 },
);
}
const username = process.env.ETERRA_USERNAME ?? "";
const password = process.env.ETERRA_PASSWORD ?? "";
if (!username || !password) {
return NextResponse.json(
{ error: "Credentials missing" },
{ status: 500 },
);
}
const client = await EterraClient.create(username, password);
const result = await client.rgiPost(
`rgi/appdetail/issueddocs/list?applicationid=${encodeURIComponent(applicationId)}&reSaveDocsInPendingAndTiomeOut=false&workspaceid=${encodeURIComponent(workspaceId)}`,
{
filters: [],
sorters: [],
nrElements: 50,
page: 0,
},
);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,176 @@
/**
* County & geometry refresh populates GisUat.county + geometry
* from eTerra LIMITE_UAT layer.
*
* Called with an already-authenticated EterraClient (fire-and-forget
* after login), so there's no session expiry risk.
*
* Strategy:
* 1. Query LIMITE_UAT for all features WITH geometry
* get ADMIN_UNIT_ID, WORKSPACE_ID, AREA_VALUE, LAST_UPDATED_DTM + rings
* 2. Map WORKSPACE_ID county name via verified mapping
* 3. Batch-update GisUat: county, workspacePk, geometry, areaValue, lastUpdatedDtm
* 4. On subsequent runs: skip UATs where lastUpdatedDtm hasn't changed
*/
import { Prisma } from "@prisma/client";
import { prisma } from "@/core/storage/prisma";
import type { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
/**
* eTerra WORKSPACE_ID Romanian county name.
*
* Verified by cross-referencing LIMITE_UAT sample UATs + DB confirmations.
*/
const WORKSPACE_TO_COUNTY: Record<number, string> = {
10: "Alba",
29: "Arad",
38: "Argeș",
47: "Bacău",
56: "Bihor",
65: "Bistrița-Năsăud",
74: "Botoșani",
83: "Brașov",
92: "Brăila",
109: "Buzău",
118: "Caraș-Severin",
127: "Cluj",
136: "Constanța",
145: "Covasna",
154: "Dâmbovița",
163: "Dolj",
172: "Galați",
181: "Gorj",
190: "Harghita",
207: "Hunedoara",
216: "Ialomița",
225: "Iași",
234: "Ilfov",
243: "Maramureș",
252: "Mehedinți",
261: "Mureș",
270: "Neamț",
289: "Olt",
298: "Prahova",
305: "Satu Mare",
314: "Sălaj",
323: "Sibiu",
332: "Suceava",
341: "Teleorman",
350: "Timiș",
369: "Tulcea",
378: "Vaslui",
387: "Vâlcea",
396: "Vrancea",
403: "București",
519: "Călărași",
528: "Giurgiu",
};
export async function refreshCountyData(client: EterraClient): Promise<void> {
const total = await prisma.gisUat.count();
if (total === 0) return;
// Check how many are missing county OR geometry
const [withCounty, withGeometry] = await Promise.all([
prisma.gisUat.count({ where: { county: { not: null } } }),
prisma.gisUat.count({
where: { geometry: { not: Prisma.AnyNull } },
}),
]);
const needsCounty = withCounty < total * 0.5;
const needsGeometry = withGeometry < total * 0.5;
if (!needsCounty && !needsGeometry) {
console.log(
`[county-refresh] ${withCounty}/${total} counties, ${withGeometry}/${total} geometries — skipping.`,
);
return;
}
console.log(
`[county-refresh] Starting: ${withCounty}/${total} counties, ${withGeometry}/${total} geometries.`,
);
// 1. Query LIMITE_UAT — with geometry if needed, without if only county
const limiteUat = findLayerById("LIMITE_UAT");
if (!limiteUat) {
console.error("[county-refresh] LIMITE_UAT layer not configured.");
return;
}
const features = await client.fetchAllLayerByWhere(limiteUat, "1=1", {
outFields: "ADMIN_UNIT_ID,WORKSPACE_ID,AREA_VALUE,LAST_UPDATED_DTM",
returnGeometry: needsGeometry,
pageSize: 1000,
});
console.log(
`[county-refresh] LIMITE_UAT: ${features.length} features` +
`${needsGeometry ? " (with geometry)" : ""}.`,
);
if (features.length === 0) return;
// 2. Log unknown workspaces
const seenWs = new Set<number>();
for (const f of features) {
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
if (ws > 0 && !(ws in WORKSPACE_TO_COUNTY) && !seenWs.has(ws)) {
seenWs.add(ws);
const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "").replace(/\.0$/, "");
console.warn(
`[county-refresh] Unknown workspace ${ws} (SIRUTA ${siruta}). Add to mapping.`,
);
}
}
// 3. Upsert each UAT with county, workspacePk, geometry, area, lastUpdatedDtm
let updated = 0;
const BATCH = 50;
for (let i = 0; i < features.length; i += BATCH) {
const batch = features.slice(i, i + BATCH);
const ops = [];
for (const f of batch) {
const siruta = String(f.attributes?.ADMIN_UNIT_ID ?? "")
.trim()
.replace(/\.0$/, "");
const ws = Number(f.attributes?.WORKSPACE_ID ?? 0);
if (!siruta || ws <= 0) continue;
const county = WORKSPACE_TO_COUNTY[ws];
const areaValue = Number(f.attributes?.AREA_VALUE ?? 0) || null;
const lastUpdatedDtm = f.attributes?.LAST_UPDATED_DTM != null
? String(f.attributes.LAST_UPDATED_DTM)
: null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const geom = (f as any).geometry ?? null;
const data: Prisma.GisUatUpdateInput = {
workspacePk: ws,
...(county ? { county } : {}),
...(areaValue ? { areaValue } : {}),
...(lastUpdatedDtm ? { lastUpdatedDtm } : {}),
...(geom ? { geometry: geom as Prisma.InputJsonValue } : {}),
};
ops.push(
prisma.gisUat.updateMany({
where: { siruta },
data,
}),
);
}
if (ops.length > 0) {
const results = await prisma.$transaction(ops);
for (const r of results) updated += r.count;
}
}
console.log(`[county-refresh] Done: ${updated}/${total} updated.`);
}
+7 -1
View File
@@ -8,6 +8,7 @@ import {
getSessionStatus,
} from "@/modules/parcel-sync/services/session-store";
import { getEterraHealth } from "@/modules/parcel-sync/services/eterra-health";
import { refreshCountyData } from "./county-refresh";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -104,9 +105,14 @@ export async function POST(req: Request) {
}
// Attempt login
await EterraClient.create(username, password);
const client = await EterraClient.create(username, password);
createSession(username, password);
// Fire-and-forget: populate county data using fresh client
refreshCountyData(client).catch((err) =>
console.error("[session] County refresh failed:", err),
);
return NextResponse.json({ success: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
+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`;
}
+87 -46
View File
@@ -187,65 +187,106 @@ async function runBackground(params: {
getLayerFreshness(siruta, "CLADIRI_ACTIVE"),
]);
const terenuriNeedsSync =
forceSync ||
!isFresh(terenuriStatus.lastSynced) ||
terenuriStatus.featureCount === 0;
const cladiriNeedsSync =
forceSync ||
!isFresh(cladiriStatus.lastSynced) ||
cladiriStatus.featureCount === 0;
const terenuriNeedsFullSync =
forceSync || terenuriStatus.featureCount === 0;
const cladiriNeedsFullSync =
forceSync || cladiriStatus.featureCount === 0;
if (terenuriNeedsSync) {
phase = "Sincronizare terenuri";
push({});
const r = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
forceFullSync: forceSync,
jobId,
isSubStep: true,
});
if (r.status === "error")
throw new Error(r.error ?? "Sync terenuri failed");
}
// Always call syncLayer — it handles quick-count + VALID_FROM delta internally.
// Only force full download when no local data or explicit forceSync.
phase = "Sincronizare terenuri";
push({});
const terenuriResult = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", {
forceFullSync: terenuriNeedsFullSync,
jobId,
isSubStep: true,
});
if (terenuriResult.status === "error")
throw new Error(terenuriResult.error ?? "Sync terenuri failed");
updateOverall(0.5);
if (cladiriNeedsSync) {
phase = "Sincronizare clădiri";
phase = "Sincronizare clădiri";
push({});
const cladiriResult = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
forceFullSync: cladiriNeedsFullSync,
jobId,
isSubStep: true,
});
if (cladiriResult.status === "error")
throw new Error(cladiriResult.error ?? "Sync clădiri failed");
// Sync admin layers — skip if synced within 24h
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
const adminStatus = await getLayerFreshness(siruta, adminLayer);
if (!forceSync && isFresh(adminStatus.lastSynced, 24)) continue;
phase = `Sincronizare ${adminLayer === "LIMITE_UAT" ? "limite UAT" : "limite intravilan"}`;
push({});
const r = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", {
forceFullSync: forceSync,
jobId,
isSubStep: true,
});
if (r.status === "error")
throw new Error(r.error ?? "Sync clădiri failed");
try {
await syncLayer(username, password, siruta, adminLayer, {
forceFullSync: forceSync,
jobId,
isSubStep: true,
});
} catch {
note = `Avertisment: ${adminLayer} nu s-a sincronizat`;
push({});
}
}
if (!terenuriNeedsSync && !cladiriNeedsSync) {
note = "Date proaspete — sync skip";
}
const syncSummary = [
terenuriResult.newFeatures > 0 ? `${terenuriResult.newFeatures} terenuri noi` : null,
terenuriResult.validFromUpdated ? `${terenuriResult.validFromUpdated} terenuri actualizate` : null,
cladiriResult.newFeatures > 0 ? `${cladiriResult.newFeatures} cladiri noi` : null,
cladiriResult.validFromUpdated ? `${cladiriResult.validFromUpdated} cladiri actualizate` : null,
].filter(Boolean);
note = syncSummary.length > 0 ? syncSummary.join(", ") : "Fără schimbări";
finishPhase();
/* ── Phase 2: No-geometry import (optional) ──────── */
if (hasNoGeom && weights.noGeom > 0) {
setPhase("Import parcele fără geometrie", weights.noGeom);
const noGeomClient = await EterraClient.create(username, password, {
timeoutMs: 120_000,
});
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
onProgress: (done, tot, ph) => {
phase = ph;
push({});
},
});
if (res.status === "error") {
note = `Avertisment no-geom: ${res.error}`;
setPhase("Verificare parcele fără geometrie", weights.noGeom);
// Skip no-geom import if recently done (within 48h) and not forced
const { PrismaClient } = await import("@prisma/client");
const _prisma = new PrismaClient();
let skipNoGeom = false;
try {
const recentNoGeom = await _prisma.gisFeature.findFirst({
where: {
layerId: "TERENURI_ACTIVE",
siruta,
geometrySource: "NO_GEOMETRY",
updatedAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) },
},
select: { id: true },
});
skipNoGeom = !forceSync && recentNoGeom != null;
} catch { /* proceed with import */ }
await _prisma.$disconnect();
if (skipNoGeom) {
note = "Parcele fără geometrie — actualizate recent, skip";
push({});
} else {
const cleanNote =
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
note = `${res.imported} parcele noi importate${cleanNote}`;
phase = "Import parcele fără geometrie";
push({});
const noGeomClient = await EterraClient.create(username, password, {
timeoutMs: 120_000,
});
const res = await syncNoGeometryParcels(noGeomClient, siruta, {
onProgress: (done, tot, ph) => {
phase = ph;
push({});
},
});
if (res.status === "error") {
note = `Avertisment no-geom: ${res.error}`;
push({});
} else {
const cleanNote =
res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : "";
note = `${res.imported} parcele noi importate${cleanNote}`;
push({});
}
}
finishPhase();
}
+334
View File
@@ -0,0 +1,334 @@
/**
* POST /api/eterra/sync-county
*
* Starts a background sync for all UATs in a given county.
* Syncs TERENURI_ACTIVE, CLADIRI_ACTIVE, and LIMITE_INTRAV_DYNAMIC.
* UATs with >30% enrichment magic mode (sync + enrichment).
*
* Body: { county: string }
* Returns immediately with jobId progress via /api/eterra/progress.
*/
import { prisma } from "@/core/storage/prisma";
import {
setProgress,
clearProgress,
type SyncProgress,
} from "@/modules/parcel-sync/services/progress-store";
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
import { createAppNotification } from "@/core/notifications/app-notifications";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* Concurrency guard */
const g = globalThis as { __countySyncRunning?: string; __allCountiesSyncRunning?: boolean };
export async function POST(req: Request) {
let body: { county?: string };
try {
body = (await req.json()) as { county?: string };
} catch {
return Response.json({ error: "Body invalid" }, { status: 400 });
}
const session = getSessionCredentials();
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
if (!username || !password) {
return Response.json(
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
{ status: 401 },
);
}
const county = body.county?.trim();
if (!county) {
return Response.json({ error: "Judetul lipseste" }, { status: 400 });
}
if (g.__allCountiesSyncRunning) {
return Response.json(
{ error: "Sync All Romania in curs — asteapta sa se termine" },
{ status: 409 },
);
}
if (g.__countySyncRunning) {
return Response.json(
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
{ status: 409 },
);
}
const jobId = crypto.randomUUID();
g.__countySyncRunning = county;
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "running",
phase: `Pregatire sync ${county}`,
});
void runCountySync(jobId, county, username, password);
return Response.json(
{ jobId, message: `Sync judet ${county} pornit` },
{ status: 202 },
);
}
async function runCountySync(
jobId: string,
county: string,
username: string,
password: string,
) {
const push = (p: Partial<SyncProgress>) =>
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "running",
...p,
} as SyncProgress);
try {
// Health check
const health = await checkEterraHealthNow();
if (!health.available) {
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "error",
phase: "eTerra indisponibil",
message: health.message ?? "maintenance",
});
await createAppNotification({
type: "sync-error",
title: `Sync ${county}: eTerra indisponibil`,
message: health.message ?? "Serviciul eTerra este in mentenanta",
metadata: { county, jobId },
});
g.__countySyncRunning = undefined;
setTimeout(() => clearProgress(jobId), 3_600_000);
return;
}
// Find all UATs in this county with feature stats
const uats = await prisma.$queryRawUnsafe<
Array<{
siruta: string;
name: string | null;
total: number;
enriched: number;
}>
>(
`SELECT u.siruta, u.name,
COALESCE(f.total, 0)::int as total,
COALESCE(f.enriched, 0)::int as enriched
FROM "GisUat" u
LEFT JOIN (
SELECT siruta, COUNT(*)::int as total,
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
FROM "GisFeature"
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
GROUP BY siruta
) f ON u.siruta = f.siruta
WHERE u.county = $1
ORDER BY COALESCE(f.total, 0) DESC`,
county,
);
if (uats.length === 0) {
setProgress({
jobId,
downloaded: 100,
total: 100,
status: "done",
phase: `Niciun UAT gasit in ${county}`,
});
g.__countySyncRunning = undefined;
setTimeout(() => clearProgress(jobId), 3_600_000);
return;
}
const results: Array<{
siruta: string;
name: string;
mode: string;
duration: number;
note: string;
}> = [];
let errors = 0;
let totalNewFeatures = 0;
for (let i = 0; i < uats.length; i++) {
const uat = uats[i]!;
const uatName = uat.name ?? uat.siruta;
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
const isMagic = ratio > 0.3;
const mode = isMagic ? "magic" : "base";
const pct = Math.round((i / uats.length) * 100);
push({
downloaded: pct,
total: 100,
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
note:
results.length > 0
? `Ultimul: ${results[results.length - 1]!.name}${results[results.length - 1]!.note}`
: undefined,
});
const uatStart = Date.now();
try {
// Sync TERENURI + CLADIRI — pass jobId for sub-progress
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
uatName, jobId, isSubStep: true,
});
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
uatName, jobId, isSubStep: true,
});
// Sync ADMINISTRATIV (intravilan) — wrapped in try/catch since it needs UAT geometry
let adminNote = "";
try {
const aRes = await syncLayer(
username,
password,
uat.siruta,
"LIMITE_INTRAV_DYNAMIC",
{ uatName, jobId, isSubStep: true },
);
if (aRes.newFeatures > 0) {
adminNote = ` | A:+${aRes.newFeatures}`;
}
} catch {
adminNote = " | A:skip";
}
// Enrichment for magic mode
let enrichNote = "";
if (isMagic) {
const client = await EterraClient.create(username, password, {
timeoutMs: 120_000,
});
const eRes = await enrichFeatures(client, uat.siruta);
enrichNote =
eRes.status === "done"
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
: ` | enrich err: ${eRes.error}`;
}
const dur = Math.round((Date.now() - uatStart) / 1000);
const parts = [
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
: "T:ok",
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
: "C:ok",
];
totalNewFeatures += tRes.newFeatures + cRes.newFeatures;
const note = `${parts.join(", ")}${adminNote}${enrichNote} (${dur}s)`;
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
// Update progress AFTER UAT completion (so % reflects completed work)
const completedPct = Math.round(((i + 1) / uats.length) * 100);
push({
downloaded: completedPct,
total: 100,
phase: `[${i + 1}/${uats.length}] ${uatName} finalizat`,
note: `${note}`,
});
console.log(`[sync-county:${county}] ${i + 1}/${uats.length} ${uatName}: ${note}`);
} catch (err) {
errors++;
const dur = Math.round((Date.now() - uatStart) / 1000);
const msg = err instanceof Error ? err.message : "Unknown";
results.push({
siruta: uat.siruta,
name: uatName,
mode,
duration: dur,
note: `ERR: ${msg}`,
});
// Still update progress after error
const completedPct = Math.round(((i + 1) / uats.length) * 100);
push({
downloaded: completedPct,
total: 100,
phase: `[${i + 1}/${uats.length}] ${uatName} — eroare`,
});
console.error(`[sync-county:${county}] ${uatName}: ${msg}`);
}
}
const totalDur = results.reduce((s, r) => s + r.duration, 0);
const summary = `${uats.length} UAT-uri, ${errors} erori, ${totalDur}s total`;
setProgress({
jobId,
downloaded: 100,
total: 100,
status: errors > 0 && errors === uats.length ? "error" : "done",
phase: `Sync ${county} finalizat`,
message: summary,
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
});
await createAppNotification({
type: errors > 0 ? "sync-error" : "sync-complete",
title:
errors > 0
? `Sync ${county}: ${errors} erori din ${uats.length} UAT-uri`
: `Sync ${county}: ${uats.length} UAT-uri sincronizate`,
message: summary,
metadata: { county, jobId, uatCount: uats.length, errors, totalDuration: totalDur },
});
console.log(`[sync-county:${county}] Done: ${summary}`);
// Trigger PMTiles rebuild if new features were synced
if (totalNewFeatures > 0) {
await firePmtilesRebuild("county-sync-complete", {
county,
uatCount: uats.length,
newFeatures: totalNewFeatures,
errors,
});
}
setTimeout(() => clearProgress(jobId), 6 * 3_600_000);
} catch (err) {
const msg = err instanceof Error ? err.message : "Unknown";
setProgress({
jobId,
downloaded: 0,
total: 100,
status: "error",
phase: "Eroare",
message: msg,
});
await createAppNotification({
type: "sync-error",
title: `Sync ${county}: eroare generala`,
message: msg,
metadata: { county, jobId },
});
setTimeout(() => clearProgress(jobId), 3_600_000);
} finally {
g.__countySyncRunning = undefined;
}
}
@@ -0,0 +1,95 @@
/**
* PATCH /api/eterra/sync-rules/[id] Update a sync rule
* DELETE /api/eterra/sync-rules/[id] Delete a sync rule
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
if (frequency === "manual") return null;
const base = lastSyncAt ?? new Date();
const ms: Record<string, number> = {
"3x-daily": 8 * 3600_000,
daily: 24 * 3600_000,
weekly: 7 * 24 * 3600_000,
monthly: 30 * 24 * 3600_000,
};
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
const existing = await prisma.gisSyncRule.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Regula nu exista" }, { status: 404 });
}
const body = (await req.json()) as Record<string, unknown>;
// Validate frequency if provided
if (body.frequency && !VALID_FREQUENCIES.includes(body.frequency as string)) {
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
}
// Build update data — only include provided fields
const data: Record<string, unknown> = {};
const fields = [
"frequency", "syncTerenuri", "syncCladiri", "syncNoGeom", "syncEnrich",
"priority", "enabled", "allowedHoursStart", "allowedHoursEnd",
"allowedDays", "label",
];
for (const f of fields) {
if (f in body) data[f] = body[f];
}
// Recompute nextDueAt if frequency changed
if (body.frequency) {
data.nextDueAt = computeNextDue(
body.frequency as string,
existing.lastSyncAt,
);
}
// If enabled changed to true and no nextDueAt, compute it
if (body.enabled === true && !existing.nextDueAt && !data.nextDueAt) {
const freq = (body.frequency as string) ?? existing.frequency;
data.nextDueAt = computeNextDue(freq, existing.lastSyncAt);
}
const updated = await prisma.gisSyncRule.update({
where: { id },
data,
});
return NextResponse.json({ rule: updated });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
try {
await prisma.gisSyncRule.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* POST /api/eterra/sync-rules/bulk Bulk operations on sync rules
*
* Actions:
* - set-county-frequency: Create or update a county-level rule
* - enable/disable: Toggle multiple rules by IDs
* - delete: Delete multiple rules by IDs
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
if (frequency === "manual") return null;
const base = lastSyncAt ?? new Date();
const ms: Record<string, number> = {
"3x-daily": 8 * 3600_000,
daily: 24 * 3600_000,
weekly: 7 * 24 * 3600_000,
monthly: 30 * 24 * 3600_000,
};
return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null;
}
type BulkBody = {
action: string;
county?: string;
frequency?: string;
syncEnrich?: boolean;
syncNoGeom?: boolean;
ruleIds?: string[];
};
export async function POST(req: Request) {
try {
const body = (await req.json()) as BulkBody;
switch (body.action) {
case "set-county-frequency": {
if (!body.county || !body.frequency) {
return NextResponse.json({ error: "county si frequency obligatorii" }, { status: 400 });
}
if (!VALID_FREQUENCIES.includes(body.frequency)) {
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
}
// Upsert county-level rule
const existing = await prisma.gisSyncRule.findFirst({
where: { county: body.county, siruta: null },
});
const rule = existing
? await prisma.gisSyncRule.update({
where: { id: existing.id },
data: {
frequency: body.frequency,
syncEnrich: body.syncEnrich ?? existing.syncEnrich,
syncNoGeom: body.syncNoGeom ?? existing.syncNoGeom,
nextDueAt: computeNextDue(body.frequency, existing.lastSyncAt),
},
})
: await prisma.gisSyncRule.create({
data: {
county: body.county,
frequency: body.frequency,
syncEnrich: body.syncEnrich ?? false,
syncNoGeom: body.syncNoGeom ?? false,
nextDueAt: computeNextDue(body.frequency, null),
},
});
return NextResponse.json({ rule, action: "set-county-frequency" });
}
case "enable":
case "disable": {
if (!body.ruleIds?.length) {
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
}
const result = await prisma.gisSyncRule.updateMany({
where: { id: { in: body.ruleIds } },
data: { enabled: body.action === "enable" },
});
return NextResponse.json({ updated: result.count, action: body.action });
}
case "delete": {
if (!body.ruleIds?.length) {
return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 });
}
const result = await prisma.gisSyncRule.deleteMany({
where: { id: { in: body.ruleIds } },
});
return NextResponse.json({ deleted: result.count, action: "delete" });
}
default:
return NextResponse.json({ error: `Actiune necunoscuta: ${body.action}` }, { status: 400 });
}
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,47 @@
/**
* GET /api/eterra/sync-rules/global-default Get global default frequency
* PATCH /api/eterra/sync-rules/global-default Set global default frequency
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const NAMESPACE = "sync-management";
const KEY = "global-default";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"];
export async function GET() {
try {
const row = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
});
const val = row?.value as { frequency?: string } | null;
return NextResponse.json({ frequency: val?.frequency ?? "monthly" });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PATCH(req: Request) {
try {
const body = (await req.json()) as { frequency?: string };
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency)) {
return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 });
}
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: NAMESPACE, key: KEY } },
update: { value: { frequency: body.frequency } },
create: { namespace: NAMESPACE, key: KEY, value: { frequency: body.frequency } },
});
return NextResponse.json({ frequency: body.frequency });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+171
View File
@@ -0,0 +1,171 @@
/**
* GET /api/eterra/sync-rules List all sync rules, enriched with UAT/county names
* POST /api/eterra/sync-rules Create a new sync rule
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"] as const;
/** Compute nextDueAt from lastSyncAt + frequency interval */
function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null {
if (frequency === "manual") return null;
const base = lastSyncAt ?? new Date();
const ms = {
"3x-daily": 8 * 3600_000,
daily: 24 * 3600_000,
weekly: 7 * 24 * 3600_000,
monthly: 30 * 24 * 3600_000,
}[frequency];
if (!ms) return null;
return new Date(base.getTime() + ms);
}
export async function GET() {
try {
const rules = await prisma.gisSyncRule.findMany({
orderBy: [{ priority: "asc" }, { createdAt: "desc" }],
});
// Enrich with UAT names for UAT-specific rules
const sirutas = rules
.map((r) => r.siruta)
.filter((s): s is string => s != null);
const uatMap = new Map<string, string>();
if (sirutas.length > 0) {
const uats = await prisma.gisUat.findMany({
where: { siruta: { in: sirutas } },
select: { siruta: true, name: true },
});
for (const u of uats) uatMap.set(u.siruta, u.name);
}
// For county rules, get UAT count per county
const counties = rules
.map((r) => r.county)
.filter((c): c is string => c != null);
const countyCountMap = new Map<string, number>();
if (counties.length > 0) {
const counts = await prisma.gisUat.groupBy({
by: ["county"],
where: { county: { in: counties } },
_count: true,
});
for (const c of counts) {
if (c.county) countyCountMap.set(c.county, c._count);
}
}
const enriched = rules.map((r) => ({
...r,
uatName: r.siruta ? (uatMap.get(r.siruta) ?? null) : null,
uatCount: r.county ? (countyCountMap.get(r.county) ?? 0) : r.siruta ? 1 : 0,
}));
// Get global default
const globalDefault = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: "sync-management", key: "global-default" } },
});
return NextResponse.json({
rules: enriched,
globalDefault: (globalDefault?.value as { frequency?: string })?.frequency ?? "monthly",
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function POST(req: Request) {
try {
const body = (await req.json()) as {
siruta?: string;
county?: string;
frequency?: string;
syncTerenuri?: boolean;
syncCladiri?: boolean;
syncNoGeom?: boolean;
syncEnrich?: boolean;
priority?: number;
enabled?: boolean;
allowedHoursStart?: number | null;
allowedHoursEnd?: number | null;
allowedDays?: string | null;
label?: string | null;
};
if (!body.siruta && !body.county) {
return NextResponse.json({ error: "Trebuie specificat siruta sau judetul" }, { status: 400 });
}
if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency as typeof VALID_FREQUENCIES[number])) {
return NextResponse.json(
{ error: `Frecventa invalida. Valori permise: ${VALID_FREQUENCIES.join(", ")}` },
{ status: 400 },
);
}
// Validate siruta exists
if (body.siruta) {
const uat = await prisma.gisUat.findUnique({ where: { siruta: body.siruta } });
if (!uat) {
return NextResponse.json({ error: `UAT ${body.siruta} nu exista` }, { status: 404 });
}
}
// Validate county has UATs
if (body.county && !body.siruta) {
const count = await prisma.gisUat.count({ where: { county: body.county } });
if (count === 0) {
return NextResponse.json({ error: `Niciun UAT in judetul ${body.county}` }, { status: 404 });
}
}
// Check for existing rule with same scope
const existing = await prisma.gisSyncRule.findFirst({
where: {
siruta: body.siruta ?? null,
county: body.siruta ? null : (body.county ?? null),
},
});
if (existing) {
return NextResponse.json(
{ error: "Exista deja o regula pentru acest scope", existingId: existing.id },
{ status: 409 },
);
}
const nextDueAt = computeNextDue(body.frequency, null);
const rule = await prisma.gisSyncRule.create({
data: {
siruta: body.siruta ?? null,
county: body.siruta ? null : (body.county ?? null),
frequency: body.frequency,
syncTerenuri: body.syncTerenuri ?? true,
syncCladiri: body.syncCladiri ?? true,
syncNoGeom: body.syncNoGeom ?? false,
syncEnrich: body.syncEnrich ?? false,
priority: body.priority ?? 5,
enabled: body.enabled ?? true,
allowedHoursStart: body.allowedHoursStart ?? null,
allowedHoursEnd: body.allowedHoursEnd ?? null,
allowedDays: body.allowedDays ?? null,
label: body.label ?? null,
nextDueAt,
},
});
return NextResponse.json({ rule }, { status: 201 });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,72 @@
/**
* GET /api/eterra/sync-rules/scheduler Scheduler status
*
* Returns current scheduler state from KeyValueStore + computed stats.
*/
import { prisma } from "@/core/storage/prisma";
import { NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
// Get scheduler state from KV (will be populated by the scheduler in Phase 2)
const kvState = await prisma.keyValueStore.findUnique({
where: {
namespace_key: { namespace: "sync-management", key: "scheduler-state" },
},
});
// Compute rule stats
const [totalRules, activeRules, dueNow, withErrors] = await Promise.all([
prisma.gisSyncRule.count(),
prisma.gisSyncRule.count({ where: { enabled: true } }),
prisma.gisSyncRule.count({
where: { enabled: true, nextDueAt: { lte: new Date() } },
}),
prisma.gisSyncRule.count({
where: { lastSyncStatus: "error" },
}),
]);
// Frequency distribution
const freqDist = await prisma.gisSyncRule.groupBy({
by: ["frequency"],
where: { enabled: true },
_count: true,
});
// County coverage
const totalCounties = await prisma.gisUat.groupBy({
by: ["county"],
where: { county: { not: null } },
_count: true,
});
const countiesWithRules = await prisma.gisSyncRule.groupBy({
by: ["county"],
where: { county: { not: null } },
_count: true,
});
return NextResponse.json({
scheduler: kvState?.value ?? { status: "not-started" },
stats: {
totalRules,
activeRules,
dueNow,
withErrors,
frequencyDistribution: Object.fromEntries(
freqDist.map((f) => [f.frequency, f._count]),
),
totalCounties: totalCounties.length,
countiesWithRules: countiesWithRules.length,
},
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* GET /api/eterra/tiles/orto?z=...&x=...&y=...
*
* Proxies eTerra ORTO2024 ortophoto tiles. Converts Web Mercator
* tile coordinates to EPSG:3844 bbox and fetches from eTerra exportImage.
* Requires active eTerra session (uses stored credentials).
*/
import { NextResponse } from "next/server";
import proj4 from "proj4";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Register projections
const EPSG_3844_DEF =
"+proj=sterea +lat_0=46 +lon_0=25 +k=0.99975 +x_0=500000 +y_0=500000 +ellps=GRS80 +units=m +no_defs";
proj4.defs("EPSG:3844", EPSG_3844_DEF);
proj4.defs("EPSG:4326", "+proj=longlat +datum=WGS84 +no_defs");
const TILE_SIZE = 512;
const ETERRA_BASE = "https://eterra.ancpi.ro/eterra";
const ORTO_ENDPOINT = `${ETERRA_BASE}/api/map/rest/basemap/ORTO2024/exportImage`;
/** Convert tile z/x/y to WGS84 bounding box [west, south, east, north] */
function tileToBbox(z: number, x: number, y: number): [number, number, number, number] {
const n = Math.pow(2, z);
const lonW = (x / n) * 360 - 180;
const lonE = ((x + 1) / n) * 360 - 180;
const latN = (Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI;
const latS = (Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI;
return [lonW, latS, lonE, latN];
}
/** Reproject WGS84 bbox to EPSG:3844 using all 4 corners (handles projection curvature) */
function bboxTo3844(bbox4326: [number, number, number, number]): [number, number, number, number] {
const [w, s, e, n] = bbox4326;
// Project all 4 corners to handle non-linear projection
const corners = [
proj4("EPSG:4326", "EPSG:3844", [w, s]),
proj4("EPSG:4326", "EPSG:3844", [e, s]),
proj4("EPSG:4326", "EPSG:3844", [w, n]),
proj4("EPSG:4326", "EPSG:3844", [e, n]),
];
const xs = corners.map((c) => c[0]!);
const ys = corners.map((c) => c[1]!);
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
}
// Simple in-memory cookie cache for eTerra session
let cachedCookie: string | null = null;
let cookieExpiry = 0;
async function getEterraCookie(): Promise<string | null> {
if (cachedCookie && Date.now() < cookieExpiry) return cachedCookie;
const username = process.env.ETERRA_USERNAME;
const password = process.env.ETERRA_PASSWORD;
if (!username || !password) return null;
try {
const loginUrl = `${ETERRA_BASE}/api/authentication`;
const body = new URLSearchParams({
j_username: username,
j_password: password,
j_uuid: "undefined",
j_isRevoked: "undefined",
_spring_security_remember_me: "true",
submit: "Login",
});
const resp = await fetch(loginUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
redirect: "manual",
});
const setCookies = resp.headers.getSetCookie?.() ?? [];
const jsessionId = setCookies
.find((c) => c.startsWith("JSESSIONID="))
?.split(";")[0];
if (jsessionId) {
cachedCookie = jsessionId;
cookieExpiry = Date.now() + 8 * 60 * 1000; // 8 min TTL
return cachedCookie;
}
return null;
} catch {
return null;
}
}
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const z = parseInt(url.searchParams.get("z") ?? "", 10);
const x = parseInt(url.searchParams.get("x") ?? "", 10);
const y = parseInt(url.searchParams.get("y") ?? "", 10);
if (isNaN(z) || isNaN(x) || isNaN(y)) {
return NextResponse.json({ error: "z, x, y required" }, { status: 400 });
}
// Only serve tiles within Romania's approximate bounds (zoom >= 6)
if (z < 6) {
return new Response(null, { status: 204 });
}
const bbox4326 = tileToBbox(z, x, y);
const bbox3844 = bboxTo3844(bbox4326);
const cookie = await getEterraCookie();
if (!cookie) {
return NextResponse.json(
{ error: "eTerra login esuat - verificati credentialele" },
{ status: 401 }
);
}
const params = new URLSearchParams({
f: "image",
bbox: bbox3844.join(","),
imageSR: "3844",
bboxSR: "3844",
size: `${TILE_SIZE},${TILE_SIZE}`,
});
const imageResp = await fetch(`${ORTO_ENDPOINT}?${params}`, {
headers: { Cookie: cookie },
signal: AbortSignal.timeout(15_000),
});
if (!imageResp.ok) {
// Return transparent tile for areas without coverage
return new Response(null, { status: 204 });
}
const imageBuffer = await imageResp.arrayBuffer();
return new Response(imageBuffer, {
headers: {
"Content-Type": "image/png",
"Cache-Control": "public, max-age=86400", // cache 24h
},
});
} catch {
return new Response(null, { status: 204 });
}
}
+373 -13
View File
@@ -2,26 +2,75 @@ import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { readFile } from "fs/promises";
import { join } from "path";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Feature count cache (expensive query, cached 5 min) */
/* ------------------------------------------------------------------ */
const gCache = globalThis as {
__featureCountCache?: { map: Map<string, number>; ts: number };
};
async function getCachedFeatureCounts(): Promise<Map<string, number>> {
const TTL = 5 * 60 * 1000; // 5 minutes
const now = Date.now();
if (gCache.__featureCountCache && now - gCache.__featureCountCache.ts < TTL) {
return gCache.__featureCountCache.map;
}
// Run in background if cache exists but expired (return stale, refresh async)
if (gCache.__featureCountCache) {
void refreshFeatureCounts();
return gCache.__featureCountCache.map;
}
// First call: must wait
return refreshFeatureCounts();
}
async function refreshFeatureCounts(): Promise<Map<string, number>> {
try {
const groups = await prisma.gisFeature.groupBy({
by: ["siruta"],
_count: { id: true },
});
const map = new Map<string, number>();
for (const g of groups) {
map.set(g.siruta, g._count.id);
}
gCache.__featureCountCache = { map, ts: Date.now() };
return map;
} catch {
return gCache.__featureCountCache?.map ?? new Map();
}
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type EnrichedUat = {
type UatResponse = {
siruta: string;
name: string;
county: string;
workspacePk: number;
/** Number of GIS features synced locally for this UAT */
localFeatures: number;
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function populateWorkspaceCache(uats: EnrichedUat[]) {
function populateWorkspaceCache(
uats: Array<{ siruta: string; workspacePk: number }>,
) {
const wsGlobal = globalThis as {
__eterraWorkspaceCache?: Map<string, number>;
};
@@ -35,24 +84,103 @@ function populateWorkspaceCache(uats: EnrichedUat[]) {
}
}
/** Remove diacritics and uppercase for fuzzy name matching */
function normalizeName(s: string): string {
return s
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toUpperCase()
.trim();
}
/** Title-case: "SATU MARE" → "Satu Mare" */
function titleCase(s: string): string {
return s
.toLowerCase()
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
}
/**
* Extract a name from an eTerra nomenclature entry.
* Tries multiple possible field names.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractName(entry: any): string {
if (!entry || typeof entry !== "object") return "";
for (const key of ["name", "nomenName", "label", "denumire", "NAME"]) {
const val = entry[key];
if (typeof val === "string" && val.trim()) return val.trim();
}
return "";
}
/**
* Extract a SIRUTA code from an eTerra nomenclature entry.
* Tries multiple possible field names (nomenPk SIRUTA, but code might be).
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function extractCode(entry: any): string {
if (!entry || typeof entry !== "object") return "";
for (const key of [
"code",
"sirutaCode",
"siruta",
"externalCode",
"cod",
"CODE",
]) {
const val = entry[key];
if (val != null) {
const s = String(val).trim();
if (s && /^\d+$/.test(s)) return s;
}
}
return "";
}
/**
* Unwrap a potentially nested response (Spring Boot Page format).
* eTerra sometimes returns {content: [...]} instead of flat arrays.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unwrapArray(data: any): any[] {
if (Array.isArray(data)) return data;
if (data && typeof data === "object") {
if (Array.isArray(data.content)) return data.content;
if (Array.isArray(data.data)) return data.data;
if (Array.isArray(data.items)) return data.items;
if (Array.isArray(data.results)) return data.results;
}
return [];
}
/* ------------------------------------------------------------------ */
/* GET /api/eterra/uats */
/* */
/* Always serves from local PostgreSQL (GisUat table). */
/* Includes local GIS feature counts per UAT for the UI indicator. */
/* No eTerra credentials needed — instant response. */
/* ------------------------------------------------------------------ */
export async function GET() {
try {
// CRITICAL: select only needed fields — geometry column has huge polygon data
const rows = await prisma.gisUat.findMany({
orderBy: { name: "asc" },
select: { siruta: true, name: true, county: true, workspacePk: true },
});
const uats: EnrichedUat[] = rows.map((r) => ({
// Feature counts: use in-memory cache (refreshed every 5 min)
// The groupBy query is expensive (~25s without cache) but the data
// changes rarely (only when sync jobs run)
const featureCounts = await getCachedFeatureCounts();
const uats: UatResponse[] = rows.map((r) => ({
siruta: r.siruta,
name: r.name,
county: r.county ?? "",
workspacePk: r.workspacePk ?? 0,
localFeatures: featureCounts.get(r.siruta) ?? 0,
}));
// Populate in-memory workspace cache for search route
@@ -74,7 +202,9 @@ export async function GET() {
/* ------------------------------------------------------------------ */
/* POST /api/eterra/uats */
/* */
/* Seed DB from static uat.json. */
/* Seed or resync DB from static uat.json. */
/* Uses upsert so it's safe to call repeatedly — new UATs are added, */
/* existing names are updated, county/workspacePk are preserved. */
/* eTerra nomenPk ≠ SIRUTA, so we cannot use the nomenclature API */
/* for populating UAT data. uat.json has correct SIRUTA codes. */
/* Workspace (county) PKs are resolved lazily via ArcGIS layer query */
@@ -83,15 +213,6 @@ export async function GET() {
export async function POST() {
try {
// Check if DB already has data
const dbCount = await prisma.gisUat.count();
if (dbCount > 0) {
return NextResponse.json({
synced: false,
reason: "already-seeded",
total: dbCount,
});
}
// Read uat.json from public/ directory
let rawUats: Array<{ siruta: string; name: string }>;
@@ -151,3 +272,242 @@ export async function POST() {
);
}
}
/* ------------------------------------------------------------------ */
/* PATCH /api/eterra/uats */
/* */
/* Populate county names from eTerra nomenclature API. */
/* */
/* Strategy (two phases): */
/* Phase 1: For UATs that already have workspacePk resolved, */
/* use fetchCounties() → countyMap[workspacePk] → instant update. */
/* Phase 2: For remaining UATs, enumerate counties → */
/* fetchAdminUnitsByCounty() per county → match by code or name. */
/* */
/* Requires active eTerra session. */
/* ------------------------------------------------------------------ */
export async function PATCH() {
try {
// 1. Get eTerra credentials from session
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 NextResponse.json(
{ error: "Conectează-te la eTerra mai întâi." },
{ status: 401 },
);
}
const client = await EterraClient.create(username, password);
// 2. Fetch all counties from eTerra nomenclature
const rawCounties = await client.fetchCounties();
const counties = unwrapArray(rawCounties);
const countyMap = new Map<number, string>(); // nomenPk → county name
for (const c of counties) {
const pk = Number(c?.nomenPk ?? 0);
const name = extractName(c);
if (pk > 0 && name) {
countyMap.set(pk, titleCase(name));
}
}
if (countyMap.size === 0) {
// Log raw response for debugging
console.error(
"[uats-patch] fetchCounties returned 0 counties. Raw sample:",
JSON.stringify(rawCounties).slice(0, 500),
);
return NextResponse.json(
{
error: "Nu s-au putut obține județele din eTerra.",
debug: {
rawType: typeof rawCounties,
isArray: Array.isArray(rawCounties),
length: Array.isArray(rawCounties) ? rawCounties.length : null,
sample: JSON.stringify(rawCounties).slice(0, 300),
},
},
{ status: 502 },
);
}
console.log(`[uats-patch] Fetched ${countyMap.size} counties from eTerra`);
// 3. Load all UATs from DB
const allUats = await prisma.gisUat.findMany({
select: { siruta: true, name: true, county: true, workspacePk: true },
});
// Phase 1: instant fill for UATs that already have workspacePk
const phase1Ops: Array<ReturnType<typeof prisma.gisUat.update>> = [];
const needsCounty: Array<{ siruta: string; name: string }> = [];
for (const uat of allUats) {
if (uat.county) continue; // already has county
if (uat.workspacePk && uat.workspacePk > 0) {
const county = countyMap.get(uat.workspacePk);
if (county) {
phase1Ops.push(
prisma.gisUat.update({
where: { siruta: uat.siruta },
data: { county },
}),
);
continue;
}
}
needsCounty.push({ siruta: uat.siruta, name: uat.name });
}
// Execute phase 1 in batches
let phase1Updated = 0;
for (let i = 0; i < phase1Ops.length; i += 100) {
const batch = phase1Ops.slice(i, i + 100);
await prisma.$transaction(batch);
phase1Updated += batch.length;
}
console.log(
`[uats-patch] Phase 1: ${phase1Updated} updated via workspacePk. ` +
`${needsCounty.length} remaining.`,
);
// Phase 2: enumerate UATs per county from nomenclature, match by code or name
// Build lookups
const nameToSirutas = new Map<string, string[]>();
const sirutaSet = new Set<string>();
for (const u of needsCounty) {
sirutaSet.add(u.siruta);
const key = normalizeName(u.name);
const arr = nameToSirutas.get(key);
if (arr) arr.push(u.siruta);
else nameToSirutas.set(key, [u.siruta]);
}
let phase2Updated = 0;
let codeMatches = 0;
let nameMatches = 0;
const matchedSirutas = new Set<string>();
let loggedSample = false;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let sampleCounty: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let sampleUat: any = null;
let totalEterraUats = 0;
for (const [countyPk, countyName] of countyMap) {
if (matchedSirutas.size >= needsCounty.length) break;
try {
const rawUats = await client.fetchAdminUnitsByCounty(countyPk);
const uats = unwrapArray(rawUats);
totalEterraUats += uats.length;
// Log first county's first UAT for debugging
if (!loggedSample && uats.length > 0) {
sampleUat = uats[0];
sampleCounty = { pk: countyPk, name: countyName, uatCount: uats.length };
console.log(
`[uats-patch] Sample UAT from ${countyName} (${uats.length} UATs):`,
JSON.stringify(uats[0]).slice(0, 500),
);
console.log(
`[uats-patch] Sample UAT keys:`,
Object.keys(uats[0] ?? {}),
);
loggedSample = true;
}
for (const uat of uats) {
// Strategy A: match by code (might be SIRUTA)
const code = extractCode(uat);
if (code && sirutaSet.has(code) && !matchedSirutas.has(code)) {
matchedSirutas.add(code);
await prisma.gisUat.update({
where: { siruta: code },
data: { county: countyName, workspacePk: countyPk },
});
phase2Updated++;
codeMatches++;
continue;
}
// Strategy B: match by normalized name
const eterraName = extractName(uat);
if (!eterraName) continue;
const key = normalizeName(eterraName);
const sirutas = nameToSirutas.get(key);
if (!sirutas || sirutas.length === 0) continue;
const siruta = sirutas.find((s) => !matchedSirutas.has(s));
if (!siruta) continue;
matchedSirutas.add(siruta);
await prisma.gisUat.update({
where: { siruta },
data: { county: countyName, workspacePk: countyPk },
});
phase2Updated++;
nameMatches++;
if (sirutas.every((s) => matchedSirutas.has(s))) {
nameToSirutas.delete(key);
}
}
} catch (err) {
console.warn(
`[uats-patch] Failed to fetch UATs for county ${countyName} (pk=${countyPk}):`,
err instanceof Error ? err.message : err,
);
}
}
const totalUpdated = phase1Updated + phase2Updated;
const unmatched = needsCounty.length - phase2Updated;
console.log(
`[uats-patch] Phase 2: ${phase2Updated} (${codeMatches} by code, ${nameMatches} by name). ` +
`Total: ${totalUpdated}. Unmatched: ${unmatched}.`,
);
return NextResponse.json({
updated: totalUpdated,
phase1: phase1Updated,
phase2: phase2Updated,
codeMatches,
nameMatches,
totalCounties: countyMap.size,
totalEterraUats,
unmatched,
// Include debug samples so we can see what eTerra returns
debug: {
sampleCounty,
sampleUatKeys: sampleUat ? Object.keys(sampleUat) : null,
sampleUat: sampleUat
? JSON.parse(JSON.stringify(sampleUat).slice(0, 500))
: null,
sampleCountyRaw: counties[0]
? {
keys: Object.keys(counties[0]),
nomenPk: counties[0].nomenPk,
name: counties[0].name,
}
: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
console.error("[uats-patch] Error:", message);
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,139 @@
import { NextResponse } from "next/server";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/**
* GET /api/eterra/uats/test-counties
*
* Diagnostic endpoint: tests eTerra nomenclature API and returns
* raw results. Hit this from your browser to see exactly what
* fetchCounties() and fetchAdminUnitsByCounty() return.
*
* Requires active eTerra session.
*/
export async function GET() {
try {
const session = getSessionCredentials();
if (!session) {
return NextResponse.json({
error: "Nu ești conectat la eTerra.",
step: "credentials",
});
}
const client = await EterraClient.create(session.username, session.password);
// Step 1: Fetch counties
let rawCounties: unknown;
try {
rawCounties = await client.fetchCounties();
} catch (err) {
return NextResponse.json({
error: "fetchCounties() a eșuat",
step: "fetchCounties",
message: err instanceof Error ? err.message : String(err),
});
}
const isArray = Array.isArray(rawCounties);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const countiesArr: any[] = isArray
? rawCounties
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
Array.isArray((rawCounties as any)?.content)
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(rawCounties as any).content
: [];
const countySummary = {
rawType: typeof rawCounties,
isArray,
unwrappedLength: countiesArr.length,
topLevelKeys:
rawCounties && typeof rawCounties === "object" && !isArray
? Object.keys(rawCounties)
: null,
firstCounty: countiesArr[0]
? {
allKeys: Object.keys(countiesArr[0]),
raw: countiesArr[0],
}
: null,
// Show first 5 counties
// eslint-disable-next-line @typescript-eslint/no-explicit-any
first5: countiesArr.slice(0, 5).map((c: any) => ({
nomenPk: c?.nomenPk,
name: c?.name,
nomenName: c?.nomenName,
label: c?.label,
code: c?.code,
allKeys: Object.keys(c ?? {}),
})),
};
// Step 2: Test fetchAdminUnitsByCounty with first county
let uatSample = null;
if (countiesArr.length > 0) {
const firstCountyPk = countiesArr[0]?.nomenPk;
if (firstCountyPk) {
try {
const rawUats =
await client.fetchAdminUnitsByCounty(firstCountyPk);
const uatsArr = Array.isArray(rawUats)
? rawUats
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
Array.isArray((rawUats as any)?.content)
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
(rawUats as any).content
: [];
uatSample = {
countyPk: firstCountyPk,
countyName: countiesArr[0]?.name,
rawType: typeof rawUats,
isArray: Array.isArray(rawUats),
unwrappedLength: uatsArr.length,
topLevelKeys:
rawUats && typeof rawUats === "object" && !Array.isArray(rawUats)
? Object.keys(rawUats)
: null,
firstUat: uatsArr[0]
? {
allKeys: Object.keys(uatsArr[0]),
raw: uatsArr[0],
}
: null,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
first5: uatsArr.slice(0, 5).map((u: any) => ({
nomenPk: u?.nomenPk,
name: u?.name,
nomenName: u?.nomenName,
code: u?.code,
sirutaCode: u?.sirutaCode,
allKeys: Object.keys(u ?? {}),
})),
};
} catch (err) {
uatSample = {
error: "fetchAdminUnitsByCounty() a eșuat",
message: err instanceof Error ? err.message : String(err),
};
}
}
}
return NextResponse.json({
status: "ok",
counties: countySummary,
uatSample,
});
} catch (error) {
return NextResponse.json({
error: "Eroare generală",
message: error instanceof Error ? error.message : String(error),
});
}
}
+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 });
}
@@ -0,0 +1,115 @@
/**
* GET /api/geoportal/boundary-check?siruta=57582
*
* Spatial cross-check: finds parcels that geometrically fall within the
* given UAT boundary but are registered under a DIFFERENT siruta.
*
* Also detects the reverse: parcels registered in this UAT whose centroid
* falls outside its boundary (edge parcels).
*
* Returns GeoJSON FeatureCollection in EPSG:4326 (WGS84) for direct
* map overlay.
*/
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type RawRow = {
id: string;
siruta: string;
object_id: number;
cadastral_ref: string | null;
area_value: number | null;
layer_id: string;
mismatch_type: string;
geojson: string;
};
export async function GET(req: NextRequest) {
const siruta = req.nextUrl.searchParams.get("siruta");
if (!siruta) {
return NextResponse.json({ error: "siruta required" }, { status: 400 });
}
try {
// 1. Foreign parcels: registered in OTHER UATs but geometrically overlap this UAT
const foreign = await prisma.$queryRaw`
SELECT
f.id,
f.siruta,
f."objectId" AS object_id,
f."cadastralRef" AS cadastral_ref,
f."areaValue" AS area_value,
f."layerId" AS layer_id,
'foreign' AS mismatch_type,
ST_AsGeoJSON(ST_Transform(f.geom, 4326)) AS geojson
FROM "GisFeature" f
JOIN "GisUat" u ON u.siruta = ${siruta}
WHERE f.siruta != ${siruta}
AND ST_Intersects(f.geom, u.geom)
AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%')
AND f.geom IS NOT NULL
LIMIT 500
` as RawRow[];
// 2. Edge parcels: registered in this UAT but centroid falls outside boundary
const edge = await prisma.$queryRaw`
SELECT
f.id,
f.siruta,
f."objectId" AS object_id,
f."cadastralRef" AS cadastral_ref,
f."areaValue" AS area_value,
f."layerId" AS layer_id,
'edge' AS mismatch_type,
ST_AsGeoJSON(ST_Transform(f.geom, 4326)) AS geojson
FROM "GisFeature" f
JOIN "GisUat" u ON u.siruta = f.siruta AND u.siruta = ${siruta}
WHERE NOT ST_Contains(u.geom, ST_Centroid(f.geom))
AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%')
AND f.geom IS NOT NULL
LIMIT 500
` as RawRow[];
const allRows = [...foreign, ...edge];
// Build GeoJSON FeatureCollection
const features = allRows
.map((row) => {
try {
const geometry = JSON.parse(row.geojson) as GeoJSON.Geometry;
return {
type: "Feature" as const,
geometry,
properties: {
id: row.id,
siruta: row.siruta,
object_id: row.object_id,
cadastral_ref: row.cadastral_ref,
area_value: row.area_value,
layer_id: row.layer_id,
mismatch_type: row.mismatch_type,
},
};
} catch {
return null;
}
})
.filter(Boolean);
return NextResponse.json({
type: "FeatureCollection",
features,
summary: {
foreign: foreign.length,
edge: edge.length,
total: allRows.length,
},
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+61
View File
@@ -0,0 +1,61 @@
/**
* GET /api/geoportal/cf-status?nrCad=...
*
* Checks if a CF extract exists for a given cadastral number.
* Returns download info if available.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
const url = new URL(req.url);
const nrCad = url.searchParams.get("nrCad")?.trim();
if (!nrCad) {
return NextResponse.json({ error: "nrCad obligatoriu" }, { status: 400 });
}
try {
// Find the latest completed CF extract for this cadastral number
const extract = await prisma.cfExtract.findFirst({
where: {
nrCadastral: nrCad,
status: "completed",
minioPath: { not: "" },
},
orderBy: { completedAt: "desc" },
select: {
id: true,
nrCadastral: true,
nrCF: true,
status: true,
minioPath: true,
documentName: true,
completedAt: true,
expiresAt: true,
},
});
if (!extract || !extract.minioPath) {
return NextResponse.json({ available: false });
}
const expired = extract.expiresAt && new Date(extract.expiresAt) < new Date();
return NextResponse.json({
available: !expired,
expired: !!expired,
id: extract.id,
nrCF: extract.nrCF,
documentName: extract.documentName,
completedAt: extract.completedAt?.toISOString(),
downloadUrl: `/api/ancpi/download?id=${extract.id}`,
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* POST /api/geoportal/enrich
*
* Per-parcel enrichment calls the proven /api/eterra/search internally
* and saves the result to GisFeature.enrichment.
*
* Body: { featureId: string } or { siruta: string, objectId: number }
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { headers } from "next/headers";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
try {
const body = (await req.json()) as { featureId?: string; siruta?: string; objectId?: number };
// Find feature
let feature;
if (body.featureId) {
feature = await prisma.gisFeature.findUnique({
where: { id: body.featureId },
select: { id: true, objectId: true, siruta: true, cadastralRef: true, areaValue: true },
});
} else if (body.siruta && body.objectId) {
feature = await prisma.gisFeature.findFirst({
where: { siruta: body.siruta, objectId: body.objectId },
select: { id: true, objectId: true, siruta: true, cadastralRef: true, areaValue: true },
});
}
if (!feature) {
return NextResponse.json({ error: "Parcela negasita in DB" }, { status: 404 });
}
const cadRef = feature.cadastralRef ?? "";
if (!cadRef) {
return NextResponse.json({ error: "Parcela fara numar cadastral" }, { status: 400 });
}
// Call the proven /api/eterra/search endpoint internally
const headersList = await headers();
const cookie = headersList.get("cookie") ?? "";
const origin = req.headers.get("origin") ?? headersList.get("host") ?? "localhost:3000";
const protocol = origin.includes("localhost") ? "http" : "https";
const baseUrl = origin.startsWith("http") ? origin : `${protocol}://${origin}`;
const searchResp = await fetch(`${baseUrl}/api/eterra/search`, {
method: "POST",
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify({ siruta: feature.siruta, search: cadRef }),
});
if (!searchResp.ok) {
const err = await searchResp.json().catch(() => ({}));
return NextResponse.json(
{ error: (err as Record<string, string>).error ?? `eTerra search esuat (${searchResp.status})` },
{ status: searchResp.status }
);
}
const searchData = await searchResp.json() as {
results: Array<{
nrCad: string; nrCF: string; nrCFVechi: string; nrTopo: string;
intravilan: string; categorieFolosinta: string; adresa: string;
proprietari: string; proprietariActuali: string; proprietariVechi: string;
suprafata: number | null; solicitant: string; immovablePk: string;
}>;
};
const match = searchData.results?.[0];
if (!match) {
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,
NR_CF: match.nrCF || "",
NR_CF_VECHI: match.nrCFVechi || "",
NR_TOPO: match.nrTopo || "",
ADRESA: match.adresa || "",
PROPRIETARI: match.proprietariActuali || match.proprietari || "",
PROPRIETARI_VECHI: match.proprietariVechi || "",
SUPRAFATA_2D: match.suprafata ?? feature.areaValue ?? "",
SUPRAFATA_R: match.suprafata ? Math.round(match.suprafata) : (feature.areaValue ? Math.round(feature.areaValue) : ""),
SOLICITANT: match.solicitant || "",
INTRAVILAN: match.intravilan || "",
CATEGORIE_FOLOSINTA: match.categorieFolosinta || "",
HAS_BUILDING: hasBuilding,
BUILD_LEGAL: buildLegal,
};
// Persist
await prisma.gisFeature.update({
where: { id: feature.id },
data: { enrichment: enrichment as object, enrichedAt: new Date() },
});
return NextResponse.json({ status: "ok", message: "Parcela imbogatita cu succes", enrichment });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+195
View File
@@ -0,0 +1,195 @@
/**
* POST /api/geoportal/export
*
* Exports selected GIS features as GeoJSON, DXF, or GeoPackage.
* Body: { ids: string[], format: "geojson" | "dxf" | "gpkg" }
*
* - GeoJSON: always works (pure JS)
* - DXF/GPKG: requires ogr2ogr (available in Docker image via gdal-tools)
*/
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/core/storage/prisma";
import { writeFile, readFile, unlink, mkdtemp } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { execFile } from "child_process";
import { promisify } from "util";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const execFileAsync = promisify(execFile);
type ExportRequest = {
ids: string[];
format: "geojson" | "dxf" | "gpkg";
};
export async function POST(req: Request) {
let tmpDir: string | null = null;
try {
const body = (await req.json()) as ExportRequest;
const { ids, format } = body;
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json(
{ error: "Selecteaza cel putin o parcela" },
{ status: 400 }
);
}
if (!["geojson", "dxf", "gpkg"].includes(format)) {
return NextResponse.json(
{ error: "Format invalid. Optiuni: geojson, dxf, gpkg" },
{ status: 400 }
);
}
// The IDs from the selection are objectId strings
// Query features by objectId (converted to int)
const objectIds = ids.map((id) => parseInt(id, 10)).filter((n) => !isNaN(n));
if (objectIds.length === 0) {
return NextResponse.json(
{ error: "Niciun ID valid in selectie" },
{ status: 400 }
);
}
const features = await prisma.gisFeature.findMany({
where: {
objectId: { in: objectIds },
geometry: { not: Prisma.JsonNull },
},
select: {
objectId: true,
cadastralRef: true,
areaValue: true,
attributes: true,
geometry: true,
enrichment: true,
layerId: true,
siruta: true,
},
});
if (features.length === 0) {
return NextResponse.json(
{ error: "Niciun feature cu geometrie gasit" },
{ status: 404 }
);
}
// Build GeoJSON FeatureCollection
const geojson = {
type: "FeatureCollection" as const,
crs: {
type: "name",
properties: { name: "urn:ogc:def:crs:EPSG::3844" },
},
features: features.map((f) => {
const enrichment = (f.enrichment ?? {}) as Record<string, unknown>;
return {
type: "Feature" as const,
geometry: f.geometry as Record<string, unknown>,
properties: {
objectId: f.objectId,
cadastralRef: f.cadastralRef,
areaValue: f.areaValue,
layerId: f.layerId,
siruta: f.siruta,
NR_CAD: enrichment.NR_CAD ?? "",
NR_CF: enrichment.NR_CF ?? "",
PROPRIETARI: enrichment.PROPRIETARI ?? "",
SUPRAFATA: enrichment.SUPRAFATA_2D ?? "",
INTRAVILAN: enrichment.INTRAVILAN ?? "",
CATEGORIE: enrichment.CATEGORIE_FOLOSINTA ?? "",
},
};
}),
};
const timestamp = new Date().toISOString().slice(0, 10);
// GeoJSON — return directly
if (format === "geojson") {
const filename = `parcele_${timestamp}.geojson`;
return new Response(JSON.stringify(geojson, null, 2), {
headers: {
"Content-Type": "application/geo+json",
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
// DXF or GPKG — use ogr2ogr
tmpDir = await mkdtemp(join(tmpdir(), "geoportal-export-"));
const inputPath = join(tmpDir, "input.geojson");
await writeFile(inputPath, JSON.stringify(geojson));
const ext = format === "dxf" ? "dxf" : "gpkg";
const outputPath = join(tmpDir, `output.${ext}`);
const ogrFormat = format === "dxf" ? "DXF" : "GPKG";
// DXF: reproject to WGS84 (-s_srs + -t_srs). GPKG: assign CRS only (-a_srs).
const ogrArgs = format === "dxf"
? ["-f", ogrFormat, outputPath, inputPath, "-s_srs", "EPSG:3844", "-t_srs", "EPSG:4326"]
: ["-f", ogrFormat, outputPath, inputPath, "-a_srs", "EPSG:3844"];
try {
await execFileAsync("ogr2ogr", ogrArgs, { timeout: 30_000 });
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
// ogr2ogr not available (local dev without GDAL)
if (errMsg.includes("ENOENT") || errMsg.includes("not found")) {
return NextResponse.json(
{ error: `Export ${format.toUpperCase()} disponibil doar in productie (Docker cu GDAL)` },
{ status: 501 }
);
}
return NextResponse.json(
{ error: `ogr2ogr a esuat: ${errMsg}` },
{ status: 500 }
);
}
const outputBuffer = await readFile(outputPath);
const filename = `parcele_${timestamp}.${ext}`;
const contentType =
format === "dxf"
? "application/dxf"
: "application/geopackage+sqlite3";
// Clean up temp files (best effort)
cleanup(tmpDir);
tmpDir = null;
return new Response(outputBuffer, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
} catch (error) {
if (tmpDir) cleanup(tmpDir);
const msg = error instanceof Error ? error.message : "Eroare la export";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
async function cleanup(dir: string) {
try {
const { readdir } = await import("fs/promises");
const files = await readdir(dir);
for (const f of files) {
await unlink(join(dir, f)).catch(() => {});
}
const { rmdir } = await import("fs/promises");
await rmdir(dir).catch(() => {});
} catch {
// best effort
}
}
+109
View File
@@ -0,0 +1,109 @@
/**
* GET /api/geoportal/feature?objectId=...&siruta=...&sourceLayer=...
*
* Returns a single GIS feature with enrichment data for the info panel.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const objectId = url.searchParams.get("objectId");
const siruta = url.searchParams.get("siruta");
const sourceLayer = url.searchParams.get("sourceLayer") ?? "";
if (!objectId || !siruta) {
return NextResponse.json(
{ error: "Parametri lipsa: objectId si siruta sunt obligatorii" },
{ status: 400 }
);
}
// UAT features come from GisUat table
if (sourceLayer === "gis_uats") {
const uat = await prisma.gisUat.findUnique({
where: { siruta },
select: {
siruta: true,
name: true,
county: true,
areaValue: true,
workspacePk: true,
},
});
if (!uat) {
return NextResponse.json({ error: "UAT negasit" }, { status: 404 });
}
return NextResponse.json({
feature: {
id: uat.siruta,
layerId: "LIMITE_UAT",
siruta: uat.siruta,
objectId: 0,
cadastralRef: null,
areaValue: uat.areaValue,
enrichment: null,
enrichedAt: null,
extra: {
name: uat.name,
county: uat.county,
workspacePk: uat.workspacePk,
},
},
});
}
// GisFeature (parcels, buildings)
const objId = parseInt(objectId, 10);
if (isNaN(objId)) {
return NextResponse.json({ error: "objectId invalid" }, { status: 400 });
}
const feature = await prisma.gisFeature.findFirst({
where: {
objectId: objId,
siruta,
},
select: {
id: true,
layerId: true,
siruta: true,
objectId: true,
cadastralRef: true,
areaValue: true,
enrichment: true,
enrichedAt: true,
inspireId: true,
},
});
if (!feature) {
return NextResponse.json(
{ error: "Feature negasit in baza de date" },
{ status: 404 }
);
}
return NextResponse.json({
feature: {
id: feature.id,
layerId: feature.layerId,
siruta: feature.siruta,
objectId: feature.objectId,
cadastralRef: feature.cadastralRef,
areaValue: feature.areaValue,
enrichment: feature.enrichment as Record<string, unknown> | null,
enrichedAt: feature.enrichedAt?.toISOString() ?? null,
},
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+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 });
}
@@ -0,0 +1,87 @@
/**
* POST /api/geoportal/optimize-tiles
*
* Slims down gis_features/terenuri/cladiri/administrativ views
* by removing heavy JSON columns (attributes, enrichment, timestamps).
* Makes Martin vector tiles much smaller and faster.
*
* Safe to re-run (CREATE OR REPLACE VIEW).
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const STEPS = [
// Drop dependent views first (they reference gis_features)
{ name: "Drop gis_documentatii", sql: `DROP VIEW IF EXISTS gis_documentatii CASCADE` },
{ name: "Drop gis_administrativ", sql: `DROP VIEW IF EXISTS gis_administrativ CASCADE` },
{ name: "Drop gis_cladiri", sql: `DROP VIEW IF EXISTS gis_cladiri CASCADE` },
{ name: "Drop gis_terenuri", sql: `DROP VIEW IF EXISTS gis_terenuri CASCADE` },
{ name: "Drop gis_features", sql: `DROP VIEW IF EXISTS gis_features CASCADE` },
{
name: "gis_features (slim)",
sql: `CREATE OR REPLACE VIEW gis_features AS
SELECT id, "layerId" AS layer_id, siruta, "objectId" AS object_id,
"cadastralRef" AS cadastral_ref, "areaValue" AS area_value,
"isActive" AS is_active, geom
FROM "GisFeature" WHERE geom IS NOT NULL`,
},
{
name: "gis_terenuri",
sql: `CREATE OR REPLACE VIEW gis_terenuri AS
SELECT * FROM gis_features
WHERE layer_id LIKE 'TERENURI%' OR layer_id LIKE 'CADGEN_LAND%'`,
},
{
name: "gis_cladiri",
sql: `CREATE OR REPLACE VIEW gis_cladiri AS
SELECT * FROM gis_features
WHERE layer_id LIKE 'CLADIRI%' OR layer_id LIKE 'CADGEN_BUILDING%'`,
},
{
name: "gis_administrativ",
sql: `CREATE OR REPLACE VIEW gis_administrativ AS
SELECT * FROM gis_features
WHERE layer_id LIKE 'LIMITE%' OR layer_id LIKE 'SPECIAL_AREAS%'`,
},
{
name: "gis_documentatii",
sql: `CREATE OR REPLACE VIEW gis_documentatii AS
SELECT * FROM gis_features
WHERE layer_id LIKE 'EXPERTIZA%' OR layer_id LIKE 'ZONE_INTERES%' OR layer_id LIKE 'RECEPTII%'`,
},
];
export async function POST() {
const results: string[] = [];
try {
for (const step of STEPS) {
await prisma.$executeRawUnsafe(step.sql);
results.push(`${step.name} OK`);
}
return NextResponse.json({ status: "ok", results });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
}
}
export async function GET() {
try {
// Check if views are slim (no 'attributes' column)
const cols = await prisma.$queryRaw`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'gis_features' AND table_schema = 'public'
ORDER BY ordinal_position
` as Array<{ column_name: string }>;
const hasAttributes = cols.some((c) => c.column_name === "attributes");
return NextResponse.json({
optimized: !hasAttributes,
columns: cols.map((c) => c.column_name),
});
} catch {
return NextResponse.json({ optimized: false, columns: [] });
}
}
@@ -0,0 +1,147 @@
/**
* POST /api/geoportal/optimize-views
*
* Replaces on-the-fly ST_Simplify views with materialized columns.
* This eliminates CPU-heavy geometry simplification on every Martin tile request.
*
* What it does:
* 1. Adds geom_z0/z5/z8 columns to GisUat table (pre-simplified geometry)
* 2. Backfills them from the original geom column
* 3. Creates spatial indexes on each
* 4. Replaces views to use pre-computed columns instead of on-the-fly simplification
*
* Safe to re-run (idempotent). Original geom column is NEVER modified.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const STEPS = [
// 0. Drop existing views that reference geom (they block ALTER TABLE)
{ name: "Drop gis_uats_z0 view", sql: `DROP VIEW IF EXISTS gis_uats_z0 CASCADE` },
{ name: "Drop gis_uats_z5 view", sql: `DROP VIEW IF EXISTS gis_uats_z5 CASCADE` },
{ name: "Drop gis_uats_z8 view", sql: `DROP VIEW IF EXISTS gis_uats_z8 CASCADE` },
{ name: "Drop gis_uats_z12 view", sql: `DROP VIEW IF EXISTS gis_uats_z12 CASCADE` },
{ name: "Drop gis_uats view", sql: `DROP VIEW IF EXISTS gis_uats CASCADE` },
// 1. Add pre-simplified geometry columns (plain geometry, no typed constraint)
{
name: "Add geom_z0 column (2000m)",
sql: `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='GisUat' AND column_name='geom_z0') THEN ALTER TABLE "GisUat" ADD COLUMN geom_z0 geometry; END IF; END $$`,
},
{
name: "Add geom_z5 column (500m)",
sql: `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='GisUat' AND column_name='geom_z5') THEN ALTER TABLE "GisUat" ADD COLUMN geom_z5 geometry; END IF; END $$`,
},
{
name: "Add geom_z8 column (50m)",
sql: `DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='GisUat' AND column_name='geom_z8') THEN ALTER TABLE "GisUat" ADD COLUMN geom_z8 geometry; END IF; END $$`,
},
// 2. Backfill with pre-computed simplified geometries
{
name: "Backfill geom_z0 (2000m simplification)",
sql: `UPDATE "GisUat" SET geom_z0 = ST_SimplifyPreserveTopology(geom, 2000) WHERE geom IS NOT NULL AND geom_z0 IS NULL`,
},
{
name: "Backfill geom_z5 (500m simplification)",
sql: `UPDATE "GisUat" SET geom_z5 = ST_SimplifyPreserveTopology(geom, 500) WHERE geom IS NOT NULL AND geom_z5 IS NULL`,
},
{
name: "Backfill geom_z8 (50m simplification)",
sql: `UPDATE "GisUat" SET geom_z8 = ST_SimplifyPreserveTopology(geom, 50) WHERE geom IS NOT NULL AND geom_z8 IS NULL`,
},
// 3. Spatial indexes
{
name: "Index geom_z0",
sql: `CREATE INDEX IF NOT EXISTS gis_uat_geom_z0_idx ON "GisUat" USING GIST (geom_z0)`,
},
{
name: "Index geom_z5",
sql: `CREATE INDEX IF NOT EXISTS gis_uat_geom_z5_idx ON "GisUat" USING GIST (geom_z5)`,
},
{
name: "Index geom_z8",
sql: `CREATE INDEX IF NOT EXISTS gis_uat_geom_z8_idx ON "GisUat" USING GIST (geom_z8)`,
},
// 4. Replace views to use pre-computed columns (zero CPU on read)
{
name: "Replace gis_uats_z0 view",
sql: `CREATE OR REPLACE VIEW gis_uats_z0 AS SELECT siruta, name, geom_z0 AS geom FROM "GisUat" WHERE geom_z0 IS NOT NULL`,
},
{
name: "Replace gis_uats_z5 view",
sql: `CREATE OR REPLACE VIEW gis_uats_z5 AS SELECT siruta, name, geom_z5 AS geom FROM "GisUat" WHERE geom_z5 IS NOT NULL`,
},
{
name: "Replace gis_uats_z8 view",
sql: `CREATE OR REPLACE VIEW gis_uats_z8 AS SELECT siruta, name, county, geom_z8 AS geom FROM "GisUat" WHERE geom_z8 IS NOT NULL`,
},
{
name: "Replace gis_uats_z12 view (original geom)",
sql: `CREATE OR REPLACE VIEW gis_uats_z12 AS SELECT siruta, name, county, geom FROM "GisUat" WHERE geom IS NOT NULL`,
},
// 5. Restore legacy gis_uats view for QGIS compatibility
{
name: "Restore gis_uats view",
sql: `CREATE OR REPLACE VIEW gis_uats AS SELECT siruta, name, county, geom_z8 AS geom FROM "GisUat" WHERE geom_z8 IS NOT NULL`,
},
// 6. Slim down gis_terenuri/cladiri views (drop huge attributes/enrichment JSON columns)
{
name: "Optimize gis_features view (slim columns)",
sql: `CREATE OR REPLACE VIEW gis_features AS SELECT id, "layerId" AS layer_id, siruta, "objectId" AS object_id, "cadastralRef" AS cadastral_ref, "areaValue" AS area_value, "isActive" AS is_active, geom FROM "GisFeature" WHERE geom IS NOT NULL`,
},
{
name: "Optimize gis_terenuri view",
sql: `CREATE OR REPLACE VIEW gis_terenuri AS SELECT * FROM gis_features WHERE layer_id LIKE 'TERENURI%' OR layer_id LIKE 'CADGEN_LAND%'`,
},
{
name: "Optimize gis_cladiri view",
sql: `CREATE OR REPLACE VIEW gis_cladiri AS SELECT * FROM gis_features WHERE layer_id LIKE 'CLADIRI%' OR layer_id LIKE 'CADGEN_BUILDING%'`,
},
{
name: "Optimize gis_administrativ view",
sql: `CREATE OR REPLACE VIEW gis_administrativ AS SELECT * FROM gis_features WHERE layer_id LIKE 'LIMITE%' OR layer_id LIKE 'SPECIAL_AREAS%'`,
},
// 7. Update trigger to also compute simplified geoms on INSERT/UPDATE
{
name: "Update trigger to pre-compute simplified geoms",
sql: `CREATE OR REPLACE FUNCTION gis_uat_sync_geom() RETURNS TRIGGER AS $$ BEGIN IF NEW.geometry IS NOT NULL THEN BEGIN NEW.geom := gis_uat_esri_to_geom(NEW.geometry::jsonb); IF NEW.geom IS NOT NULL THEN NEW.geom_z0 := ST_SimplifyPreserveTopology(NEW.geom, 2000); NEW.geom_z5 := ST_SimplifyPreserveTopology(NEW.geom, 500); NEW.geom_z8 := ST_SimplifyPreserveTopology(NEW.geom, 50); END IF; EXCEPTION WHEN OTHERS THEN NEW.geom := NULL; NEW.geom_z0 := NULL; NEW.geom_z5 := NULL; NEW.geom_z8 := NULL; END; ELSE NEW.geom := NULL; NEW.geom_z0 := NULL; NEW.geom_z5 := NULL; NEW.geom_z8 := NULL; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql`,
},
];
export async function POST() {
const results: string[] = [];
try {
for (const step of STEPS) {
await prisma.$executeRawUnsafe(step.sql);
results.push(`${step.name} OK`);
}
return NextResponse.json({ status: "ok", results });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
}
}
/** GET — check optimization status */
export async function GET() {
try {
const cols = await prisma.$queryRaw`
SELECT column_name FROM information_schema.columns
WHERE table_name = 'GisUat' AND column_name LIKE 'geom_z%'
` as Array<{ column_name: string }>;
const optimized = cols.length >= 3;
return NextResponse.json({ optimized, columns: cols.map((c) => c.column_name) });
} catch {
return NextResponse.json({ optimized: false, columns: [] });
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* GET /api/geoportal/search?q=...&type=...&limit=...
*
* Searches parcels (by cadastral ref, owner) and UATs (by name).
* Returns centroids in EPSG:4326 (WGS84) for map flyTo.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type SearchResultItem = {
id: string;
type: "parcel" | "uat" | "building";
label: string;
sublabel?: string;
coordinates?: [number, number];
};
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const q = url.searchParams.get("q")?.trim() ?? "";
const typeFilter = url.searchParams.get("type") ?? "";
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "20", 10), 50);
if (q.length < 2) {
return NextResponse.json({ results: [] });
}
const results: SearchResultItem[] = [];
const pattern = `%${q}%`;
// Search UATs by name
if (!typeFilter || typeFilter === "uat") {
const uats = await prisma.$queryRaw`
SELECT
siruta,
name,
county,
ST_X(ST_Centroid(ST_Transform(geom, 4326))) as lng,
ST_Y(ST_Centroid(ST_Transform(geom, 4326))) as lat
FROM "GisUat"
WHERE geom IS NOT NULL
AND (name ILIKE ${pattern} OR county ILIKE ${pattern})
ORDER BY name
LIMIT ${limit}
` as Array<{ siruta: string; name: string; county: string | null; lng: number; lat: number }>;
for (const u of uats) {
results.push({
id: `uat-${u.siruta}`,
type: "uat",
label: u.name,
sublabel: u.county ? `Jud. ${u.county}` : undefined,
coordinates: u.lng && u.lat ? [u.lng, u.lat] : undefined,
});
}
}
// Search parcels by cadastral ref or enrichment data
if (!typeFilter || typeFilter === "parcel") {
const isNumericish = /^\d/.test(q);
if (isNumericish) {
// Search by cadastral reference
const parcels = await prisma.$queryRaw`
SELECT
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;
}>;
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}${uatLabel}`,
sublabel: [area, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
});
}
} else {
// Search by owner name in enrichment JSON
const parcels = await prisma.$queryRaw`
SELECT
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;
}>;
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}${uatLabel}`,
sublabel: [ownerShort, p.uat_name ? `SIRUTA ${p.siruta}` : ""].filter(Boolean).join(" | "),
coordinates: p.lng && p.lat ? [p.lng, p.lat] : undefined,
});
}
}
}
return NextResponse.json({ results: results.slice(0, limit) });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare la cautare";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,100 @@
/**
* GET /api/geoportal/setup-enrichment-views check if views exist
* POST /api/geoportal/setup-enrichment-views create enrichment status views
*
* Creates gis_terenuri_status and gis_cladiri_status views that include
* enrichment metadata (has_enrichment, has_building, build_legal).
* Martin serves these as vector tile sources, MapLibre uses them for
* data-driven styling in the ParcelSync Harta tab.
*
* IMPORTANT: Does NOT modify existing gis_terenuri/gis_cladiri views
* used by the Geoportal module.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VIEWS = [
{
name: "gis_terenuri_status",
sql: `CREATE OR REPLACE VIEW gis_terenuri_status AS
SELECT
f.id,
f."layerId" AS layer_id,
f.siruta,
f."objectId" AS object_id,
f."cadastralRef" AS cadastral_ref,
f."areaValue" AS area_value,
f."isActive" AS is_active,
CASE WHEN f.enrichment IS NOT NULL AND f."enrichedAt" IS NOT NULL THEN 1 ELSE 0 END AS has_enrichment,
COALESCE((f.enrichment->>'HAS_BUILDING')::int, 0) AS has_building,
COALESCE((f.enrichment->>'BUILD_LEGAL')::int, 0) AS build_legal,
f.geom
FROM "GisFeature" f
WHERE f.geom IS NOT NULL
AND (f."layerId" LIKE 'TERENURI%' OR f."layerId" LIKE 'CADGEN_LAND%')`,
},
{
name: "gis_cladiri_status",
sql: `CREATE OR REPLACE VIEW gis_cladiri_status AS
SELECT
f.id,
f."layerId" AS layer_id,
f.siruta,
f."objectId" AS object_id,
f."cadastralRef" AS cadastral_ref,
f."areaValue" AS area_value,
f."isActive" AS is_active,
COALESCE(
(SELECT (p.enrichment->>'BUILD_LEGAL')::int
FROM "GisFeature" p
WHERE p.siruta = f.siruta
AND p."cadastralRef" = f."cadastralRef"
AND (p."layerId" LIKE 'TERENURI%' OR p."layerId" LIKE 'CADGEN_LAND%')
AND p.enrichment IS NOT NULL
LIMIT 1),
-1
) AS build_legal,
f.geom
FROM "GisFeature" f
WHERE f.geom IS NOT NULL
AND (f."layerId" LIKE 'CLADIRI%' OR f."layerId" LIKE 'CADGEN_BUILDING%')`,
},
];
/** GET — check if enrichment views exist */
export async function GET() {
try {
const existing = await prisma.$queryRaw`
SELECT viewname FROM pg_views
WHERE schemaname = 'public' AND (viewname = 'gis_terenuri_status' OR viewname = 'gis_cladiri_status')
` as Array<{ viewname: string }>;
const existingNames = new Set(existing.map((r) => r.viewname));
const missing = VIEWS.filter((v) => !existingNames.has(v.name)).map((v) => v.name);
return NextResponse.json({ ready: missing.length === 0, missing });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ ready: false, missing: VIEWS.map((v) => v.name), error: msg });
}
}
/** POST — create enrichment views (idempotent, drops first if structure changed) */
export async function POST() {
const results: string[] = [];
try {
for (const v of VIEWS) {
// DROP first — CREATE OR REPLACE fails when columns change
await prisma.$executeRawUnsafe(`DROP VIEW IF EXISTS ${v.name} CASCADE`);
await prisma.$executeRawUnsafe(v.sql);
results.push(`${v.name} OK`);
}
return NextResponse.json({ status: "ok", results });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
}
}
@@ -0,0 +1,61 @@
/**
* GET /api/geoportal/setup-views check if views exist
* POST /api/geoportal/setup-views create views (idempotent)
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const VIEWS = [
{
name: "gis_uats_z0",
sql: `CREATE OR REPLACE VIEW gis_uats_z0 AS SELECT siruta, name, ST_SimplifyPreserveTopology(geom, 2000) AS geom FROM "GisUat" WHERE geom IS NOT NULL`,
},
{
name: "gis_uats_z5",
sql: `CREATE OR REPLACE VIEW gis_uats_z5 AS SELECT siruta, name, ST_SimplifyPreserveTopology(geom, 500) AS geom FROM "GisUat" WHERE geom IS NOT NULL`,
},
{
name: "gis_uats_z8",
sql: `CREATE OR REPLACE VIEW gis_uats_z8 AS SELECT siruta, name, county, ST_SimplifyPreserveTopology(geom, 50) AS geom FROM "GisUat" WHERE geom IS NOT NULL`,
},
{
name: "gis_uats_z12",
sql: `CREATE OR REPLACE VIEW gis_uats_z12 AS SELECT siruta, name, county, geom FROM "GisUat" WHERE geom IS NOT NULL`,
},
];
/** GET — returns { ready: boolean, missing: string[] } */
export async function GET() {
try {
const existing = await prisma.$queryRaw`
SELECT viewname FROM pg_views
WHERE schemaname = 'public' AND viewname LIKE 'gis_uats_z%'
` as Array<{ viewname: string }>;
const existingNames = new Set(existing.map((r) => r.viewname));
const missing = VIEWS.filter((v) => !existingNames.has(v.name)).map((v) => v.name);
return NextResponse.json({ ready: missing.length === 0, missing });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ ready: false, missing: VIEWS.map((v) => v.name), error: msg });
}
}
/** POST — creates all views (idempotent) */
export async function POST() {
const results: string[] = [];
try {
for (const v of VIEWS) {
await prisma.$executeRawUnsafe(v.sql);
results.push(`${v.name} OK`);
}
return NextResponse.json({ status: "ok", results });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ status: "error", results, error: msg }, { status: 500 });
}
}
+52
View File
@@ -0,0 +1,52 @@
/**
* GET /api/geoportal/uat-bounds?siruta=57582
*
* Returns WGS84 bounding box for a UAT from PostGIS geometry.
* Used by ParcelSync Harta tab to zoom to selected UAT.
*/
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const siruta = req.nextUrl.searchParams.get("siruta");
if (!siruta) {
return NextResponse.json({ error: "siruta required" }, { status: 400 });
}
try {
const rows = await prisma.$queryRaw`
SELECT
ST_XMin(ST_Transform(geom, 4326)) AS min_lng,
ST_YMin(ST_Transform(geom, 4326)) AS min_lat,
ST_XMax(ST_Transform(geom, 4326)) AS max_lng,
ST_YMax(ST_Transform(geom, 4326)) AS max_lat
FROM "GisUat"
WHERE siruta = ${siruta} AND geom IS NOT NULL
LIMIT 1
` as Array<{
min_lng: number;
min_lat: number;
max_lng: number;
max_lat: number;
}>;
const first = rows[0];
if (!first) {
return NextResponse.json({ error: "UAT not found or no geometry" }, { status: 404 });
}
return NextResponse.json({
siruta,
bounds: [
[first.min_lng, first.min_lat],
[first.max_lng, first.max_lat],
],
});
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+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 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { runDigest } from "@/core/notifications";
import { sendTestDigest } from "@/core/notifications/notification-service";
/**
* POST /api/notifications/digest
*
* Server-to-server endpoint called by N8N cron.
* Auth via Authorization: Bearer <NOTIFICATION_CRON_SECRET>
*
* Query params:
* ?test=true send a test email with sample data to all subscribers
*/
export async function POST(request: Request) {
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 });
}
const url = new URL(request.url);
const isTest = url.searchParams.get("test") === "true";
const result = isTest ? await sendTestDigest() : await runDigest();
return NextResponse.json(result, {
status: result.success ? 200 : 500,
});
}
@@ -0,0 +1,110 @@
import { NextResponse } from "next/server";
import { getAuthSession } from "@/core/auth";
import type { CompanyId } from "@/core/auth/types";
import {
getPreference,
savePreference,
defaultPreference,
} from "@/core/notifications";
import type { NotificationType, NotificationPreference } from "@/core/notifications";
const VALID_TYPES: NotificationType[] = [
"deadline-urgent",
"deadline-overdue",
"document-expiry",
"status-change",
];
type SessionUser = {
id?: string;
name?: string | null;
email?: string | null;
company?: string;
};
/**
* GET /api/notifications/preferences
*
* Returns the current user's notification preferences.
* Creates defaults (all enabled) if none exist.
*/
export async function GET() {
const session = await getAuthSession();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const u = session.user as SessionUser;
const id = u.id ?? "unknown";
const email = u.email ?? "";
const name = u.name ?? "";
const company = (u.company ?? "beletage") as CompanyId;
let pref = await getPreference(id);
if (!pref) {
pref = defaultPreference(id, email, name, company);
await savePreference(pref);
}
return NextResponse.json(pref);
}
/**
* PUT /api/notifications/preferences
*
* Update the current user's notification preferences.
* Body: { enabledTypes?: NotificationType[], globalOptOut?: boolean }
*/
export async function PUT(request: Request) {
const session = await getAuthSession();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const u = session.user as SessionUser;
const id = u.id ?? "unknown";
const email = u.email ?? "";
const name = u.name ?? "";
const company = (u.company ?? "beletage") as CompanyId;
const body = (await request.json()) as Partial<
Pick<NotificationPreference, "enabledTypes" | "globalOptOut">
>;
// Validate types
if (body.enabledTypes) {
const invalid = body.enabledTypes.filter(
(t) => !VALID_TYPES.includes(t),
);
if (invalid.length > 0) {
return NextResponse.json(
{ error: `Tipuri invalide: ${invalid.join(", ")}` },
{ status: 400 },
);
}
}
// Load existing or create default
let pref = await getPreference(id);
if (!pref) {
pref = defaultPreference(id, email, name, company);
}
// Update fields
if (body.enabledTypes !== undefined) {
pref.enabledTypes = body.enabledTypes;
}
if (body.globalOptOut !== undefined) {
pref.globalOptOut = body.globalOptOut;
}
// Always refresh identity from session
pref.email = email;
pref.name = name;
pref.company = company;
await savePreference(pref);
return NextResponse.json(pref);
}
+89
View File
@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
const NAMESPACE = "tags";
// ─── Auth: same Bearer token as address-book ────────────────────────
function checkBearerAuth(req: NextRequest): boolean {
const secret = process.env.ADDRESSBOOK_API_KEY;
if (!secret) return false;
const authHeader = req.headers.get("Authorization");
const token = authHeader?.replace("Bearer ", "");
return token === secret;
}
// ─── GET /api/projects ──────────────────────────────────────────────
// Read-only. Returns all tags with category = "project".
//
// Query params:
// ?q=<search> → search in label / projectCode
// ?company=<companyId> → filter by companyId (beletage, urban-switch, studii-de-teren)
// ?id=<uuid> → single project by tag ID
export async function GET(req: NextRequest) {
if (!checkBearerAuth(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const params = req.nextUrl.searchParams;
const id = params.get("id");
const q = params.get("q")?.toLowerCase();
const company = params.get("company");
try {
// Single project by ID
if (id) {
const item = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: NAMESPACE, key: id } },
});
if (!item) {
return NextResponse.json({ error: "Project not found" }, { status: 404 });
}
const val = item.value as Record<string, unknown>;
if (val.category !== "project") {
return NextResponse.json({ error: "Not a project tag" }, { status: 404 });
}
return NextResponse.json({ project: val });
}
// All project tags
const items = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
select: { key: true, value: true },
});
let projects: Record<string, unknown>[] = [];
for (const item of items) {
const val = item.value as Record<string, unknown>;
if (!val || val.category !== "project") continue;
// Company filter
if (company && val.companyId !== company) continue;
// Search filter
if (q) {
const label = String(val.label ?? "").toLowerCase();
const code = String(val.projectCode ?? "").toLowerCase();
if (!label.includes(q) && !code.includes(q)) continue;
}
projects.push(val);
}
// Sort by projectCode (B-001, B-002, US-001...) then label
projects.sort((a, b) => {
const aCode = String(a.projectCode ?? "");
const bCode = String(b.projectCode ?? "");
if (aCode && bCode) return aCode.localeCompare(bCode);
if (aCode) return -1;
if (bCode) return 1;
return String(a.label ?? "").localeCompare(String(b.label ?? ""), "ro");
});
return NextResponse.json({ projects, total: projects.length });
} catch (error) {
console.error("Projects GET error:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Registratura Audit API Read-only access to audit trail.
*
* GET Retrieve audit events by entry ID or company.
*/
import { NextRequest, NextResponse } from "next/server";
import { getAuthSession } from "@/core/auth";
import {
getAuditHistory,
getAuditByCompany,
} from "@/modules/registratura/services/audit-service";
// ── Auth ──
interface Actor {
id: string;
name: string;
}
async function authenticateRequest(req: NextRequest): Promise<Actor | null> {
const session = await getAuthSession();
if (session?.user) {
const u = session.user as { id?: string; name?: string | null; email?: string | null };
return {
id: u.id ?? u.email ?? "unknown",
name: u.name ?? u.email ?? "unknown",
};
}
const apiKey = process.env.REGISTRY_API_KEY;
if (apiKey) {
const auth = req.headers.get("authorization");
if (auth === `Bearer ${apiKey}`) {
return { id: "api-key", name: "ERP Integration" };
}
}
return null;
}
// ── GET — Audit history ──
export async function GET(req: NextRequest) {
const actor = await authenticateRequest(req);
if (!actor) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const entryId = url.searchParams.get("entryId");
const company = url.searchParams.get("company");
const from = url.searchParams.get("from");
const to = url.searchParams.get("to");
const limit = url.searchParams.get("limit");
try {
if (entryId) {
const events = await getAuditHistory(entryId);
return NextResponse.json({ success: true, events, total: events.length });
}
if (company) {
const events = await getAuditByCompany(company, {
from: from ?? undefined,
to: to ?? undefined,
limit: limit ? parseInt(limit, 10) : undefined,
});
return NextResponse.json({ success: true, events, total: events.length });
}
return NextResponse.json(
{ error: "Provide entryId or company query parameter" },
{ status: 400 },
);
} catch (err) {
const message = err instanceof Error ? err.message : "Internal error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
@@ -0,0 +1,214 @@
/**
* Debug endpoint for registry sequence counters.
*
* GET Show all sequence counters + actual max from entries + sample numbers
* POST Reset all counters to match actual entries (fixes stale counters)
*
* Auth: NextAuth session only.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getAuthSession } from "@/core/auth";
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<
Array<{ company: string; year: number; type: string; lastSeq: number }>
>`SELECT company, year, type, "lastSeq" FROM "RegistrySequence" ORDER BY company, year, type`;
// Sample: show raw value snippet + extracted number (for debugging regex issues)
const samples = await prisma.$queryRawUnsafe<
Array<{ key: string; num: string | null; snippet: string }>
>(`
SELECT key,
SUBSTRING(value::text FROM '"number": "([^"]+)"') AS num,
SUBSTRING(value::text FROM 1 FOR 200) AS snippet
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
ORDER BY key
LIMIT 5
`);
// Get actual max sequences from entries — current format: B-2026-00001
// Use [0-9] instead of \d for PostgreSQL POSIX regex compatibility
const actuals = await prisma.$queryRawUnsafe<
Array<{ prefix: string; maxSeq: number; count: number }>
>(`
SELECT
SUBSTRING(value::text FROM '"number": "([A-Z]-[0-9]{4})-') AS prefix,
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
COUNT(*)::int AS count
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number": "[A-Z]-[0-9]{4}-[0-9]{5}"'
GROUP BY prefix
ORDER BY prefix
`);
// Also check for old-format entries (BTG-2026-OUT-00001)
const oldFormatActuals = await prisma.$queryRawUnsafe<
Array<{ prefix: string; maxSeq: number; count: number }>
>(`
SELECT
SUBSTRING(value::text FROM '"number": "([A-Z]{3}-[0-9]{4})-') AS prefix,
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "maxSeq",
COUNT(*)::int AS count
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number": "[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
GROUP BY prefix
ORDER BY prefix
`);
return NextResponse.json({
counters,
samples,
currentFormatEntries: actuals,
oldFormatEntries: oldFormatActuals,
note: "POST to this endpoint to reset all counters to match actual entries",
});
}
export async function POST() {
const denied = await requireAdmin();
if (denied) return denied;
// Delete ALL old counters
const deleted = await prisma.$executeRaw`DELETE FROM "RegistrySequence"`;
// Re-create counters from current format entries (B-2026-00001)
const insertedNew = await prisma.$executeRawUnsafe(`
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
SELECT
gen_random_uuid()::text,
SUBSTRING(value::text FROM '"number": "([A-Z])-') AS company,
CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-([0-9]{4})-') AS INTEGER) AS year,
'SEQ' AS type,
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]-[0-9]{4}-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
NOW(),
NOW()
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number": "[A-Z]-[0-9]{4}-[0-9]{5}"'
GROUP BY company, year, type
`);
// Also handle old-format entries (BTG→B, USW→U, SDT→S, GRP→G)
const insertedOld = await prisma.$executeRawUnsafe(`
INSERT INTO "RegistrySequence" (id, company, year, type, "lastSeq", "createdAt", "updatedAt")
SELECT
gen_random_uuid()::text,
CASE SUBSTRING(value::text FROM '"number": "([A-Z]{3})-')
WHEN 'BTG' THEN 'B'
WHEN 'USW' THEN 'U'
WHEN 'SDT' THEN 'S'
WHEN 'GRP' THEN 'G'
ELSE SUBSTRING(value::text FROM '"number": "([A-Z]{3})-')
END AS company,
CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-([0-9]{4})-') AS INTEGER) AS year,
'SEQ' AS type,
MAX(CAST(SUBSTRING(value::text FROM '"number": "[A-Z]{3}-[0-9]{4}-(?:IN|OUT|INT)-([0-9]{5})"') AS INTEGER)) AS "lastSeq",
NOW(),
NOW()
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number": "[A-Z]{3}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
GROUP BY company, year, type
ON CONFLICT (company, year, type)
DO UPDATE SET "lastSeq" = GREATEST("RegistrySequence"."lastSeq", EXCLUDED."lastSeq"),
"updatedAt" = NOW()
`);
return NextResponse.json({
success: true,
deletedCounters: deleted,
recreatedFromNewFormat: insertedNew,
recreatedFromOldFormat: insertedOld,
message: "All counters reset from actual entries (both old and new format).",
});
}
/**
* PATCH Migrate old-format entries (BTG/SDT/USW/GRP) to new format (B/S/U/G).
* Rewrites the "number" field inside the JSONB value for matching entries.
*/
export async function PATCH() {
const denied = await requireAdmin();
if (denied) return denied;
// Map old 3-letter prefixes to new single-letter
const migrations: Array<{ old: string; new: string }> = [
{ old: "BTG", new: "B" },
{ old: "SDT", new: "S" },
{ old: "USW", new: "U" },
{ old: "GRP", new: "G" },
];
const results: Array<{ prefix: string; updated: number }> = [];
for (const m of migrations) {
// Find entries with old-format numbers: BTG-2026-IN-00001, SDT-2026-OUT-00002, etc.
const entries = await prisma.$queryRawUnsafe<
Array<{ key: string; num: string }>
>(`
SELECT key,
SUBSTRING(value::text FROM '"number": "([^"]+)"') AS num
FROM "KeyValueStore"
WHERE namespace = 'registratura'
AND key LIKE 'entry:%'
AND value::text ~ '"number": "${m.old}-[0-9]{4}-(IN|OUT|INT)-[0-9]{5}"'
`);
let updated = 0;
for (const entry of entries) {
if (!entry.num) continue;
// Parse: SDT-2026-OUT-00001 → S-2026-00001
const match = entry.num.match(
new RegExp(`^${m.old}-(\\d{4})-(?:IN|OUT|INT)-(\\d{5})$`)
);
if (!match) continue;
const newNumber = `${m.new}-${match[1]}-${match[2]}`;
// Update the JSONB value — replace the number field
await prisma.$executeRawUnsafe(`
UPDATE "KeyValueStore"
SET value = jsonb_set(value, '{number}', $1::jsonb)
WHERE namespace = 'registratura'
AND key = $2
`, JSON.stringify(newNumber), entry.key);
updated++;
}
results.push({ prefix: m.old, updated });
}
return NextResponse.json({
success: true,
migrations: results,
message: "Old-format entries migrated to new format. Run POST to reset counters.",
});
}
+180
View File
@@ -0,0 +1,180 @@
/**
* Reserved Slots API Generate and list reserved registration slots.
*
* POST Generate reserved slots for a company + month
* GET List reserved slots (with claimed/unclaimed status)
*/
import { NextRequest, NextResponse } from "next/server";
import type { Prisma } from "@prisma/client";
import { prisma } from "@/core/storage/prisma";
import { getAuthSession } from "@/core/auth";
import {
generateReservedSlots,
countReservedSlots,
} from "@/modules/registratura/services/reserved-slots-service";
import { logAuditEvent } from "@/modules/registratura/services/audit-service";
import type { RegistryEntry } from "@/modules/registratura/types";
import type { CompanyId } from "@/core/auth/types";
const NAMESPACE = "registratura";
const STORAGE_PREFIX = "entry:";
// ── Auth (same as main route) ──
interface Actor {
id: string;
name: string;
}
async function authenticateRequest(req: NextRequest): Promise<Actor | null> {
const session = await getAuthSession();
if (session?.user) {
const u = session.user as { id?: string; name?: string | null; email?: string | null };
return {
id: u.id ?? u.email ?? "unknown",
name: u.name ?? u.email ?? "unknown",
};
}
const apiKey = process.env.REGISTRY_API_KEY;
if (apiKey) {
const auth = req.headers.get("authorization");
if (auth === `Bearer ${apiKey}`) {
return { id: "api-key", name: "ERP Integration" };
}
}
return null;
}
// ── Helpers ──
async function loadAllEntries(): Promise<RegistryEntry[]> {
const rows = await prisma.keyValueStore.findMany({
where: { namespace: NAMESPACE },
select: { key: true, value: true },
});
const entries: RegistryEntry[] = [];
for (const row of rows) {
if (row.key.startsWith(STORAGE_PREFIX) && row.value) {
entries.push(row.value as unknown as RegistryEntry);
}
}
return entries;
}
// ── POST — Generate reserved slots ──
export async function POST(req: NextRequest) {
const actor = await authenticateRequest(req);
if (!actor) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await req.json();
const { company, year, month } = body as {
company: CompanyId;
year: number;
month: number; // 0-indexed
};
if (!company || year == null || month == null) {
return NextResponse.json(
{ error: "Missing required fields: company, year, month" },
{ status: 400 },
);
}
if (month < 0 || month > 11) {
return NextResponse.json(
{ error: "Month must be 0-11 (0-indexed)" },
{ status: 400 },
);
}
// Check if slots already exist for this company+month
const allEntries = await loadAllEntries();
const existing = countReservedSlots(allEntries, company, year, month);
if (existing >= 2) {
return NextResponse.json(
{ error: "Reserved slots already exist for this month", existingCount: existing },
{ status: 409 },
);
}
// Generate slots
const slots = await generateReservedSlots(
company,
year,
month,
actor.id,
actor.name,
);
// Save to KeyValueStore
for (const slot of slots) {
await prisma.keyValueStore.upsert({
where: {
namespace_key: {
namespace: NAMESPACE,
key: `${STORAGE_PREFIX}${slot.id}`,
},
},
update: { value: slot as unknown as Prisma.InputJsonValue },
create: {
namespace: NAMESPACE,
key: `${STORAGE_PREFIX}${slot.id}`,
value: slot as unknown as Prisma.InputJsonValue,
},
});
await logAuditEvent({
entryId: slot.id,
entryNumber: slot.number,
action: "reserved_created",
actor: actor.id,
actorName: actor.name,
company,
detail: { date: slot.date, month, year },
});
}
return NextResponse.json({ success: true, slots }, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Internal error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
// ── GET — List reserved slots ──
export async function GET(req: NextRequest) {
const actor = await authenticateRequest(req);
if (!actor) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const company = url.searchParams.get("company");
const year = url.searchParams.get("year");
const month = url.searchParams.get("month");
const allEntries = await loadAllEntries();
let reserved = allEntries.filter((e) => e.isReserved === true);
if (company) reserved = reserved.filter((e) => e.company === company);
if (year) {
const yr = parseInt(year, 10);
reserved = reserved.filter((e) => new Date(e.date).getFullYear() === yr);
}
if (month) {
const m = parseInt(month, 10);
reserved = reserved.filter((e) => new Date(e.date).getMonth() === m);
}
reserved.sort((a, b) => a.date.localeCompare(b.date));
return NextResponse.json({ success: true, slots: reserved, total: reserved.length });
}

Some files were not shown because too many files have changed in this diff Show More