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();