/** * 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 (task: () => Promise) => { 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, })); 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" }, }); } }