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
+6 -4
View File
@@ -69,10 +69,12 @@ model GisSyncRun {
} }
model GisUat { model GisUat {
siruta String @id siruta String @id
name String name String
county String? county String?
updatedAt DateTime @updatedAt workspacePk Int?
updatedAt DateTime @updatedAt
@@index([name]) @@index([name])
@@index([county])
} }
+118 -31
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client"; import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store"; import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
@@ -6,7 +7,7 @@ export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Server-side cache */ /* Types */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
type EnrichedUat = { type EnrichedUat = {
@@ -16,29 +17,71 @@ type EnrichedUat = {
workspacePk: number; workspacePk: number;
}; };
const globalRef = globalThis as { /* ------------------------------------------------------------------ */
__eterraEnrichedUats?: { data: EnrichedUat[]; ts: number }; /* Helpers */
}; /* ------------------------------------------------------------------ */
/** Cache TTL — 1 hour (county/UAT data rarely changes) */ function populateWorkspaceCache(uats: EnrichedUat[]) {
const CACHE_TTL_MS = 60 * 60 * 1000; 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 */ /* GET /api/eterra/uats */
/* */ /* */
/* Returns all UATs enriched with county name + workspacePk. */ /* Always serves from local PostgreSQL (GisUat table). */
/* Cached server-side for 1 hour. */ /* No eTerra credentials needed — instant response. */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
export async function GET() { export async function GET() {
try { try {
// Return from cache if fresh const rows = await prisma.gisUat.findMany({
const cached = globalRef.__eterraEnrichedUats; orderBy: { name: "asc" },
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) { });
return NextResponse.json({ uats: cached.data, cached: true });
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 session = getSessionCredentials();
const username = String( const username = String(
session?.username || process.env.ETERRA_USERNAME || "", session?.username || process.env.ETERRA_USERNAME || "",
@@ -49,15 +92,40 @@ export async function GET() {
if (!username || !password) { if (!username || !password) {
return NextResponse.json( return NextResponse.json(
{ error: "Conectează-te la eTerra mai întâi.", uats: [] }, { error: "Conectează-te la eTerra mai întâi.", synced: false },
{ status: 401 }, { status: 401 },
); );
} }
const client = await EterraClient.create(username, password); const client = await EterraClient.create(username, password);
// Fetch all counties // Quick check: fetch county list from eTerra (lightweight)
const counties = await client.fetchCounties(); 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[] = []; const enriched: EnrichedUat[] = [];
for (const county of counties) { for (const county of counties) {
@@ -80,32 +148,51 @@ export async function GET() {
}); });
} }
} catch { } catch {
// Skip county if UAT fetch fails
continue; continue;
} }
} }
// Update cache if (enriched.length === 0) {
globalRef.__eterraEnrichedUats = { data: enriched, ts: Date.now() }; return NextResponse.json({
synced: false,
reason: "no-data-from-eterra",
total: dbTotalUats,
});
}
// Also populate the workspace cache used by search route // Batch upsert into DB
const wsGlobal = globalThis as { await prisma.$transaction(
__eterraWorkspaceCache?: Map<string, number>; enriched.map((u) =>
}; prisma.gisUat.upsert({
if (!wsGlobal.__eterraWorkspaceCache) { where: { siruta: u.siruta },
wsGlobal.__eterraWorkspaceCache = new Map(); update: {
} name: u.name,
for (const u of enriched) { county: u.county,
wsGlobal.__eterraWorkspaceCache.set(u.siruta, u.workspacePk); 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({ return NextResponse.json({
uats: enriched, synced: true,
cached: false,
total: enriched.length, total: enriched.length,
counties: eterraCountyCount,
}); });
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Eroare server"; 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(() => { useEffect(() => {
// Load static UAT data as fallback // Load UATs from local DB (fast — no eTerra needed)
fetch("/uat.json") fetch("/api/eterra/uats")
.then((res) => res.json()) .then((res) => res.json())
.then((data: UatEntry[]) => setUatData(data)) .then(
.catch(() => {}); (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((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 // Check existing server session on mount
void fetchSession(); void fetchSession();
@@ -342,33 +362,34 @@ export function ParcelSyncModule() {
}, [fetchSession]); }, [fetchSession]);
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
/* Fetch enriched UAT list (with county + workspace) when */ /* Sync UATs from eTerra → DB on connect (lightweight check) */
/* connected to eTerra. Falls back to static uat.json. */
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */
useEffect(() => { useEffect(() => {
if (!session.connected || enrichedUatsFetched.current) return; if (!session.connected || enrichedUatsFetched.current) return;
enrichedUatsFetched.current = true; enrichedUatsFetched.current = true;
fetch("/api/eterra/uats") // POST triggers sync check — only does full fetch if data changed
fetch("/api/eterra/uats", { method: "POST" })
.then((res) => res.json()) .then((res) => res.json())
.then( .then(
(data: { (data: { synced?: boolean }) => {
uats?: { if (data.synced) {
siruta: string; // Data changed — reload from DB
name: string; fetch("/api/eterra/uats")
county: string; .then((res) => res.json())
workspacePk: number; .then(
}[]; (fresh: { uats?: UatEntry[] }) => {
}) => { if (fresh.uats && fresh.uats.length > 0) {
if (data.uats && data.uats.length > 0) { setUatData(fresh.uats);
setUatData(data.uats); }
},
)
.catch(() => {});
} }
}, },
) )
.catch(() => { .catch(() => {});
// Keep static uat.json data
});
}, [session.connected]); }, [session.connected]);
/* ════════════════════════════════════════════════════════════ */ /* ════════════════════════════════════════════════════════════ */