377b88c48d
- Add pmtiles-webhook.ts shared helper for triggering PMTiles rebuild - sync-county: trigger rebuild when new features synced, pass jobId to syncLayer for sub-progress, update % after UAT completion (not before) - sync-all-counties: same progress fix + rebuild trigger at end - geoportal monitor: use shared helper instead of raw fetch - weekend-deep-sync + auto-refresh: consolidate webhook code via helper - docker-compose: default N8N_WEBHOOK_URL to pmtiles-webhook on satra:9876 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
308 lines
9.7 KiB
TypeScript
308 lines
9.7 KiB
TypeScript
/**
|
|
* POST /api/eterra/sync-all-counties
|
|
*
|
|
* Starts a background sync for ALL counties in the database (entire Romania).
|
|
* Iterates counties sequentially, running county-sync logic for each.
|
|
* Returns immediately with jobId — progress via /api/eterra/progress.
|
|
*
|
|
* Body: {} (no params needed)
|
|
*/
|
|
|
|
import { prisma } from "@/core/storage/prisma";
|
|
import {
|
|
setProgress,
|
|
clearProgress,
|
|
type SyncProgress,
|
|
} from "@/modules/parcel-sync/services/progress-store";
|
|
import { syncLayer } from "@/modules/parcel-sync/services/sync-service";
|
|
import { enrichFeatures } from "@/modules/parcel-sync/services/enrich-service";
|
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
|
import { checkEterraHealthNow } from "@/modules/parcel-sync/services/eterra-health";
|
|
import { createAppNotification } from "@/core/notifications/app-notifications";
|
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
|
import { firePmtilesRebuild } from "@/modules/parcel-sync/services/pmtiles-webhook";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/* Concurrency guard — blocks both this and single county sync */
|
|
const g = globalThis as {
|
|
__countySyncRunning?: string;
|
|
__allCountiesSyncRunning?: boolean;
|
|
};
|
|
|
|
export async function POST() {
|
|
const session = getSessionCredentials();
|
|
const username = String(session?.username || process.env.ETERRA_USERNAME || "").trim();
|
|
const password = String(session?.password || process.env.ETERRA_PASSWORD || "").trim();
|
|
if (!username || !password) {
|
|
return Response.json(
|
|
{ error: "Credentiale lipsa — conecteaza-te la eTerra mai intai." },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
if (g.__allCountiesSyncRunning) {
|
|
return Response.json(
|
|
{ error: "Sync All Romania deja in curs" },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
if (g.__countySyncRunning) {
|
|
return Response.json(
|
|
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
const jobId = crypto.randomUUID();
|
|
g.__allCountiesSyncRunning = true;
|
|
|
|
setProgress({
|
|
jobId,
|
|
downloaded: 0,
|
|
total: 100,
|
|
status: "running",
|
|
phase: "Pregatire sync Romania...",
|
|
});
|
|
|
|
void runAllCountiesSync(jobId, username, password);
|
|
|
|
return Response.json(
|
|
{ jobId, message: "Sync All Romania pornit" },
|
|
{ status: 202 },
|
|
);
|
|
}
|
|
|
|
async function runAllCountiesSync(
|
|
jobId: string,
|
|
username: string,
|
|
password: string,
|
|
) {
|
|
const push = (p: Partial<SyncProgress>) =>
|
|
setProgress({
|
|
jobId,
|
|
downloaded: 0,
|
|
total: 100,
|
|
status: "running",
|
|
...p,
|
|
} as SyncProgress);
|
|
|
|
try {
|
|
// Health check
|
|
const health = await checkEterraHealthNow();
|
|
if (!health.available) {
|
|
setProgress({
|
|
jobId,
|
|
downloaded: 0,
|
|
total: 100,
|
|
status: "error",
|
|
phase: "eTerra indisponibil",
|
|
message: health.message ?? "maintenance",
|
|
});
|
|
g.__allCountiesSyncRunning = false;
|
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
|
return;
|
|
}
|
|
|
|
// Get all distinct counties, ordered alphabetically
|
|
const countyRows = await prisma.gisUat.groupBy({
|
|
by: ["county"],
|
|
where: { county: { not: null } },
|
|
_count: true,
|
|
orderBy: { county: "asc" },
|
|
});
|
|
|
|
const counties = countyRows
|
|
.map((r) => r.county)
|
|
.filter((c): c is string => c != null);
|
|
|
|
if (counties.length === 0) {
|
|
setProgress({
|
|
jobId,
|
|
downloaded: 100,
|
|
total: 100,
|
|
status: "done",
|
|
phase: "Niciun judet gasit in DB",
|
|
});
|
|
g.__allCountiesSyncRunning = false;
|
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
|
return;
|
|
}
|
|
|
|
push({ phase: `0/${counties.length} judete — pornire...` });
|
|
|
|
const countyResults: Array<{
|
|
county: string;
|
|
uatCount: number;
|
|
errors: number;
|
|
duration: number;
|
|
}> = [];
|
|
let totalErrors = 0;
|
|
let totalUats = 0;
|
|
|
|
for (let ci = 0; ci < counties.length; ci++) {
|
|
const county = counties[ci]!;
|
|
g.__countySyncRunning = county;
|
|
|
|
// Get UATs for this county
|
|
const uats = await prisma.$queryRawUnsafe<
|
|
Array<{
|
|
siruta: string;
|
|
name: string | null;
|
|
total: number;
|
|
enriched: number;
|
|
}>
|
|
>(
|
|
`SELECT u.siruta, u.name,
|
|
COALESCE(f.total, 0)::int as total,
|
|
COALESCE(f.enriched, 0)::int as enriched
|
|
FROM "GisUat" u
|
|
LEFT JOIN (
|
|
SELECT siruta, COUNT(*)::int as total,
|
|
COUNT(*) FILTER (WHERE "enrichedAt" IS NOT NULL)::int as enriched
|
|
FROM "GisFeature"
|
|
WHERE "layerId" IN ('TERENURI_ACTIVE','CLADIRI_ACTIVE') AND "objectId" > 0
|
|
GROUP BY siruta
|
|
) f ON u.siruta = f.siruta
|
|
WHERE u.county = $1
|
|
ORDER BY COALESCE(f.total, 0) DESC`,
|
|
county,
|
|
);
|
|
|
|
if (uats.length === 0) {
|
|
countyResults.push({ county, uatCount: 0, errors: 0, duration: 0 });
|
|
continue;
|
|
}
|
|
|
|
const countyStart = Date.now();
|
|
let countyErrors = 0;
|
|
|
|
for (let i = 0; i < uats.length; i++) {
|
|
const uat = uats[i]!;
|
|
const uatName = uat.name ?? uat.siruta;
|
|
const ratio = uat.total > 0 ? uat.enriched / uat.total : 0;
|
|
const isMagic = ratio > 0.3;
|
|
const mode = isMagic ? "magic" : "base";
|
|
|
|
// Progress: county level + UAT level — update before starting UAT
|
|
const countyPct = ci / counties.length;
|
|
const uatPct = i / uats.length;
|
|
const overallPct = Math.round((countyPct + uatPct / counties.length) * 100);
|
|
|
|
push({
|
|
downloaded: overallPct,
|
|
total: 100,
|
|
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
|
note: countyResults.length > 0
|
|
? `Ultimul judet: ${countyResults[countyResults.length - 1]!.county} (${countyResults[countyResults.length - 1]!.uatCount} UAT, ${countyResults[countyResults.length - 1]!.errors} err)`
|
|
: undefined,
|
|
});
|
|
|
|
try {
|
|
await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", { uatName, jobId, isSubStep: true });
|
|
await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", { uatName, jobId, isSubStep: true });
|
|
|
|
// LIMITE_INTRAV_DYNAMIC — best effort
|
|
try {
|
|
await syncLayer(username, password, uat.siruta, "LIMITE_INTRAV_DYNAMIC", { uatName, jobId, isSubStep: true });
|
|
} catch { /* skip */ }
|
|
|
|
// Enrichment for magic mode
|
|
if (isMagic) {
|
|
try {
|
|
const client = await EterraClient.create(username, password, { timeoutMs: 120_000 });
|
|
await enrichFeatures(client, uat.siruta);
|
|
} catch {
|
|
// Enrichment failure is non-fatal
|
|
}
|
|
}
|
|
} catch (err) {
|
|
countyErrors++;
|
|
const msg = err instanceof Error ? err.message : "Unknown";
|
|
console.error(`[sync-all] ${county}/${uatName}: ${msg}`);
|
|
}
|
|
|
|
// Update progress AFTER UAT completion
|
|
const completedUatPct = (i + 1) / uats.length;
|
|
const completedOverallPct = Math.round((countyPct + completedUatPct / counties.length) * 100);
|
|
push({
|
|
downloaded: completedOverallPct,
|
|
total: 100,
|
|
phase: `[${ci + 1}/${counties.length}] ${county} — [${i + 1}/${uats.length}] ${uatName} finalizat`,
|
|
});
|
|
}
|
|
|
|
const dur = Math.round((Date.now() - countyStart) / 1000);
|
|
countyResults.push({ county, uatCount: uats.length, errors: countyErrors, duration: dur });
|
|
totalErrors += countyErrors;
|
|
totalUats += uats.length;
|
|
|
|
console.log(
|
|
`[sync-all] ${ci + 1}/${counties.length} ${county}: ${uats.length} UAT, ${countyErrors} err, ${dur}s`,
|
|
);
|
|
}
|
|
|
|
const totalDur = countyResults.reduce((s, r) => s + r.duration, 0);
|
|
const summary = `${counties.length} judete, ${totalUats} UAT-uri, ${totalErrors} erori, ${formatDuration(totalDur)}`;
|
|
|
|
setProgress({
|
|
jobId,
|
|
downloaded: 100,
|
|
total: 100,
|
|
status: totalErrors > 0 && totalErrors === totalUats ? "error" : "done",
|
|
phase: "Sync Romania finalizat",
|
|
message: summary,
|
|
});
|
|
|
|
await createAppNotification({
|
|
type: totalErrors > 0 ? "sync-error" : "sync-complete",
|
|
title: totalErrors > 0
|
|
? `Sync Romania: ${totalErrors} erori din ${totalUats} UAT-uri`
|
|
: `Sync Romania: ${totalUats} UAT-uri in ${counties.length} judete`,
|
|
message: summary,
|
|
metadata: { jobId, counties: counties.length, totalUats, totalErrors, totalDuration: totalDur },
|
|
});
|
|
|
|
console.log(`[sync-all] Done: ${summary}`);
|
|
|
|
// Trigger PMTiles rebuild after full Romania sync
|
|
await firePmtilesRebuild("all-counties-sync-complete", {
|
|
counties: counties.length,
|
|
totalUats,
|
|
totalErrors,
|
|
});
|
|
|
|
setTimeout(() => clearProgress(jobId), 12 * 3_600_000);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : "Unknown";
|
|
setProgress({
|
|
jobId,
|
|
downloaded: 0,
|
|
total: 100,
|
|
status: "error",
|
|
phase: "Eroare",
|
|
message: msg,
|
|
});
|
|
await createAppNotification({
|
|
type: "sync-error",
|
|
title: "Sync Romania: eroare generala",
|
|
message: msg,
|
|
metadata: { jobId },
|
|
});
|
|
setTimeout(() => clearProgress(jobId), 3_600_000);
|
|
} finally {
|
|
g.__allCountiesSyncRunning = false;
|
|
g.__countySyncRunning = undefined;
|
|
}
|
|
}
|
|
|
|
function formatDuration(seconds: number): string {
|
|
if (seconds < 60) return `${seconds}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
const h = Math.floor(seconds / 3600);
|
|
const m = Math.floor((seconds % 3600) / 60);
|
|
return `${h}h${String(m).padStart(2, "0")}m`;
|
|
}
|