feat: county sync on monitor page + in-app notification system
- GET /api/eterra/counties — distinct county list from GisUat - POST /api/eterra/sync-county — background sync all UATs in a county (TERENURI + CLADIRI + INTRAVILAN), magic mode for enriched UATs, concurrency guard, creates notification on completion - In-app notification service (KeyValueStore, CRUD, unread count) - GET/PATCH /api/notifications/app — list and mark-read endpoints - NotificationBell component in header with popover + polling - Monitor page: county select dropdown + SyncTestButton with customBody Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* POST /api/eterra/sync-county
|
||||
*
|
||||
* Starts a background sync for all UATs in a given county.
|
||||
* Syncs TERENURI_ACTIVE, CLADIRI_ACTIVE, and LIMITE_INTRAV_DYNAMIC.
|
||||
* UATs with >30% enrichment → magic mode (sync + enrichment).
|
||||
*
|
||||
* Body: { county: string }
|
||||
* Returns immediately with jobId — progress via /api/eterra/progress.
|
||||
*/
|
||||
|
||||
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";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/* Concurrency guard */
|
||||
const g = globalThis as { __countySyncRunning?: string };
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const username = process.env.ETERRA_USERNAME ?? "";
|
||||
const password = process.env.ETERRA_PASSWORD ?? "";
|
||||
if (!username || !password) {
|
||||
return Response.json(
|
||||
{ error: "ETERRA_USERNAME / ETERRA_PASSWORD nu sunt configurate" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: { county?: string };
|
||||
try {
|
||||
body = (await req.json()) as { county?: string };
|
||||
} catch {
|
||||
return Response.json({ error: "Body invalid" }, { status: 400 });
|
||||
}
|
||||
|
||||
const county = body.county?.trim();
|
||||
if (!county) {
|
||||
return Response.json({ error: "Judetul lipseste" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (g.__countySyncRunning) {
|
||||
return Response.json(
|
||||
{ error: `Sync judet deja in curs: ${g.__countySyncRunning}` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
|
||||
const jobId = crypto.randomUUID();
|
||||
g.__countySyncRunning = county;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 0,
|
||||
total: 100,
|
||||
status: "running",
|
||||
phase: `Pregatire sync ${county}`,
|
||||
});
|
||||
|
||||
void runCountySync(jobId, county, username, password);
|
||||
|
||||
return Response.json(
|
||||
{ jobId, message: `Sync judet ${county} pornit` },
|
||||
{ status: 202 },
|
||||
);
|
||||
}
|
||||
|
||||
async function runCountySync(
|
||||
jobId: string,
|
||||
county: 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",
|
||||
});
|
||||
await createAppNotification({
|
||||
type: "sync-error",
|
||||
title: `Sync ${county}: eTerra indisponibil`,
|
||||
message: health.message ?? "Serviciul eTerra este in mentenanta",
|
||||
metadata: { county, jobId },
|
||||
});
|
||||
g.__countySyncRunning = undefined;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all UATs in this county with feature stats
|
||||
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) {
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: "done",
|
||||
phase: `Niciun UAT gasit in ${county}`,
|
||||
});
|
||||
g.__countySyncRunning = undefined;
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
return;
|
||||
}
|
||||
|
||||
const results: Array<{
|
||||
siruta: string;
|
||||
name: string;
|
||||
mode: string;
|
||||
duration: number;
|
||||
note: string;
|
||||
}> = [];
|
||||
let errors = 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";
|
||||
const pct = Math.round((i / uats.length) * 100);
|
||||
|
||||
push({
|
||||
downloaded: pct,
|
||||
total: 100,
|
||||
phase: `[${i + 1}/${uats.length}] ${uatName} (${mode})`,
|
||||
note:
|
||||
results.length > 0
|
||||
? `Ultimul: ${results[results.length - 1]!.name} — ${results[results.length - 1]!.note}`
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const uatStart = Date.now();
|
||||
try {
|
||||
// Sync TERENURI + CLADIRI
|
||||
const tRes = await syncLayer(username, password, uat.siruta, "TERENURI_ACTIVE", {
|
||||
uatName,
|
||||
});
|
||||
const cRes = await syncLayer(username, password, uat.siruta, "CLADIRI_ACTIVE", {
|
||||
uatName,
|
||||
});
|
||||
|
||||
// Sync ADMINISTRATIV (intravilan) — wrapped in try/catch since it needs UAT geometry
|
||||
let adminNote = "";
|
||||
try {
|
||||
const aRes = await syncLayer(
|
||||
username,
|
||||
password,
|
||||
uat.siruta,
|
||||
"LIMITE_INTRAV_DYNAMIC",
|
||||
{ uatName },
|
||||
);
|
||||
if (aRes.newFeatures > 0) {
|
||||
adminNote = ` | A:+${aRes.newFeatures}`;
|
||||
}
|
||||
} catch {
|
||||
adminNote = " | A:skip";
|
||||
}
|
||||
|
||||
// Enrichment for magic mode
|
||||
let enrichNote = "";
|
||||
if (isMagic) {
|
||||
const client = await EterraClient.create(username, password, {
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
const eRes = await enrichFeatures(client, uat.siruta);
|
||||
enrichNote =
|
||||
eRes.status === "done"
|
||||
? ` | enrich: ${eRes.enrichedCount}/${eRes.totalFeatures ?? "?"}`
|
||||
: ` | enrich err: ${eRes.error}`;
|
||||
}
|
||||
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const parts = [
|
||||
tRes.newFeatures > 0 || (tRes.validFromUpdated ?? 0) > 0
|
||||
? `T:+${tRes.newFeatures}/${tRes.validFromUpdated ?? 0}vf`
|
||||
: "T:ok",
|
||||
cRes.newFeatures > 0 || (cRes.validFromUpdated ?? 0) > 0
|
||||
? `C:+${cRes.newFeatures}/${cRes.validFromUpdated ?? 0}vf`
|
||||
: "C:ok",
|
||||
];
|
||||
const note = `${parts.join(", ")}${adminNote}${enrichNote} (${dur}s)`;
|
||||
results.push({ siruta: uat.siruta, name: uatName, mode, duration: dur, note });
|
||||
console.log(`[sync-county:${county}] ${i + 1}/${uats.length} ${uatName}: ${note}`);
|
||||
} catch (err) {
|
||||
errors++;
|
||||
const dur = Math.round((Date.now() - uatStart) / 1000);
|
||||
const msg = err instanceof Error ? err.message : "Unknown";
|
||||
results.push({
|
||||
siruta: uat.siruta,
|
||||
name: uatName,
|
||||
mode,
|
||||
duration: dur,
|
||||
note: `ERR: ${msg}`,
|
||||
});
|
||||
console.error(`[sync-county:${county}] ${uatName}: ${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const totalDur = results.reduce((s, r) => s + r.duration, 0);
|
||||
const summary = `${uats.length} UAT-uri, ${errors} erori, ${totalDur}s total`;
|
||||
|
||||
setProgress({
|
||||
jobId,
|
||||
downloaded: 100,
|
||||
total: 100,
|
||||
status: errors > 0 && errors === uats.length ? "error" : "done",
|
||||
phase: `Sync ${county} finalizat`,
|
||||
message: summary,
|
||||
note: results.map((r) => `${r.name}: ${r.note}`).join("\n"),
|
||||
});
|
||||
|
||||
await createAppNotification({
|
||||
type: errors > 0 ? "sync-error" : "sync-complete",
|
||||
title:
|
||||
errors > 0
|
||||
? `Sync ${county}: ${errors} erori din ${uats.length} UAT-uri`
|
||||
: `Sync ${county}: ${uats.length} UAT-uri sincronizate`,
|
||||
message: summary,
|
||||
metadata: { county, jobId, uatCount: uats.length, errors, totalDuration: totalDur },
|
||||
});
|
||||
|
||||
console.log(`[sync-county:${county}] Done: ${summary}`);
|
||||
setTimeout(() => clearProgress(jobId), 6 * 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 ${county}: eroare generala`,
|
||||
message: msg,
|
||||
metadata: { county, jobId },
|
||||
});
|
||||
setTimeout(() => clearProgress(jobId), 3_600_000);
|
||||
} finally {
|
||||
g.__countySyncRunning = undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user