feat(parcel-sync): store UATs in PostgreSQL, eliminate repeated eTerra calls

- GisUat table now includes workspacePk column (created via raw SQL)
- GET /api/eterra/uats serves from PostgreSQL  instant, no eTerra login needed
- POST /api/eterra/uats triggers sync check: compares county count with DB,
  only does full eTerra fetch if data differs or DB is empty
- Frontend loads UATs from DB on mount (fast), falls back to uat.json if empty
- On eTerra connect, fires POST to sync-check; if data changed, reloads from DB
- Workspace cache populated from DB on GET for search route performance
This commit is contained in:
AI Assistant
2026-03-06 20:56:12 +02:00
parent d948e5c1cf
commit ec5a866673
3 changed files with 165 additions and 55 deletions
+2
View File
@@ -72,7 +72,9 @@ model GisUat {
siruta String @id
name String
county String?
workspacePk Int?
updatedAt DateTime @updatedAt
@@index([name])
@@index([county])
}
+118 -31
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
@@ -6,7 +7,7 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Server-side cache */
/* Types */
/* ------------------------------------------------------------------ */
type EnrichedUat = {
@@ -16,29 +17,71 @@ type EnrichedUat = {
workspacePk: number;
};
const globalRef = globalThis as {
__eterraEnrichedUats?: { data: EnrichedUat[]; ts: number };
};
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/** Cache TTL — 1 hour (county/UAT data rarely changes) */
const CACHE_TTL_MS = 60 * 60 * 1000;
function populateWorkspaceCache(uats: EnrichedUat[]) {
const wsGlobal = globalThis as {
__eterraWorkspaceCache?: Map<string, number>;
};
if (!wsGlobal.__eterraWorkspaceCache) {
wsGlobal.__eterraWorkspaceCache = new Map();
}
for (const u of uats) {
if (u.workspacePk) {
wsGlobal.__eterraWorkspaceCache.set(u.siruta, u.workspacePk);
}
}
}
/* ------------------------------------------------------------------ */
/* GET /api/eterra/uats */
/* */
/* Returns all UATs enriched with county name + workspacePk. */
/* Cached server-side for 1 hour. */
/* Always serves from local PostgreSQL (GisUat table). */
/* No eTerra credentials needed — instant response. */
/* ------------------------------------------------------------------ */
export async function GET() {
try {
// Return from cache if fresh
const cached = globalRef.__eterraEnrichedUats;
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
return NextResponse.json({ uats: cached.data, cached: true });
const rows = await prisma.gisUat.findMany({
orderBy: { name: "asc" },
});
const uats: EnrichedUat[] = rows.map((r) => ({
siruta: r.siruta,
name: r.name,
county: r.county ?? "",
workspacePk: r.workspacePk ?? 0,
}));
// Populate in-memory workspace cache for search route
if (uats.length > 0) {
populateWorkspaceCache(uats);
}
// Need eTerra credentials
return NextResponse.json({
uats,
total: uats.length,
source: "database",
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message, uats: [] }, { status: 500 });
}
}
/* ------------------------------------------------------------------ */
/* POST /api/eterra/uats */
/* */
/* Sync check: compare eTerra county count vs DB. */
/* If DB is empty or county count differs → full fetch + upsert. */
/* Otherwise returns { synced: false, reason: "up-to-date" }. */
/* ------------------------------------------------------------------ */
export async function POST() {
try {
// Need eTerra credentials for sync
const session = getSessionCredentials();
const username = String(
session?.username || process.env.ETERRA_USERNAME || "",
@@ -49,15 +92,40 @@ export async function GET() {
if (!username || !password) {
return NextResponse.json(
{ error: "Conectează-te la eTerra mai întâi.", uats: [] },
{ error: "Conectează-te la eTerra mai întâi.", synced: false },
{ status: 401 },
);
}
const client = await EterraClient.create(username, password);
// Fetch all counties
// Quick check: fetch county list from eTerra (lightweight)
const counties = await client.fetchCounties();
const eterraCountyCount = counties.length;
// Compare with DB
const dbCounties = await prisma.gisUat.groupBy({
by: ["county"],
_count: true,
});
const dbCountyCount = dbCounties.length;
const dbTotalUats = await prisma.gisUat.count();
// If county counts match and we have data → already synced
if (
dbCountyCount === eterraCountyCount &&
dbCountyCount > 0 &&
dbTotalUats > 0
) {
return NextResponse.json({
synced: false,
reason: "up-to-date",
total: dbTotalUats,
counties: dbCountyCount,
});
}
// Full sync: fetch all UATs for all counties
const enriched: EnrichedUat[] = [];
for (const county of counties) {
@@ -80,32 +148,51 @@ export async function GET() {
});
}
} catch {
// Skip county if UAT fetch fails
continue;
}
}
// Update cache
globalRef.__eterraEnrichedUats = { data: enriched, ts: Date.now() };
if (enriched.length === 0) {
return NextResponse.json({
synced: false,
reason: "no-data-from-eterra",
total: dbTotalUats,
});
}
// Also populate the workspace cache used by search route
const wsGlobal = globalThis as {
__eterraWorkspaceCache?: Map<string, number>;
};
if (!wsGlobal.__eterraWorkspaceCache) {
wsGlobal.__eterraWorkspaceCache = new Map();
}
for (const u of enriched) {
wsGlobal.__eterraWorkspaceCache.set(u.siruta, u.workspacePk);
}
// Batch upsert into DB
await prisma.$transaction(
enriched.map((u) =>
prisma.gisUat.upsert({
where: { siruta: u.siruta },
update: {
name: u.name,
county: u.county,
workspacePk: u.workspacePk,
},
create: {
siruta: u.siruta,
name: u.name,
county: u.county,
workspacePk: u.workspacePk,
},
}),
),
);
// Populate in-memory cache
populateWorkspaceCache(enriched);
return NextResponse.json({
uats: enriched,
cached: false,
synced: true,
total: enriched.length,
counties: eterraCountyCount,
});
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message, uats: [] }, { status: 500 });
return NextResponse.json(
{ error: message, synced: false },
{ status: 500 },
);
}
}
@@ -325,11 +325,31 @@ export function ParcelSyncModule() {
}, []);
useEffect(() => {
// Load static UAT data as fallback
// Load UATs from local DB (fast — no eTerra needed)
fetch("/api/eterra/uats")
.then((res) => res.json())
.then(
(data: {
uats?: UatEntry[];
}) => {
if (data.uats && data.uats.length > 0) {
setUatData(data.uats);
} else {
// DB empty — fall back to static uat.json (no county/workspace)
fetch("/uat.json")
.then((res) => res.json())
.then((data: UatEntry[]) => setUatData(data))
.then((fallback: UatEntry[]) => setUatData(fallback))
.catch(() => {});
}
},
)
.catch(() => {
// API failed — fall back to static uat.json
fetch("/uat.json")
.then((res) => res.json())
.then((fallback: UatEntry[]) => setUatData(fallback))
.catch(() => {});
});
// Check existing server session on mount
void fetchSession();
@@ -342,33 +362,34 @@ export function ParcelSyncModule() {
}, [fetchSession]);
/* ════════════════════════════════════════════════════════════ */
/* Fetch enriched UAT list (with county + workspace) when */
/* connected to eTerra. Falls back to static uat.json. */
/* Sync UATs from eTerra → DB on connect (lightweight check) */
/* ════════════════════════════════════════════════════════════ */
useEffect(() => {
if (!session.connected || enrichedUatsFetched.current) return;
enrichedUatsFetched.current = true;
// POST triggers sync check — only does full fetch if data changed
fetch("/api/eterra/uats", { method: "POST" })
.then((res) => res.json())
.then(
(data: { synced?: boolean }) => {
if (data.synced) {
// Data changed — reload from DB
fetch("/api/eterra/uats")
.then((res) => res.json())
.then(
(data: {
uats?: {
siruta: string;
name: string;
county: string;
workspacePk: number;
}[];
}) => {
if (data.uats && data.uats.length > 0) {
setUatData(data.uats);
(fresh: { uats?: UatEntry[] }) => {
if (fresh.uats && fresh.uats.length > 0) {
setUatData(fresh.uats);
}
},
)
.catch(() => {
// Keep static uat.json data
});
.catch(() => {});
}
},
)
.catch(() => {});
}, [session.connected]);
/* ════════════════════════════════════════════════════════════ */