From b0c4bf91d7e3962c5beba5dbe6862f81b91eaa6a Mon Sep 17 00:00:00 2001 From: AI Assistant Date: Sat, 7 Mar 2026 10:05:39 +0200 Subject: [PATCH] feat(parcel-sync): sync-to-DB + local export + layer catalog enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer catalog now has 3 actions per layer: - Sync: downloads from eTerra, stores in PostgreSQL (GisFeature table), incremental — only new OBJECTIDs fetched, removed ones deleted - GPKG: direct download from eTerra (existing behavior) - Local export: generates GPKG from local DB (no eTerra needed) New features: - /api/eterra/export-local endpoint — builds GPKG from DB, ZIP for multi-layer - /api/eterra/sync now uses session-based auth (no credentials in request) - Category headers show both remote + local feature counts - Each layer shows local DB count (violet badge) + last sync timestamp - 'Export local' button in action bar when any layer has local data - Sync progress message with auto-dismiss DB schema already had GisFeature + GisSyncRun tables from prior work. --- src/app/api/eterra/export-local/route.ts | 138 ++++++ src/app/api/eterra/sync/route.ts | 14 +- .../components/parcel-sync-module.tsx | 401 +++++++++++++++--- 3 files changed, 483 insertions(+), 70 deletions(-) create mode 100644 src/app/api/eterra/export-local/route.ts diff --git a/src/app/api/eterra/export-local/route.ts b/src/app/api/eterra/export-local/route.ts new file mode 100644 index 0000000..80599dc --- /dev/null +++ b/src/app/api/eterra/export-local/route.ts @@ -0,0 +1,138 @@ +/** + * POST /api/eterra/export-local + * + * Export features from local PostgreSQL database as GPKG. + * No eTerra connection needed — serves from previously synced data. + * + * Body: { siruta, layerIds?: string[], allLayers?: boolean } + */ +import { prisma } from "@/core/storage/prisma"; +import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export"; +import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject"; +import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers"; +import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson"; +import JSZip from "jszip"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +type Body = { + siruta?: string; + layerIds?: string[]; + allLayers?: boolean; +}; + +export async function POST(req: Request) { + try { + const body = (await req.json()) as Body; + const siruta = String(body.siruta ?? "").trim(); + + if (!siruta || !/^\d+$/.test(siruta)) { + return new Response(JSON.stringify({ error: "SIRUTA invalid" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + // Determine which layers to export + let layerIds: string[]; + if (body.layerIds?.length) { + layerIds = body.layerIds; + } else if (body.allLayers) { + // Find all layers that have data for this siruta + const layerGroups = await prisma.gisFeature.groupBy({ + by: ["layerId"], + where: { siruta }, + _count: { id: true }, + }); + layerIds = layerGroups + .filter((g) => g._count.id > 0) + .map((g) => g.layerId); + } else { + return new Response( + JSON.stringify({ error: "Specifică layerIds sau allLayers=true" }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + if (layerIds.length === 0) { + return new Response( + JSON.stringify({ + error: "Niciun layer sincronizat în baza de date pentru acest UAT", + }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + + // If single layer, return GPKG directly. If multiple, ZIP them. + if (layerIds.length === 1) { + const layerId = layerIds[0]!; + const gpkg = await buildLayerGpkg(siruta, layerId); + const layer = findLayerById(layerId); + const filename = `eterra_local_${siruta}_${layer?.name ?? layerId}.gpkg`; + return new Response(new Uint8Array(gpkg), { + headers: { + "Content-Type": "application/geopackage+sqlite3", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + } + + // Multiple layers — ZIP + const zip = new JSZip(); + for (const layerId of layerIds) { + const gpkg = await buildLayerGpkg(siruta, layerId); + const layer = findLayerById(layerId); + const name = layer?.name ?? layerId; + zip.file(`${name}.gpkg`, gpkg); + } + + const zipBuffer = await zip.generateAsync({ type: "uint8array" }); + const filename = `eterra_local_${siruta}_${layerIds.length}layers.zip`; + return new Response(Buffer.from(zipBuffer), { + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Eroare server"; + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +} + +/** Build a GPKG from local DB features for one layer+siruta */ +async function buildLayerGpkg(siruta: string, layerId: string) { + const features = await prisma.gisFeature.findMany({ + where: { layerId, siruta }, + select: { attributes: true, geometry: true }, + }); + + if (features.length === 0) { + throw new Error(`Niciun feature local pentru ${layerId} / ${siruta}`); + } + + // Reconstruct GeoJSON features from DB records + const geoFeatures: GeoJsonFeature[] = features + .filter((f) => f.geometry != null) + .map((f) => ({ + type: "Feature" as const, + geometry: f.geometry as GeoJsonFeature["geometry"], + properties: f.attributes as Record, + })); + + // Collect field names from first feature + const fields = Object.keys(geoFeatures[0]?.properties ?? {}); + + const layer = findLayerById(layerId); + const name = layer?.name ?? layerId; + + return buildGpkg({ + srsId: 3844, + srsWkt: getEpsg3844Wkt(), + layers: [{ name, fields, features: geoFeatures }], + }); +} diff --git a/src/app/api/eterra/sync/route.ts b/src/app/api/eterra/sync/route.ts index ae80097..f7623aa 100644 --- a/src/app/api/eterra/sync/route.ts +++ b/src/app/api/eterra/sync/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { syncLayer } from "@/modules/parcel-sync/services/sync-service"; +import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -18,15 +19,12 @@ type Body = { export async function POST(req: Request) { try { const body = (await req.json()) as Body; - const username = ( - body.username ?? - process.env.ETERRA_USERNAME ?? - "" + const session = getSessionCredentials(); + const username = String( + body.username || session?.username || process.env.ETERRA_USERNAME || "", ).trim(); - const password = ( - body.password ?? - process.env.ETERRA_PASSWORD ?? - "" + const password = String( + body.password || session?.password || process.env.ETERRA_PASSWORD || "", ).trim(); const siruta = String(body.siruta ?? "").trim(); const layerId = String(body.layerId ?? "TERENURI_ACTIVE").trim(); diff --git a/src/modules/parcel-sync/components/parcel-sync-module.tsx b/src/modules/parcel-sync/components/parcel-sync-module.tsx index 9607d5e..9364729 100644 --- a/src/modules/parcel-sync/components/parcel-sync-module.tsx +++ b/src/modules/parcel-sync/components/parcel-sync-module.tsx @@ -19,6 +19,9 @@ import { ClipboardCopy, Trash2, Plus, + RefreshCw, + Database, + HardDrive, } from "lucide-react"; import { Button } from "@/shared/components/ui/button"; import { Input } from "@/shared/components/ui/input"; @@ -305,9 +308,36 @@ export function ParcelSyncModule() { const [countingLayers, setCountingLayers] = useState(false); const [layerCountSiruta, setLayerCountSiruta] = useState(""); // siruta for which counts were fetched const [layerHistory, setLayerHistory] = useState< - { layerId: string; label: string; count: number; time: string; siruta: string }[] + { + layerId: string; + label: string; + count: number; + time: string; + siruta: string; + }[] >([]); + /* ── Sync status ────────────────────────────────────────────── */ + type SyncRunInfo = { + id: string; + layerId: string; + status: string; + totalRemote: number; + totalLocal: number; + newFeatures: number; + removedFeatures: number; + startedAt: string; + completedAt?: string; + }; + const [syncLocalCounts, setSyncLocalCounts] = useState< + Record + >({}); + const [syncRuns, setSyncRuns] = useState([]); + const [syncingSiruta, setSyncingSiruta] = useState(""); + const [syncingLayer, setSyncingLayer] = useState(null); + const [syncProgress, setSyncProgress] = useState(""); + const [exportingLocal, setExportingLocal] = useState(false); + /* ── Parcel search tab ──────────────────────────────────────── */ const [searchResults, setSearchResults] = useState([]); const [searchList, setSearchList] = useState([]); @@ -640,6 +670,119 @@ export function ParcelSyncModule() { setCountingLayers(false); }, [siruta, countingLayers]); + /* ════════════════════════════════════════════════════════════ */ + /* Sync status — load local feature counts for current UAT */ + /* ════════════════════════════════════════════════════════════ */ + + const fetchSyncStatus = useCallback(async () => { + if (!siruta) return; + try { + const res = await fetch(`/api/eterra/sync-status?siruta=${siruta}`); + const data = (await res.json()) as { + localCounts?: Record; + runs?: SyncRunInfo[]; + }; + if (data.localCounts) setSyncLocalCounts(data.localCounts); + if (data.runs) setSyncRuns(data.runs); + setSyncingSiruta(siruta); + } catch { + // silent + } + }, [siruta]); + + // Auto-fetch sync status when siruta changes + useEffect(() => { + if (siruta && /^\d+$/.test(siruta)) { + void fetchSyncStatus(); + } + }, [siruta, fetchSyncStatus]); + + const handleSyncLayer = useCallback( + async (layerId: string) => { + if (!siruta || syncingLayer) return; + setSyncingLayer(layerId); + setSyncProgress("Sincronizare pornită…"); + try { + const res = await fetch("/api/eterra/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + siruta, + layerId, + jobId: crypto.randomUUID(), + }), + }); + const data = (await res.json()) as { + status?: string; + newFeatures?: number; + removedFeatures?: number; + totalLocal?: number; + error?: string; + }; + if (data.error) { + setSyncProgress(`Eroare: ${data.error}`); + } else { + setSyncProgress( + `Finalizat — ${data.newFeatures ?? 0} noi, ${data.removedFeatures ?? 0} șterse, ${data.totalLocal ?? 0} total local`, + ); + // Refresh sync status + await fetchSyncStatus(); + } + } catch { + setSyncProgress("Eroare rețea"); + } + // Clear progress after 8s + setTimeout(() => { + setSyncingLayer(null); + setSyncProgress(""); + }, 8_000); + }, + [siruta, syncingLayer, fetchSyncStatus], + ); + + const handleExportLocal = useCallback( + async (layerIds?: string[]) => { + if (!siruta || exportingLocal) return; + setExportingLocal(true); + try { + const res = await fetch("/api/eterra/export-local", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + siruta, + ...(layerIds ? { layerIds } : { allLayers: true }), + }), + }); + if (!res.ok) { + const err = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(err.error ?? `HTTP ${res.status}`); + } + const blob = await res.blob(); + const cd = res.headers.get("Content-Disposition") ?? ""; + const match = /filename="?([^"]+)"?/.exec(cd); + const filename = + match?.[1] ?? + `eterra_local_${siruta}.${layerIds?.length === 1 ? "gpkg" : "zip"}`; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare export"; + setSyncProgress(msg); + setTimeout(() => setSyncProgress(""), 5_000); + } + setExportingLocal(false); + }, + [siruta, exportingLocal], + ); + /* ════════════════════════════════════════════════════════════ */ /* Export individual layer */ /* ════════════════════════════════════════════════════════════ */ @@ -1379,28 +1522,61 @@ export function ParcelSyncModule() { ) : (
- {/* Count all button */} -
+ {/* Action bar */} +

- {layerCountSiruta === siruta && Object.keys(layerCounts).length > 0 + {layerCountSiruta === siruta && + Object.keys(layerCounts).length > 0 ? `Număr features pentru SIRUTA ${siruta}` : "Apasă pentru a număra features-urile din fiecare layer."}

- +
+ {/* Export from local DB */} + {syncingSiruta === siruta && + Object.values(syncLocalCounts).some((c) => c > 0) && ( + + )} + +
+ {/* Sync progress message */} + {syncProgress && ( +
+ {syncingLayer ? ( + + ) : ( + + )} + {syncProgress} +
+ )} + {(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map( (cat) => { const layers = layersByCategory[cat]; @@ -1416,6 +1592,15 @@ export function ParcelSyncModule() { ) : null; + // Sum local counts for category + const catLocal = + syncingSiruta === siruta + ? layers.reduce( + (sum, l) => sum + (syncLocalCounts[l.id] ?? 0), + 0, + ) + : null; + return ( + {/* Direct GPKG from eTerra */} + + {/* Export from local DB */} + {localCount > 0 && ( + )}
-

- {layer.id} -

- ); })} @@ -1529,7 +1799,10 @@ export function ParcelSyncModule() {
Drumul de azi - + {layerHistory.length}
@@ -1549,7 +1822,11 @@ export function ParcelSyncModule() {

SIRUTA {sir}{" "} - — {new Date(entries[0]!.time).toLocaleTimeString("ro-RO", { hour: "2-digit", minute: "2-digit" })} + —{" "} + {new Date(entries[0]!.time).toLocaleTimeString( + "ro-RO", + { hour: "2-digit", minute: "2-digit" }, + )}