feat(parcel-sync): sync-to-DB + local export + layer catalog enhancements

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.
This commit is contained in:
AI Assistant
2026-03-07 10:05:39 +02:00
parent f73e639e4f
commit b0c4bf91d7
3 changed files with 483 additions and 70 deletions
+138
View File
@@ -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<string, unknown>,
}));
// 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 }],
});
}
+6 -8
View File
@@ -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();
@@ -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<string, number>
>({});
const [syncRuns, setSyncRuns] = useState<SyncRunInfo[]>([]);
const [syncingSiruta, setSyncingSiruta] = useState("");
const [syncingLayer, setSyncingLayer] = useState<string | null>(null);
const [syncProgress, setSyncProgress] = useState("");
const [exportingLocal, setExportingLocal] = useState(false);
/* ── Parcel search tab ──────────────────────────────────────── */
const [searchResults, setSearchResults] = useState<ParcelDetail[]>([]);
const [searchList, setSearchList] = useState<ParcelDetail[]>([]);
@@ -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<string, number>;
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,13 +1522,33 @@ export function ParcelSyncModule() {
</Card>
) : (
<div className="space-y-3">
{/* Count all button */}
<div className="flex items-center justify-between">
{/* Action bar */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<p className="text-xs text-muted-foreground">
{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."}
</p>
<div className="flex items-center gap-2">
{/* Export from local DB */}
{syncingSiruta === siruta &&
Object.values(syncLocalCounts).some((c) => c > 0) && (
<Button
size="sm"
variant="outline"
disabled={exportingLocal}
onClick={() => void handleExportLocal()}
className="border-violet-300 text-violet-700 dark:border-violet-700 dark:text-violet-300"
>
{exportingLocal ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" />
) : (
<HardDrive className="h-3.5 w-3.5 mr-1.5" />
)}
Export local
</Button>
)}
<Button
size="sm"
variant="outline"
@@ -1397,9 +1560,22 @@ export function ParcelSyncModule() {
) : (
<Search className="h-3.5 w-3.5 mr-1.5" />
)}
{countingLayers ? "Se numără…" : "Numără toate"}
{countingLayers ? "Se numără…" : "Numără"}
</Button>
</div>
</div>
{/* Sync progress message */}
{syncProgress && (
<div className="flex items-center gap-2 rounded-lg border px-3 py-2 text-xs">
{syncingLayer ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-blue-500 shrink-0" />
) : (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
)}
<span>{syncProgress}</span>
</div>
)}
{(Object.keys(LAYER_CATEGORY_LABELS) as LayerCategory[]).map(
(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 (
<Card key={cat}>
<button
@@ -1428,7 +1613,7 @@ export function ParcelSyncModule() {
}))
}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">
{LAYER_CATEGORY_LABELS[cat]}
</span>
@@ -1440,7 +1625,12 @@ export function ParcelSyncModule() {
</Badge>
{catTotal != null && catTotal > 0 && (
<Badge className="font-mono text-[10px] bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 border-0">
{catTotal.toLocaleString("ro-RO")} feat.
{catTotal.toLocaleString("ro-RO")} remote
</Badge>
)}
{catLocal != null && catLocal > 0 && (
<Badge className="font-mono text-[10px] bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300 border-0">
{catLocal.toLocaleString("ro-RO")} local
</Badge>
)}
</div>
@@ -1455,22 +1645,35 @@ export function ParcelSyncModule() {
<CardContent className="pt-0 pb-3 space-y-1.5">
{layers.map((layer) => {
const isDownloading = downloadingLayer === layer.id;
const isSyncing = syncingLayer === layer.id;
const lc =
layerCountSiruta === siruta
? layerCounts[layer.id]
: undefined;
const localCount =
syncingSiruta === siruta
? (syncLocalCounts[layer.id] ?? 0)
: 0;
// Find last sync run for this layer
const lastRun = syncRuns.find(
(r) =>
r.layerId === layer.id && r.status === "done",
);
return (
<div
key={layer.id}
className={cn(
"flex items-center justify-between gap-3 rounded-lg border px-3 py-2.5 transition-colors",
isDownloading
"rounded-lg border px-3 py-2.5 transition-colors",
isDownloading || isSyncing
? "border-blue-300 bg-blue-50/50 dark:border-blue-700 dark:bg-blue-950/20"
: "hover:bg-muted/50",
)}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium truncate">
{layer.label}
</p>
@@ -1492,16 +1695,66 @@ export function ParcelSyncModule() {
eroare
</span>
)}
{localCount > 0 && (
<Badge className="font-mono text-[10px] bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300 border-0 shrink-0">
<Database className="h-2.5 w-2.5 mr-0.5" />
{localCount.toLocaleString("ro-RO")}
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<p className="text-[11px] text-muted-foreground font-mono">
{layer.id}
</p>
{lastRun && (
<span className="text-[10px] text-muted-foreground/70">
sync{" "}
{new Date(
lastRun.completedAt ??
lastRun.startedAt,
).toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* Sync to DB */}
<Button
size="sm"
variant="outline"
disabled={
!!syncingLayer ||
!!downloadingLayer ||
exporting
}
onClick={() =>
void handleSyncLayer(layer.id)
}
className="border-violet-200 dark:border-violet-800"
title="Sincronizează în baza de date"
>
{isSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="h-3.5 w-3.5" />
)}
<span className="ml-1.5 hidden sm:inline">
Sync
</span>
</Button>
{/* Direct GPKG from eTerra */}
<Button
size="sm"
variant="outline"
disabled={!!downloadingLayer || exporting}
onClick={() => void handleExportLayer(layer.id)}
onClick={() =>
void handleExportLayer(layer.id)
}
>
{isDownloading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
@@ -1512,6 +1765,23 @@ export function ParcelSyncModule() {
GPKG
</span>
</Button>
{/* Export from local DB */}
{localCount > 0 && (
<Button
size="sm"
variant="ghost"
disabled={exportingLocal}
onClick={() =>
void handleExportLocal([layer.id])
}
title="Exportă din baza de date locală"
className="text-violet-600 dark:text-violet-400"
>
<HardDrive className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
);
})}
@@ -1529,7 +1799,10 @@ export function ParcelSyncModule() {
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-amber-500" />
<span className="text-sm font-semibold">Drumul de azi</span>
<Badge variant="outline" className="font-normal text-[11px]">
<Badge
variant="outline"
className="font-normal text-[11px]"
>
{layerHistory.length}
</Badge>
</div>
@@ -1549,7 +1822,11 @@ export function ParcelSyncModule() {
<p className="text-[11px] font-semibold text-muted-foreground">
SIRUTA {sir}{" "}
<span className="font-normal opacity-70">
{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" },
)}
</span>
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1">