feat(parcel-sync): native PostGIS geometry support for QGIS

- Remove postgresqlExtensions/postgis from Prisma schema (PostGIS not yet installed)
- Add prisma/postgis-setup.sql: trigger auto-converts GeoJSON→native geometry,
  GiST spatial index, QGIS-friendly views (gis_terenuri, gis_cladiri, etc.)
- Add POST /api/eterra/setup-postgis endpoint (idempotent, runs all SQL setup)
- Add safety-net raw SQL in sync-service: backfills geom after upsert phase
- Add QGIS/PostGIS setup card in layer catalog UI with connection info
- Schema comment documents the trigger-managed 'geom' column approach
This commit is contained in:
AI Assistant
2026-03-07 10:25:30 +02:00
parent b0c4bf91d7
commit 0d0b1f8c9f
5 changed files with 433 additions and 15 deletions
+167
View File
@@ -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 });
}
}