diff --git a/prisma/postgis-setup.sql b/prisma/postgis-setup.sql new file mode 100644 index 0000000..a33bf12 --- /dev/null +++ b/prisma/postgis-setup.sql @@ -0,0 +1,115 @@ +-- ============================================================================= +-- PostGIS native geometry setup for GisFeature +-- Run once via POST /api/eterra/setup-postgis (idempotent — safe to re-run) +-- +-- What this does: +-- 1. Ensures PostGIS extension +-- 2. Adds native geometry column (geom) if missing +-- 3. Creates trigger to auto-convert GeoJSON → native on INSERT/UPDATE +-- 4. Backfills existing features that have JSON geometry but no native geom +-- 5. Creates GiST spatial index for fast spatial queries +-- 6. Creates QGIS-friendly views with clean column names +-- ============================================================================= + +-- 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 = 'GisFeature' AND column_name = 'geom' + ) THEN + ALTER TABLE "GisFeature" ADD COLUMN geom geometry(Geometry, 3844); + END IF; +END $$; + +-- 3. Trigger function: auto-convert GeoJSON (geometry JSON column) → native PostGIS (geom) +CREATE OR REPLACE FUNCTION gis_feature_sync_geom() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.geometry IS NOT NULL THEN + BEGIN + NEW.geom := ST_SetSRID(ST_GeomFromGeoJSON(NEW.geometry::text), 3844); + EXCEPTION WHEN OTHERS THEN + -- Invalid GeoJSON → leave geom NULL rather than fail the write + NEW.geom := NULL; + END; + ELSE + NEW.geom := NULL; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- 4. Attach trigger (drop + recreate for idempotency) +DROP TRIGGER IF EXISTS trg_gis_feature_sync_geom ON "GisFeature"; +CREATE TRIGGER trg_gis_feature_sync_geom + BEFORE INSERT OR UPDATE OF geometry ON "GisFeature" + FOR EACH ROW + EXECUTE FUNCTION gis_feature_sync_geom(); + +-- 5. Backfill: convert existing JSON geometries to native +UPDATE "GisFeature" +SET geom = ST_SetSRID(ST_GeomFromGeoJSON(geometry::text), 3844) +WHERE geometry IS NOT NULL AND geom IS NULL; + +-- 6. GiST spatial index for fast bounding-box / intersection queries +CREATE INDEX IF NOT EXISTS gis_feature_geom_idx + ON "GisFeature" USING GIST (geom); + +-- ============================================================================= +-- 7. QGIS-friendly views +-- - Clean snake_case column names +-- - Only rows with valid geometry +-- - One master view + per-category views +-- ============================================================================= + +-- Master view: all features +CREATE OR REPLACE VIEW gis_features AS +SELECT + id, + "layerId" AS layer_id, + siruta, + "objectId" AS object_id, + "inspireId" AS inspire_id, + "cadastralRef" AS cadastral_ref, + "areaValue" AS area_value, + "isActive" AS is_active, + attributes, + "projectId" AS project_id, + "createdAt" AS created_at, + "updatedAt" AS updated_at, + geom +FROM "GisFeature" +WHERE geom IS NOT NULL; + +-- Terenuri (parcels) +CREATE OR REPLACE VIEW gis_terenuri AS +SELECT * FROM gis_features +WHERE layer_id LIKE 'TERENURI%' OR layer_id LIKE 'CADGEN_LAND%'; + +-- Clădiri (buildings) +CREATE OR REPLACE VIEW gis_cladiri AS +SELECT * FROM gis_features +WHERE layer_id LIKE 'CLADIRI%' OR layer_id LIKE 'CADGEN_BUILDING%'; + +-- Documentații (expertize, zone interes, recepții) +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%'; + +-- Administrativ (limite UAT, intravilan, arii speciale) +CREATE OR REPLACE VIEW gis_administrativ AS +SELECT * FROM gis_features +WHERE layer_id LIKE 'LIMITE%' + OR layer_id LIKE 'SPECIAL_AREAS%'; + +-- ============================================================================= +-- Done! QGIS connection: PostgreSQL → 10.10.10.166:5432 / architools_db +-- Add layers from views: gis_terenuri, gis_cladiri, gis_documentatii, etc. +-- SRID: 3844 (Stereo70) +-- ============================================================================= diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a2f3ac5..f9b82a7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,12 +1,10 @@ generator client { - provider = "prisma-client-js" - previewFeatures = ["postgresqlExtensions"] + provider = "prisma-client-js" } datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - extensions = [postgis] + provider = "postgresql" + url = env("DATABASE_URL") } model KeyValueStore { @@ -24,20 +22,22 @@ model KeyValueStore { // ─── GIS: eTerra ParcelSync ──────────────────────────────────────── model GisFeature { - id String @id @default(uuid()) - layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE + id String @id @default(uuid()) + layerId String // e.g. TERENURI_ACTIVE, CLADIRI_ACTIVE siruta String - objectId Int // eTerra OBJECTID (unique per layer) + objectId Int // eTerra OBJECTID (unique per layer) inspireId String? - cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE + cadastralRef String? // NATIONAL_CADASTRAL_REFERENCE areaValue Float? - isActive Boolean @default(true) - attributes Json // all raw eTerra attributes - geometry Json? // GeoJSON geometry (Polygon/MultiPolygon) + isActive Boolean @default(true) + attributes Json // all raw eTerra attributes + geometry Json? // GeoJSON geometry (Polygon/MultiPolygon) + // NOTE: native PostGIS column 'geom' is managed via SQL trigger (see prisma/postgis-setup.sql) + // Prisma doesn't need to know about it — trigger auto-populates from geometry JSON syncRunId String? - projectId String? // link to project tag - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + projectId String? // link to project tag + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt syncRun GisSyncRun? @relation(fields: [syncRunId], references: [id]) diff --git a/src/app/api/eterra/setup-postgis/route.ts b/src/app/api/eterra/setup-postgis/route.ts new file mode 100644 index 0000000..00a4f59 --- /dev/null +++ b/src/app/api/eterra/setup-postgis/route.ts @@ -0,0 +1,167 @@ +/** + * POST /api/eterra/setup-postgis + * + * One-time (idempotent) setup: adds native PostGIS geometry column, + * trigger for auto-conversion, spatial index, and QGIS-friendly views. + * + * Safe to call multiple times — all operations use IF NOT EXISTS / CREATE OR REPLACE. + */ +import { prisma } from "@/core/storage/prisma"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST() { + try { + // Execute the setup SQL statements individually + // (Prisma $executeRawUnsafe doesn't support multi-statement SQL) + + // 1. Ensure PostGIS extension + await prisma.$executeRawUnsafe(`CREATE EXTENSION IF NOT EXISTS postgis`); + + // 2. Add native geometry column if missing + await prisma.$executeRawUnsafe(` + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'GisFeature' AND column_name = 'geom' + ) THEN + ALTER TABLE "GisFeature" ADD COLUMN geom geometry(Geometry, 3844); + END IF; + END $$ + `); + + // 3. Trigger function + await prisma.$executeRawUnsafe(` + CREATE OR REPLACE FUNCTION gis_feature_sync_geom() + RETURNS TRIGGER AS $$ + BEGIN + IF NEW.geometry IS NOT NULL THEN + BEGIN + NEW.geom := ST_SetSRID(ST_GeomFromGeoJSON(NEW.geometry::text), 3844); + EXCEPTION WHEN OTHERS THEN + NEW.geom := NULL; + END; + ELSE + NEW.geom := NULL; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + `); + + // 4. Trigger + await prisma.$executeRawUnsafe( + `DROP TRIGGER IF EXISTS trg_gis_feature_sync_geom ON "GisFeature"`, + ); + await prisma.$executeRawUnsafe(` + CREATE TRIGGER trg_gis_feature_sync_geom + BEFORE INSERT OR UPDATE OF geometry ON "GisFeature" + FOR EACH ROW + EXECUTE FUNCTION gis_feature_sync_geom() + `); + + // 5. Backfill existing features + const backfilled: { count: bigint }[] = await prisma.$queryRawUnsafe(` + WITH updated AS ( + UPDATE "GisFeature" + SET geom = ST_SetSRID(ST_GeomFromGeoJSON(geometry::text), 3844) + WHERE geometry IS NOT NULL AND geom IS NULL + RETURNING id + ) + SELECT count(*) FROM updated + `); + const backfilledCount = Number(backfilled[0]?.count ?? 0); + + // 6. Spatial index + await prisma.$executeRawUnsafe(` + CREATE INDEX IF NOT EXISTS gis_feature_geom_idx + ON "GisFeature" USING GIST (geom) + `); + + // 7. QGIS-friendly views + await prisma.$executeRawUnsafe(` + CREATE OR REPLACE VIEW gis_features AS + SELECT + id, + "layerId" AS layer_id, + siruta, + "objectId" AS object_id, + "inspireId" AS inspire_id, + "cadastralRef" AS cadastral_ref, + "areaValue" AS area_value, + "isActive" AS is_active, + attributes, + "projectId" AS project_id, + "createdAt" AS created_at, + "updatedAt" AS updated_at, + geom + FROM "GisFeature" + WHERE geom IS NOT NULL + `); + + await prisma.$executeRawUnsafe(` + CREATE OR REPLACE VIEW gis_terenuri AS + SELECT * FROM gis_features + WHERE layer_id LIKE 'TERENURI%' OR layer_id LIKE 'CADGEN_LAND%' + `); + + await prisma.$executeRawUnsafe(` + CREATE OR REPLACE VIEW gis_cladiri AS + SELECT * FROM gis_features + WHERE layer_id LIKE 'CLADIRI%' OR layer_id LIKE 'CADGEN_BUILDING%' + `); + + await prisma.$executeRawUnsafe(` + 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%' + `); + + await prisma.$executeRawUnsafe(` + CREATE OR REPLACE VIEW gis_administrativ AS + SELECT * FROM gis_features + WHERE layer_id LIKE 'LIMITE%' + OR layer_id LIKE 'SPECIAL_AREAS%' + `); + + // Count total features with native geometry + const total: { count: bigint }[] = await prisma.$queryRawUnsafe(` + SELECT count(*) FROM "GisFeature" WHERE geom IS NOT NULL + `); + const totalWithGeom = Number(total[0]?.count ?? 0); + + return Response.json({ + success: true, + message: "PostGIS setup complet", + details: { + backfilledFeatures: backfilledCount, + totalFeaturesWithGeom: totalWithGeom, + trigger: "gis_feature_sync_geom (BEFORE INSERT OR UPDATE OF geometry)", + index: "gis_feature_geom_idx (GiST)", + views: [ + "gis_features (master)", + "gis_terenuri", + "gis_cladiri", + "gis_documentatii", + "gis_administrativ", + ], + qgisConnection: { + host: "10.10.10.166", + port: 5432, + database: "architools_db", + user: "architools_user", + srid: 3844, + note: "Adaugă layere din view-uri: gis_terenuri, gis_cladiri, etc.", + }, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + console.error("[setup-postgis] Error:", error); + return Response.json({ success: false, error: message }, { status: 500 }); + } +} diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 9364729..5f0d793 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -338,6 +338,15 @@ export function ParcelSyncModule() { const [syncProgress, setSyncProgress] = useState(""); const [exportingLocal, setExportingLocal] = useState(false); + /* ── PostGIS setup ───────────────────────────────────────────── */ + const [postgisRunning, setPostgisRunning] = useState(false); + const [postgisResult, setPostgisResult] = useState<{ + success: boolean; + message?: string; + details?: Record; + error?: string; + } | null>(null); + /* ── Parcel search tab ──────────────────────────────────────── */ const [searchResults, setSearchResults] = useState([]); const [searchList, setSearchList] = useState([]); @@ -783,6 +792,25 @@ export function ParcelSyncModule() { [siruta, exportingLocal], ); + /* ════════════════════════════════════════════════════════════ */ + /* PostGIS setup (one-time) */ + /* ════════════════════════════════════════════════════════════ */ + + const handleSetupPostgis = useCallback(async () => { + if (postgisRunning) return; + setPostgisRunning(true); + setPostgisResult(null); + try { + const res = await fetch("/api/eterra/setup-postgis", { method: "POST" }); + const json = await res.json(); + setPostgisResult(json as typeof postgisResult); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare setup"; + setPostgisResult({ success: false, error: msg }); + } + setPostgisRunning(false); + }, [postgisRunning, postgisResult]); + /* ════════════════════════════════════════════════════════════ */ /* Export individual layer */ /* ════════════════════════════════════════════════════════════ */ @@ -1852,6 +1880,103 @@ export function ParcelSyncModule() { )} + + {/* PostGIS / QGIS setup */} + +
+
+
+ + + QGIS / PostGIS + +
+ +
+
+ + {postgisResult ? ( + postgisResult.success ? ( +
+
+ + + {postgisResult.message} + +
+ {postgisResult.details && ( +
+

+ Backfill:{" "} + {String( + ( + postgisResult.details as { + backfilledFeatures?: number; + } + ).backfilledFeatures ?? 0, + )}{" "} + features convertite +

+

+ Total cu geometrie nativă:{" "} + {String( + ( + postgisResult.details as { + totalFeaturesWithGeom?: number; + } + ).totalFeaturesWithGeom ?? 0, + )} +

+

+ QGIS → PostgreSQL → 10.10.10.166:5432 / + architools_db +

+

+ View-uri: gis_terenuri, gis_cladiri, + gis_documentatii, gis_administrativ +

+

SRID: 3844

+
+ )} +
+ ) : ( +
+ +
+

+ PostGIS nu este instalat +

+

+ Instalează PostGIS pe serverul PostgreSQL: +

+ + apt install postgresql-16-postgis-3 + +
+
+ ) + ) : ( +

+ Creează coloana nativă PostGIS, trigger auto-conversie, + index spațial GiST și view-uri QGIS-compatibile. Necesită + PostGIS instalat pe server. +

+ )} +
+
)} diff --git a/src/modules/parcel-sync/services/sync-service.ts b/src/modules/parcel-sync/services/sync-service.ts index 63618c4..5c6d0fd 100644 --- a/src/modules/parcel-sync/services/sync-service.ts +++ b/src/modules/parcel-sync/services/sync-service.ts @@ -221,6 +221,17 @@ export async function syncLayer( }); } + // Populate native PostGIS geometry (safety net if trigger not installed) + try { + await prisma.$executeRaw` + UPDATE "GisFeature" + SET geom = ST_SetSRID(ST_GeomFromGeoJSON(geometry::text), 3844) + WHERE "layerId" = ${layerId} AND siruta = ${siruta} + AND geometry IS NOT NULL AND geom IS NULL`; + } catch { + // PostGIS not available yet — not critical, skip silently + } + // Mark removed features if (removedObjIds.length > 0) { push({ phase: "Marcare șterse" });