/* eslint-disable @typescript-eslint/no-explicit-any */ /** * POST /api/eterra/sync-background * * Starts a background sync + enrichment job on the server. * Returns immediately with the jobId — work continues in-process. * Progress is tracked via /api/eterra/progress?jobId=... * * The user can close the browser and come back later; * data is written to PostgreSQL and persists across sessions. * * Body: { siruta, mode?: "base"|"magic", forceSync?: boolean, includeNoGeometry?: boolean } */ import { getSessionCredentials, registerJob, unregisterJob, } from "@/modules/parcel-sync/services/session-store"; import { setProgress, clearProgress, type SyncProgress, } from "@/modules/parcel-sync/services/progress-store"; import { syncLayer } from "@/modules/parcel-sync/services/sync-service"; import { enrichFeatures, getLayerFreshness, isFresh, } from "@/modules/parcel-sync/services/enrich-service"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { syncNoGeometryParcels } from "@/modules/parcel-sync/services/no-geom-sync"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; type Body = { username?: string; password?: string; siruta?: string | number; mode?: "base" | "magic"; forceSync?: boolean; includeNoGeometry?: boolean; }; export async function POST(req: Request) { try { const body = (await req.json()) as Body; 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 mode = body.mode === "magic" ? "magic" : "base"; const forceSync = body.forceSync === true; const includeNoGeometry = body.includeNoGeometry === true; if (!username || !password) { return Response.json( { error: "Credențiale lipsă — conectează-te la eTerra." }, { status: 401 }, ); } if (!/^\d+$/.test(siruta)) { return Response.json({ error: "SIRUTA invalid" }, { status: 400 }); } const jobId = crypto.randomUUID(); registerJob(jobId); // Set initial progress so the UI picks it up immediately setProgress({ jobId, downloaded: 0, total: 100, status: "running", phase: "Pornire sincronizare fundal", }); // Fire and forget — runs in the Node.js event loop after the response is sent. // In Docker standalone mode the Node.js process is long-lived. void runBackground({ jobId, username, password, siruta, mode, forceSync, includeNoGeometry, }); return Response.json( { jobId, message: `Sincronizare ${mode === "magic" ? "Magic" : "bază"} pornită în fundal.`, }, { status: 202 }, ); } catch (error) { const msg = error instanceof Error ? error.message : "Eroare server"; return Response.json({ error: msg }, { status: 500 }); } } /* ────────────────────────────────────────────────── */ /* Background worker */ /* ────────────────────────────────────────────────── */ async function runBackground(params: { jobId: string; username: string; password: string; siruta: string; mode: "base" | "magic"; forceSync: boolean; includeNoGeometry: boolean; }) { const { jobId, username, password, siruta, mode, forceSync, includeNoGeometry, } = params; // Weighted progress (same logic as export-bundle) let completedWeight = 0; let currentWeight = 0; let phase = "Inițializare"; let note: string | undefined; const push = (partial: Partial) => { setProgress({ jobId, downloaded: 0, total: 100, status: "running", phase, note, ...partial, } as SyncProgress); }; const updateOverall = (fraction = 0) => { const overall = completedWeight + currentWeight * fraction; push({ downloaded: Number(Math.min(100, Math.max(0, overall)).toFixed(1)), total: 100, }); }; const setPhase = (next: string, weight: number) => { phase = next; currentWeight = weight; note = undefined; updateOverall(0); }; const finishPhase = () => { completedWeight += currentWeight; currentWeight = 0; note = undefined; updateOverall(0); }; try { const isMagic = mode === "magic"; const hasNoGeom = includeNoGeometry; const weights = isMagic ? hasNoGeom ? { sync: 35, noGeom: 10, enrich: 55 } : { sync: 40, noGeom: 0, enrich: 60 } : hasNoGeom ? { sync: 70, noGeom: 30, enrich: 0 } : { sync: 100, noGeom: 0, enrich: 0 }; /* ── Phase 1: Sync GIS layers ──────────────────────── */ setPhase("Verificare date locale", weights.sync); const [terenuriStatus, cladiriStatus] = await Promise.all([ getLayerFreshness(siruta, "TERENURI_ACTIVE"), getLayerFreshness(siruta, "CLADIRI_ACTIVE"), ]); const terenuriNeedsSync = forceSync || !isFresh(terenuriStatus.lastSynced) || terenuriStatus.featureCount === 0; const cladiriNeedsSync = forceSync || !isFresh(cladiriStatus.lastSynced) || cladiriStatus.featureCount === 0; if (terenuriNeedsSync) { phase = "Sincronizare terenuri"; push({}); const r = await syncLayer(username, password, siruta, "TERENURI_ACTIVE", { forceFullSync: forceSync, jobId, isSubStep: true, }); if (r.status === "error") throw new Error(r.error ?? "Sync terenuri failed"); } updateOverall(0.5); if (cladiriNeedsSync) { phase = "Sincronizare clădiri"; push({}); const r = await syncLayer(username, password, siruta, "CLADIRI_ACTIVE", { forceFullSync: forceSync, jobId, isSubStep: true, }); if (r.status === "error") throw new Error(r.error ?? "Sync clădiri failed"); } // Sync admin layers (always, lightweight) for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) { phase = `Sincronizare ${adminLayer === "LIMITE_UAT" ? "limite UAT" : "limite intravilan"}`; push({}); try { await syncLayer(username, password, siruta, adminLayer, { forceFullSync: forceSync, jobId, isSubStep: true, }); } catch { // Non-critical — don't fail the whole job note = `Avertisment: ${adminLayer} nu s-a sincronizat`; push({}); } } if (!terenuriNeedsSync && !cladiriNeedsSync) { note = "Date proaspete — sync skip"; } finishPhase(); /* ── Phase 2: No-geometry import (optional) ──────── */ if (hasNoGeom && weights.noGeom > 0) { setPhase("Import parcele fără geometrie", weights.noGeom); const noGeomClient = await EterraClient.create(username, password, { timeoutMs: 120_000, }); const res = await syncNoGeometryParcels(noGeomClient, siruta, { onProgress: (done, tot, ph) => { phase = ph; push({}); }, }); if (res.status === "error") { note = `Avertisment no-geom: ${res.error}`; push({}); } else { const cleanNote = res.cleaned > 0 ? `, ${res.cleaned} vechi șterse` : ""; note = `${res.imported} parcele noi importate${cleanNote}`; push({}); } finishPhase(); } /* ── Phase 3: Enrich (magic mode only) ────────────── */ if (isMagic) { setPhase("Verificare îmbogățire", weights.enrich); const enrichStatus = await getLayerFreshness(siruta, "TERENURI_ACTIVE"); const needsEnrich = forceSync || enrichStatus.enrichedCount === 0 || enrichStatus.enrichedCount < enrichStatus.featureCount; if (needsEnrich) { phase = "Îmbogățire parcele (CF, proprietari, adrese)"; push({}); const client = await EterraClient.create(username, password, { timeoutMs: 120_000, }); const enrichResult = await enrichFeatures(client, siruta, { onProgress: (done, tot, ph) => { phase = ph; const frac = tot > 0 ? done / tot : 0; updateOverall(frac); }, }); note = enrichResult.status === "done" ? `Îmbogățite ${enrichResult.enrichedCount}/${enrichResult.totalFeatures ?? "?"}` : `Eroare: ${enrichResult.error}`; } else { note = "Îmbogățire existentă — skip"; } finishPhase(); } /* ── Done ──────────────────────────────────────────── */ setProgress({ jobId, downloaded: 100, total: 100, status: "done", phase: "Sincronizare completă", message: `Datele sunt în baza de date. Descarcă ZIP-ul de acolo oricând.`, note, }); unregisterJob(jobId); // Keep progress visible for 6 hours (background jobs can be very long) setTimeout(() => clearProgress(jobId), 6 * 3_600_000); } catch (error) { const msg = error instanceof Error ? error.message : "Eroare necunoscută"; setProgress({ jobId, downloaded: 0, total: 100, status: "error", phase: "Eroare sincronizare fundal", message: msg, }); unregisterJob(jobId); setTimeout(() => clearProgress(jobId), 6 * 3_600_000); } }