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
+47 -3
View File
@@ -16,6 +16,8 @@ export default function MonitorPage() {
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState("");
const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]);
const [counties, setCounties] = useState<string[]>([]);
const [selectedCounty, setSelectedCounty] = useState("");
const rebuildPrevRef = useRef<string | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -40,6 +42,14 @@ export default function MonitorPage() {
setLogs((prev) => [{ time: new Date().toLocaleTimeString("ro-RO"), type, msg }, ...prev.slice(0, 49)]);
}, []);
// Fetch counties for sync selector
useEffect(() => {
fetch("/api/eterra/counties")
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d: { counties: string[] }) => setCounties(d.counties ?? []))
.catch(() => {});
}, []);
// Cleanup poll on unmount
useEffect(() => {
return () => { if (pollRef.current) clearInterval(pollRef.current); };
@@ -273,6 +283,38 @@ export default function MonitorPage() {
pollRef={pollRef}
/>
</div>
{/* County sync */}
<div className="flex items-end gap-3 mt-2 pt-3 border-t border-border/50">
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">Sync pe judet</span>
<select
value={selectedCounty}
onChange={(e) => setSelectedCounty(e.target.value)}
className="h-9 w-52 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="">Alege judet...</option>
{counties.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
<SyncTestButton
label={selectedCounty ? `Sync ${selectedCounty}` : "Sync Judet"}
description="TERENURI + CLADIRI + INTRAVILAN pentru tot judetul"
siruta=""
mode="base"
includeNoGeometry={false}
actionKey="sync-county"
actionLoading={actionLoading}
setActionLoading={setActionLoading}
addLog={addLog}
pollRef={pollRef}
customEndpoint="/api/eterra/sync-county"
customBody={{ county: selectedCounty }}
disabled={!selectedCounty}
/>
</div>
{logs.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
@@ -366,13 +408,15 @@ function ActionButton({ label, description, loading, onClick }: {
);
}
function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint }: {
function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, actionKey, actionLoading, setActionLoading, addLog, pollRef, customEndpoint, customBody, disabled }: {
label: string; description: string; siruta: string; mode: "base" | "magic";
includeNoGeometry: boolean; actionKey: string; actionLoading: string;
setActionLoading: (v: string) => void;
addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void;
pollRef: React.MutableRefObject<ReturnType<typeof setInterval> | null>;
customEndpoint?: string;
customBody?: Record<string, unknown>;
disabled?: boolean;
}) {
const startTimeRef = useRef<number>(0);
const formatElapsed = () => {
@@ -389,7 +433,7 @@ function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, a
addLog("info", `[${label}] Pornire...`);
try {
const endpoint = customEndpoint ?? "/api/eterra/sync-background";
const body = customEndpoint ? {} : { siruta, mode, includeNoGeometry };
const body = customEndpoint ? (customBody ?? {}) : { siruta, mode, includeNoGeometry };
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -439,7 +483,7 @@ function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, a
}, 3 * 60 * 60_000);
} catch { addLog("error", `[${label}] Eroare retea`); setActionLoading(""); }
}}
disabled={!!actionLoading}
disabled={!!actionLoading || !!disabled}
className="flex flex-col items-start px-4 py-3 rounded-lg border border-border hover:border-primary/50 hover:bg-primary/5 transition-colors disabled:opacity-50 text-left"
>
<span className="font-medium text-sm">{actionLoading === actionKey ? "Se ruleaza..." : label}</span>
+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;
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* GET /api/notifications/app — list recent + unread count
* PATCH /api/notifications/app — mark read / mark all read
*
* Body for PATCH:
* { action: "mark-read", id: string }
* { action: "mark-all-read" }
*/
import { NextResponse } from "next/server";
import {
getAppNotifications,
getUnreadCount,
markAsRead,
markAllAsRead,
} from "@/core/notifications/app-notifications";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "30", 10), 100);
const [notifications, unreadCount] = await Promise.all([
getAppNotifications(limit),
getUnreadCount(),
]);
return NextResponse.json({ notifications, unreadCount });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare notificari";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function PATCH(req: Request) {
try {
const body = (await req.json()) as { action: string; id?: string };
if (body.action === "mark-read" && body.id) {
await markAsRead(body.id);
return NextResponse.json({ ok: true });
}
if (body.action === "mark-all-read") {
await markAllAsRead();
return NextResponse.json({ ok: true });
}
return NextResponse.json({ error: "Actiune necunoscuta" }, { status: 400 });
} catch (error) {
const msg = error instanceof Error ? error.message : "Eroare notificari";
return NextResponse.json({ error: msg }, { status: 500 });
}
}