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:
Claude VM
2026-04-07 22:56:59 +03:00
parent 8222be2f0e
commit f44d57629f
8 changed files with 742 additions and 3 deletions
+26
View File
@@ -0,0 +1,26 @@
/**
* GET /api/eterra/counties
*
* Returns distinct county names from GisUat, sorted alphabetically.
*/
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET() {
try {
const rows = await prisma.gisUat.findMany({
where: { county: { not: null } },
select: { county: true },
distinct: ["county"],
orderBy: { county: "asc" },
});
const counties = rows.map((r) => r.county).filter(Boolean) as string[];
return NextResponse.json({ counties });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare la interogare judete";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+293
View File
@@ -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;
}
}