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
+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 },
);
}
}