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:
@@ -16,6 +16,8 @@ export default function MonitorPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState("");
|
const [actionLoading, setActionLoading] = useState("");
|
||||||
const [logs, setLogs] = useState<{ time: string; type: "info" | "ok" | "error" | "wait"; msg: string }[]>([]);
|
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 rebuildPrevRef = useRef<string | null>(null);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | 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)]);
|
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
|
// Cleanup poll on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
return () => { if (pollRef.current) clearInterval(pollRef.current); };
|
||||||
@@ -273,6 +283,38 @@ export default function MonitorPage() {
|
|||||||
pollRef={pollRef}
|
pollRef={pollRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 && (
|
{logs.length > 0 && (
|
||||||
<div className="border border-border rounded-lg overflow-hidden">
|
<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">
|
<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";
|
label: string; description: string; siruta: string; mode: "base" | "magic";
|
||||||
includeNoGeometry: boolean; actionKey: string; actionLoading: string;
|
includeNoGeometry: boolean; actionKey: string; actionLoading: string;
|
||||||
setActionLoading: (v: string) => void;
|
setActionLoading: (v: string) => void;
|
||||||
addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void;
|
addLog: (type: "info" | "ok" | "error" | "wait", msg: string) => void;
|
||||||
pollRef: React.MutableRefObject<ReturnType<typeof setInterval> | null>;
|
pollRef: React.MutableRefObject<ReturnType<typeof setInterval> | null>;
|
||||||
customEndpoint?: string;
|
customEndpoint?: string;
|
||||||
|
customBody?: Record<string, unknown>;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const startTimeRef = useRef<number>(0);
|
const startTimeRef = useRef<number>(0);
|
||||||
const formatElapsed = () => {
|
const formatElapsed = () => {
|
||||||
@@ -389,7 +433,7 @@ function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, a
|
|||||||
addLog("info", `[${label}] Pornire...`);
|
addLog("info", `[${label}] Pornire...`);
|
||||||
try {
|
try {
|
||||||
const endpoint = customEndpoint ?? "/api/eterra/sync-background";
|
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, {
|
const res = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -439,7 +483,7 @@ function SyncTestButton({ label, description, siruta, mode, includeNoGeometry, a
|
|||||||
}, 3 * 60 * 60_000);
|
}, 3 * 60 * 60_000);
|
||||||
} catch { addLog("error", `[${label}] Eroare retea`); setActionLoading(""); }
|
} 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"
|
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>
|
<span className="font-medium text-sm">{actionLoading === actionKey ? "Se ruleaza..." : label}</span>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* In-app notification service.
|
||||||
|
*
|
||||||
|
* Stores lightweight notifications in KeyValueStore (namespace "app-notifications").
|
||||||
|
* Used for sync completion alerts, errors, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { prisma } from "@/core/storage/prisma";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Types */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export type AppNotificationType = "sync-complete" | "sync-error";
|
||||||
|
|
||||||
|
export interface AppNotification {
|
||||||
|
id: string;
|
||||||
|
type: AppNotificationType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: string;
|
||||||
|
readAt: string | null;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAMESPACE = "app-notifications";
|
||||||
|
const MAX_AGE_DAYS = 30;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Create */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function createAppNotification(
|
||||||
|
input: Omit<AppNotification, "id" | "createdAt" | "readAt">,
|
||||||
|
): Promise<AppNotification> {
|
||||||
|
const notification: AppNotification = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
readAt: null,
|
||||||
|
...input,
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.keyValueStore.create({
|
||||||
|
data: {
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
key: notification.id,
|
||||||
|
value: notification as unknown as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Read */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function getAppNotifications(limit = 30): Promise<AppNotification[]> {
|
||||||
|
const rows = await prisma.keyValueStore.findMany({
|
||||||
|
where: { namespace: NAMESPACE },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cutoff = Date.now() - MAX_AGE_DAYS * 86_400_000;
|
||||||
|
const notifications: AppNotification[] = [];
|
||||||
|
const staleIds: string[] = [];
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const n = row.value as unknown as AppNotification;
|
||||||
|
if (new Date(n.createdAt).getTime() < cutoff) {
|
||||||
|
staleIds.push(row.id);
|
||||||
|
} else {
|
||||||
|
notifications.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy cleanup of old notifications
|
||||||
|
if (staleIds.length > 0) {
|
||||||
|
void prisma.keyValueStore.deleteMany({
|
||||||
|
where: { id: { in: staleIds } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return notifications;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUnreadCount(): Promise<number> {
|
||||||
|
const rows = await prisma.$queryRaw<Array<{ count: number }>>`
|
||||||
|
SELECT COUNT(*)::int as count
|
||||||
|
FROM "KeyValueStore"
|
||||||
|
WHERE namespace = ${NAMESPACE}
|
||||||
|
AND value->>'readAt' IS NULL
|
||||||
|
`;
|
||||||
|
return rows[0]?.count ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Update */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
export async function markAsRead(id: string): Promise<void> {
|
||||||
|
const row = await prisma.keyValueStore.findUnique({
|
||||||
|
where: { namespace_key: { namespace: NAMESPACE, key: id } },
|
||||||
|
});
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const n = row.value as unknown as AppNotification;
|
||||||
|
n.readAt = new Date().toISOString();
|
||||||
|
|
||||||
|
await prisma.keyValueStore.update({
|
||||||
|
where: { namespace_key: { namespace: NAMESPACE, key: id } },
|
||||||
|
data: { value: n as unknown as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markAllAsRead(): Promise<void> {
|
||||||
|
const rows = await prisma.keyValueStore.findMany({
|
||||||
|
where: { namespace: NAMESPACE },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updates = rows
|
||||||
|
.filter((r) => {
|
||||||
|
const n = r.value as unknown as AppNotification;
|
||||||
|
return n.readAt === null;
|
||||||
|
})
|
||||||
|
.map((r) => {
|
||||||
|
const n = r.value as unknown as AppNotification;
|
||||||
|
n.readAt = now;
|
||||||
|
return prisma.keyValueStore.update({
|
||||||
|
where: { namespace_key: { namespace: NAMESPACE, key: r.key } },
|
||||||
|
data: { value: n as unknown as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updates.length > 0) {
|
||||||
|
await prisma.$transaction(updates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,3 +15,12 @@ export {
|
|||||||
getAllPreferences,
|
getAllPreferences,
|
||||||
runDigest,
|
runDigest,
|
||||||
} from "./notification-service";
|
} from "./notification-service";
|
||||||
|
export {
|
||||||
|
createAppNotification,
|
||||||
|
getAppNotifications,
|
||||||
|
getUnreadCount,
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
type AppNotification,
|
||||||
|
type AppNotificationType,
|
||||||
|
} from "./app-notifications";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { useAuth } from "@/core/auth";
|
import { useAuth } from "@/core/auth";
|
||||||
import { signIn, signOut } from "next-auth/react";
|
import { signIn, signOut } from "next-auth/react";
|
||||||
import { ThemeToggle } from "@/shared/components/common/theme-toggle";
|
import { ThemeToggle } from "@/shared/components/common/theme-toggle";
|
||||||
|
import { NotificationBell } from "./notification-bell";
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
@@ -35,6 +36,7 @@ export function Header({ onToggleSidebar }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<NotificationBell />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Bell, Check, CheckCheck, AlertTriangle } from "lucide-react";
|
||||||
|
import { Button } from "@/shared/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/shared/components/ui/popover";
|
||||||
|
import { ScrollArea } from "@/shared/components/ui/scroll-area";
|
||||||
|
import type { AppNotification } from "@/core/notifications/app-notifications";
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 60_000; // 60s
|
||||||
|
|
||||||
|
function relativeTime(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diff / 60_000);
|
||||||
|
if (mins < 1) return "acum";
|
||||||
|
if (mins < 60) return `acum ${mins} min`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `acum ${hours} ore`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days === 1) return "ieri";
|
||||||
|
return `acum ${days} zile`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchUnreadCount = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notifications/app?limit=1");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as { unreadCount: number };
|
||||||
|
setUnreadCount(data.unreadCount);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAll = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/notifications/app?limit=30");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
notifications: AppNotification[];
|
||||||
|
unreadCount: number;
|
||||||
|
};
|
||||||
|
setNotifications(data.notifications);
|
||||||
|
setUnreadCount(data.unreadCount);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Poll unread count
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUnreadCount();
|
||||||
|
const id = setInterval(fetchUnreadCount, POLL_INTERVAL);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [fetchUnreadCount]);
|
||||||
|
|
||||||
|
// Fetch full list when popover opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) fetchAll();
|
||||||
|
}, [open, fetchAll]);
|
||||||
|
|
||||||
|
const handleMarkRead = async (id: string) => {
|
||||||
|
await fetch("/api/notifications/app", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "mark-read", id }),
|
||||||
|
});
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => (n.id === id ? { ...n, readAt: new Date().toISOString() } : n)),
|
||||||
|
);
|
||||||
|
setUnreadCount((c) => Math.max(0, c - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllRead = async () => {
|
||||||
|
await fetch("/api/notifications/app", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action: "mark-all-read" }),
|
||||||
|
});
|
||||||
|
setNotifications((prev) =>
|
||||||
|
prev.map((n) => ({ ...n, readAt: n.readAt ?? new Date().toISOString() })),
|
||||||
|
);
|
||||||
|
setUnreadCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="relative">
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute -top-0.5 -right-0.5 h-4 min-w-4 rounded-full bg-destructive text-[10px] font-medium text-white flex items-center justify-center px-1">
|
||||||
|
{unreadCount > 99 ? "99+" : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-80 p-0">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||||
|
<span className="text-sm font-medium">Notificari</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={handleMarkAllRead}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<CheckCheck className="h-3 w-3" />
|
||||||
|
Marcheaza toate ca citite
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<ScrollArea className="max-h-80">
|
||||||
|
{loading && notifications.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Se incarca...
|
||||||
|
</div>
|
||||||
|
) : notifications.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
Nicio notificare
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.map((n) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
onClick={() => !n.readAt && handleMarkRead(n.id)}
|
||||||
|
className={`w-full flex items-start gap-2.5 px-3 py-2.5 text-left border-b border-border/30 last:border-0 hover:bg-muted/50 transition-colors ${
|
||||||
|
!n.readAt ? "bg-primary/5" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 shrink-0">
|
||||||
|
{n.type === "sync-error" ? (
|
||||||
|
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||||
|
) : (
|
||||||
|
<Check className="h-4 w-4 text-green-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className={`text-sm truncate ${!n.readAt ? "font-medium" : ""}`}>
|
||||||
|
{n.title}
|
||||||
|
</p>
|
||||||
|
{!n.readAt && (
|
||||||
|
<span className="shrink-0 h-2 w-2 rounded-full bg-primary" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground truncate">{n.message}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground/60 mt-0.5">
|
||||||
|
{relativeTime(n.createdAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user