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:
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
Reference in New Issue
Block a user