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:
@@ -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 }],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user