b0927ee075
- Rewrite export-bundle to sync-first: check freshness -> sync layers -> enrich (magic) -> build GPKG/CSV from local DB - Rewrite export-layer-gpkg to sync-first: sync if stale -> export from DB - Create enrich-service.ts: extracted magic enrichment logic (CF, owners, addresses) with DB storage - Add enrichment + enrichedAt columns to GisFeature schema - Update PostGIS views to include enrichment data - UI: update button labels for sync-first semantics, refresh sync status after exports - Smart caching: skip sync if data is fresh (168h / 1 week default)
269 lines
7.8 KiB
TypeScript
269 lines
7.8 KiB
TypeScript
/**
|
|
* POST /api/eterra/export-layer-gpkg (v2 — sync-first)
|
|
*
|
|
* Flow:
|
|
* 1. Check local DB freshness for the requested layer
|
|
* 2. If stale/empty → sync from eTerra (stores in DB)
|
|
* 3. Build GPKG from local DB
|
|
* 4. Return GPKG
|
|
*
|
|
* Body: { username?, password?, siruta, layerId, jobId?, forceSync? }
|
|
*/
|
|
import { prisma } from "@/core/storage/prisma";
|
|
import { findLayerById } from "@/modules/parcel-sync/services/eterra-layers";
|
|
import { getEpsg3844Wkt } from "@/modules/parcel-sync/services/reproject";
|
|
import { buildGpkg } from "@/modules/parcel-sync/services/gpkg-export";
|
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
|
import {
|
|
getLayerFreshness,
|
|
isFresh,
|
|
} from "@/modules/parcel-sync/services/enrich-service";
|
|
import {
|
|
clearProgress,
|
|
setProgress,
|
|
} from "@/modules/parcel-sync/services/progress-store";
|
|
import {
|
|
getSessionCredentials,
|
|
registerJob,
|
|
unregisterJob,
|
|
} from "@/modules/parcel-sync/services/session-store";
|
|
import type { GeoJsonFeature } from "@/modules/parcel-sync/services/esri-geojson";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
type ExportLayerRequest = {
|
|
username?: string;
|
|
password?: string;
|
|
siruta?: string | number;
|
|
layerId?: string;
|
|
jobId?: string;
|
|
forceSync?: boolean;
|
|
};
|
|
|
|
const validate = (body: ExportLayerRequest) => {
|
|
const session = getSessionCredentials();
|
|
const username = String(
|
|
body.username || session?.username || process.env.ETERRA_USERNAME || "",
|
|
).trim();
|
|
const password = String(
|
|
body.password || session?.password || process.env.ETERRA_PASSWORD || "",
|
|
).trim();
|
|
const siruta = String(body.siruta ?? "").trim();
|
|
const layerId = String(body.layerId ?? "").trim();
|
|
const jobId = body.jobId ? String(body.jobId).trim() : undefined;
|
|
const forceSync = body.forceSync === true;
|
|
|
|
if (!username) throw new Error("Email is required");
|
|
if (!password) throw new Error("Password is required");
|
|
if (!/^\d+$/.test(siruta)) throw new Error("SIRUTA must be numeric");
|
|
if (!layerId) throw new Error("Layer ID missing");
|
|
|
|
return { username, password, siruta, layerId, jobId, forceSync };
|
|
};
|
|
|
|
const scheduleClear = (jobId?: string) => {
|
|
if (!jobId) return;
|
|
setTimeout(() => clearProgress(jobId), 60_000);
|
|
};
|
|
|
|
export async function POST(req: Request) {
|
|
let jobId: string | undefined;
|
|
let message: string | undefined;
|
|
let phase = "Inițializare";
|
|
let note: string | undefined;
|
|
let status: "running" | "done" | "error" = "running";
|
|
let downloaded = 0;
|
|
let total: number | undefined;
|
|
let completedWeight = 0;
|
|
let currentWeight = 0;
|
|
let phaseTotal: number | undefined;
|
|
let phaseCurrent: number | undefined;
|
|
|
|
const pushProgress = () => {
|
|
if (!jobId) return;
|
|
setProgress({
|
|
jobId,
|
|
downloaded,
|
|
total,
|
|
status,
|
|
phase,
|
|
note,
|
|
message,
|
|
phaseCurrent,
|
|
phaseTotal,
|
|
});
|
|
};
|
|
|
|
const updateOverall = (fraction = 0) => {
|
|
const overall = completedWeight + currentWeight * fraction;
|
|
downloaded = Number(Math.min(100, Math.max(0, overall)).toFixed(1));
|
|
total = 100;
|
|
pushProgress();
|
|
};
|
|
|
|
const setPhaseState = (next: string, weight: number, nextTotal?: number) => {
|
|
phase = next;
|
|
currentWeight = weight;
|
|
phaseTotal = nextTotal;
|
|
phaseCurrent = nextTotal ? 0 : undefined;
|
|
note = undefined;
|
|
updateOverall(0);
|
|
};
|
|
|
|
const updatePhaseProgress = (value: number, nextTotal?: number) => {
|
|
if (typeof nextTotal === "number") phaseTotal = nextTotal;
|
|
if (phaseTotal && phaseTotal > 0) {
|
|
phaseCurrent = value;
|
|
updateOverall(Math.min(1, value / phaseTotal));
|
|
} else {
|
|
phaseCurrent = undefined;
|
|
updateOverall(0);
|
|
}
|
|
};
|
|
|
|
const finishPhase = () => {
|
|
completedWeight += currentWeight;
|
|
currentWeight = 0;
|
|
phaseTotal = undefined;
|
|
phaseCurrent = undefined;
|
|
note = undefined;
|
|
updateOverall(0);
|
|
};
|
|
|
|
const withHeartbeat = async <T>(task: () => Promise<T>) => {
|
|
let tick = 0.1;
|
|
updatePhaseProgress(tick, 1);
|
|
const interval = setInterval(() => {
|
|
tick = Math.min(0.9, tick + 0.05);
|
|
updatePhaseProgress(tick, 1);
|
|
}, 1200);
|
|
try {
|
|
return await task();
|
|
} finally {
|
|
clearInterval(interval);
|
|
}
|
|
};
|
|
|
|
try {
|
|
const body = (await req.json()) as ExportLayerRequest;
|
|
const validated = validate(body);
|
|
jobId = validated.jobId;
|
|
if (jobId) registerJob(jobId);
|
|
pushProgress();
|
|
|
|
const layer = findLayerById(validated.layerId);
|
|
if (!layer) throw new Error("Layer not configured");
|
|
|
|
const weights = { sync: 60, gpkg: 30, finalize: 10 };
|
|
|
|
/* ── Phase 1: Check freshness & sync if needed ── */
|
|
setPhaseState("Verificare date locale", weights.sync, 1);
|
|
|
|
const freshness = await getLayerFreshness(
|
|
validated.siruta,
|
|
validated.layerId,
|
|
);
|
|
const needsSync =
|
|
validated.forceSync ||
|
|
!isFresh(freshness.lastSynced) ||
|
|
freshness.featureCount === 0;
|
|
|
|
let syncedFromCache = true;
|
|
|
|
if (needsSync) {
|
|
syncedFromCache = false;
|
|
phase = `Sincronizare ${layer.name}`;
|
|
note =
|
|
freshness.featureCount > 0
|
|
? "Re-sync (date expirate)"
|
|
: "Sync inițial de la eTerra";
|
|
pushProgress();
|
|
|
|
await syncLayer(
|
|
validated.username,
|
|
validated.password,
|
|
validated.siruta,
|
|
validated.layerId,
|
|
{ forceFullSync: validated.forceSync },
|
|
);
|
|
} else {
|
|
note = "Date proaspete în baza de date — skip sync";
|
|
pushProgress();
|
|
}
|
|
updatePhaseProgress(1, 1);
|
|
finishPhase();
|
|
|
|
/* ── Phase 2: Build GPKG from local DB ── */
|
|
setPhaseState("Generare GPKG din baza de date", weights.gpkg, 1);
|
|
|
|
const features = await prisma.gisFeature.findMany({
|
|
where: { layerId: validated.layerId, siruta: validated.siruta },
|
|
select: { attributes: true, geometry: true },
|
|
});
|
|
|
|
if (features.length === 0) {
|
|
throw new Error(
|
|
`Niciun feature în DB pentru ${layer.name} / SIRUTA ${validated.siruta}`,
|
|
);
|
|
}
|
|
|
|
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>,
|
|
}));
|
|
|
|
const fields = Object.keys(geoFeatures[0]?.properties ?? {});
|
|
|
|
const gpkg = await withHeartbeat(() =>
|
|
buildGpkg({
|
|
srsId: 3844,
|
|
srsWkt: getEpsg3844Wkt(),
|
|
layers: [{ name: layer.name, fields, features: geoFeatures }],
|
|
}),
|
|
);
|
|
updatePhaseProgress(1, 1);
|
|
finishPhase();
|
|
|
|
/* ── Phase 3: Finalize ── */
|
|
setPhaseState("Finalizare", weights.finalize, 1);
|
|
updatePhaseProgress(1, 1);
|
|
finishPhase();
|
|
|
|
const suffix = syncedFromCache ? " (din cache local)" : "";
|
|
status = "done";
|
|
phase = "Finalizat";
|
|
message = `Finalizat 100% · ${geoFeatures.length} elemente${suffix}`;
|
|
pushProgress();
|
|
scheduleClear(jobId);
|
|
|
|
if (jobId) unregisterJob(jobId);
|
|
const filename = `eterra_uat_${validated.siruta}_${layer.name}.gpkg`;
|
|
return new Response(new Uint8Array(gpkg), {
|
|
headers: {
|
|
"Content-Type": "application/geopackage+sqlite3",
|
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const errMessage =
|
|
error instanceof Error ? error.message : "Unexpected server error";
|
|
status = "error";
|
|
message = errMessage;
|
|
note = undefined;
|
|
pushProgress();
|
|
scheduleClear(jobId);
|
|
if (jobId) unregisterJob(jobId);
|
|
const lower = errMessage.toLowerCase();
|
|
const statusCode =
|
|
lower.includes("login failed") || lower.includes("session") ? 401 : 400;
|
|
return new Response(JSON.stringify({ error: errMessage }), {
|
|
status: statusCode,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
}
|