f7468b23c2
The ~30s groupBy over 9.7M GisFeature rows ran synchronously on the first /api/eterra/uats call after every redeploy (in-memory cache), freezing the UAT autocomplete right when users reload post-deploy. Counts only feed the decorative 'N local' badge — return the (possibly empty) cache immediately and refresh in the background, single-flight so concurrent cold requests don't stack 30s queries. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { prisma } from "@/core/storage/prisma";
|
|
import { readFile } from "fs/promises";
|
|
import { join } from "path";
|
|
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
|
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
|
import { gateLegacyGisWrite } from "@/core/feature-flags/gis-write-gate";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Feature count cache (expensive query, cached 5 min) */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
const gCache = globalThis as {
|
|
__featureCountCache?: { map: Map<string, number>; ts: number };
|
|
__featureCountInFlight?: Promise<void> | null;
|
|
};
|
|
|
|
// NEVER block the response on the counts query — the groupBy takes ~30s
|
|
// on 9.7M GisFeature rows and the in-memory cache dies on every redeploy,
|
|
// which used to freeze the UAT selector for the first ~30s after a deploy
|
|
// (2026-06-04 incident). Counts only feed the decorative "N local" badge;
|
|
// a cold start simply renders the badge as absent until the first refresh
|
|
// lands. Single-flight so concurrent cold requests don't pile up 30s
|
|
// queries on the DB.
|
|
function getCachedFeatureCounts(): Map<string, number> {
|
|
const TTL = 5 * 60 * 1000; // 5 minutes
|
|
const cached = gCache.__featureCountCache;
|
|
|
|
if (!cached || Date.now() - cached.ts >= TTL) {
|
|
void refreshFeatureCounts();
|
|
}
|
|
return cached?.map ?? new Map();
|
|
}
|
|
|
|
function refreshFeatureCounts(): Promise<void> {
|
|
if (gCache.__featureCountInFlight) return gCache.__featureCountInFlight;
|
|
gCache.__featureCountInFlight = (async () => {
|
|
try {
|
|
const groups = await prisma.gisFeature.groupBy({
|
|
by: ["siruta"],
|
|
_count: { id: true },
|
|
});
|
|
const map = new Map<string, number>();
|
|
for (const g of groups) {
|
|
map.set(g.siruta, g._count.id);
|
|
}
|
|
gCache.__featureCountCache = { map, ts: Date.now() };
|
|
} catch {
|
|
// keep whatever cache we had; next request retries
|
|
} finally {
|
|
gCache.__featureCountInFlight = null;
|
|
}
|
|
})();
|
|
return gCache.__featureCountInFlight;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type UatResponse = {
|
|
siruta: string;
|
|
name: string;
|
|
county: string;
|
|
workspacePk: number;
|
|
/** Number of GIS features synced locally for this UAT */
|
|
localFeatures: number;
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
function populateWorkspaceCache(
|
|
uats: Array<{ siruta: string; workspacePk: number }>,
|
|
) {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Remove diacritics and uppercase for fuzzy name matching */
|
|
function normalizeName(s: string): string {
|
|
return s
|
|
.normalize("NFD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.toUpperCase()
|
|
.trim();
|
|
}
|
|
|
|
/** Title-case: "SATU MARE" → "Satu Mare" */
|
|
function titleCase(s: string): string {
|
|
return s
|
|
.toLowerCase()
|
|
.replace(/(?:^|\s)\S/g, (ch) => ch.toUpperCase());
|
|
}
|
|
|
|
/**
|
|
* Extract a name from an eTerra nomenclature entry.
|
|
* Tries multiple possible field names.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function extractName(entry: any): string {
|
|
if (!entry || typeof entry !== "object") return "";
|
|
for (const key of ["name", "nomenName", "label", "denumire", "NAME"]) {
|
|
const val = entry[key];
|
|
if (typeof val === "string" && val.trim()) return val.trim();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Extract a SIRUTA code from an eTerra nomenclature entry.
|
|
* Tries multiple possible field names (nomenPk ≠ SIRUTA, but code might be).
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function extractCode(entry: any): string {
|
|
if (!entry || typeof entry !== "object") return "";
|
|
for (const key of [
|
|
"code",
|
|
"sirutaCode",
|
|
"siruta",
|
|
"externalCode",
|
|
"cod",
|
|
"CODE",
|
|
]) {
|
|
const val = entry[key];
|
|
if (val != null) {
|
|
const s = String(val).trim();
|
|
if (s && /^\d+$/.test(s)) return s;
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Unwrap a potentially nested response (Spring Boot Page format).
|
|
* eTerra sometimes returns {content: [...]} instead of flat arrays.
|
|
*/
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function unwrapArray(data: any): any[] {
|
|
if (Array.isArray(data)) return data;
|
|
if (data && typeof data === "object") {
|
|
if (Array.isArray(data.content)) return data.content;
|
|
if (Array.isArray(data.data)) return data.data;
|
|
if (Array.isArray(data.items)) return data.items;
|
|
if (Array.isArray(data.results)) return data.results;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* GET /api/eterra/uats */
|
|
/* */
|
|
/* Always serves from local PostgreSQL (GisUat table). */
|
|
/* Includes local GIS feature counts per UAT for the UI indicator. */
|
|
/* No eTerra credentials needed — instant response. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export async function GET() {
|
|
try {
|
|
// CRITICAL: select only needed fields — geometry column has huge polygon data
|
|
const rows = await prisma.gisUat.findMany({
|
|
orderBy: { name: "asc" },
|
|
select: { siruta: true, name: true, county: true, workspacePk: true },
|
|
});
|
|
|
|
// Feature counts: in-memory cache, refreshed in the background (never
|
|
// awaited — see getCachedFeatureCounts). Cold cache → badge absent.
|
|
const featureCounts = getCachedFeatureCounts();
|
|
|
|
const uats: UatResponse[] = rows.map((r) => ({
|
|
siruta: r.siruta,
|
|
name: r.name,
|
|
county: r.county ?? "",
|
|
workspacePk: r.workspacePk ?? 0,
|
|
localFeatures: featureCounts.get(r.siruta) ?? 0,
|
|
}));
|
|
|
|
// Populate in-memory workspace cache for search route
|
|
if (uats.length > 0) {
|
|
populateWorkspaceCache(uats);
|
|
}
|
|
|
|
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 */
|
|
/* */
|
|
/* Seed or resync DB from static uat.json. */
|
|
/* Uses upsert so it's safe to call repeatedly — new UATs are added, */
|
|
/* existing names are updated, county/workspacePk are preserved. */
|
|
/* eTerra nomenPk ≠ SIRUTA, so we cannot use the nomenclature API */
|
|
/* for populating UAT data. uat.json has correct SIRUTA codes. */
|
|
/* Workspace (county) PKs are resolved lazily via ArcGIS layer query */
|
|
/* in the search route and persisted to DB on first resolution. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export async function POST() {
|
|
const gate = await gateLegacyGisWrite("/api/eterra/uats");
|
|
if (gate) return gate;
|
|
try {
|
|
|
|
// Read uat.json from public/ directory
|
|
let rawUats: Array<{ siruta: string; name: string }>;
|
|
try {
|
|
const filePath = join(process.cwd(), "public", "uat.json");
|
|
const content = await readFile(filePath, "utf-8");
|
|
rawUats = JSON.parse(content);
|
|
} catch {
|
|
return NextResponse.json(
|
|
{ error: "Nu s-a putut citi uat.json", synced: false },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
|
|
if (!Array.isArray(rawUats) || rawUats.length === 0) {
|
|
return NextResponse.json({
|
|
synced: false,
|
|
reason: "empty-uat-json",
|
|
total: 0,
|
|
});
|
|
}
|
|
|
|
// Batch insert in chunks of 500 (Prisma transaction limit)
|
|
const CHUNK_SIZE = 500;
|
|
let inserted = 0;
|
|
for (let i = 0; i < rawUats.length; i += CHUNK_SIZE) {
|
|
const chunk = rawUats.slice(i, i + CHUNK_SIZE);
|
|
await prisma.$transaction(
|
|
chunk
|
|
.filter((u) => u.siruta && u.name)
|
|
.map((u) =>
|
|
prisma.gisUat.upsert({
|
|
where: { siruta: String(u.siruta) },
|
|
update: { name: String(u.name).trim() },
|
|
create: {
|
|
siruta: String(u.siruta),
|
|
name: String(u.name).trim(),
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
inserted += chunk.length;
|
|
}
|
|
|
|
console.log(`[uats] Seeded ${inserted} UATs from uat.json`);
|
|
|
|
return NextResponse.json({
|
|
synced: true,
|
|
total: inserted,
|
|
source: "uat-json",
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Eroare server";
|
|
return NextResponse.json(
|
|
{ error: message, synced: false },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* PATCH /api/eterra/uats */
|
|
/* */
|
|
/* Populate county names from eTerra nomenclature API. */
|
|
/* */
|
|
/* Strategy (two phases): */
|
|
/* Phase 1: For UATs that already have workspacePk resolved, */
|
|
/* use fetchCounties() → countyMap[workspacePk] → instant update. */
|
|
/* Phase 2: For remaining UATs, enumerate counties → */
|
|
/* fetchAdminUnitsByCounty() per county → match by code or name. */
|
|
/* */
|
|
/* Requires active eTerra session. */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
export async function PATCH() {
|
|
const gate = await gateLegacyGisWrite("/api/eterra/uats");
|
|
if (gate) return gate;
|
|
try {
|
|
// 1. Get eTerra credentials from session
|
|
const session = getSessionCredentials();
|
|
const username = String(
|
|
session?.username || process.env.ETERRA_USERNAME || "",
|
|
).trim();
|
|
const password = String(
|
|
session?.password || process.env.ETERRA_PASSWORD || "",
|
|
).trim();
|
|
|
|
if (!username || !password) {
|
|
return NextResponse.json(
|
|
{ error: "Conectează-te la eTerra mai întâi." },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
|
|
const client = await EterraClient.create(username, password);
|
|
|
|
// 2. Fetch all counties from eTerra nomenclature
|
|
const rawCounties = await client.fetchCounties();
|
|
const counties = unwrapArray(rawCounties);
|
|
const countyMap = new Map<number, string>(); // nomenPk → county name
|
|
for (const c of counties) {
|
|
const pk = Number(c?.nomenPk ?? 0);
|
|
const name = extractName(c);
|
|
if (pk > 0 && name) {
|
|
countyMap.set(pk, titleCase(name));
|
|
}
|
|
}
|
|
|
|
if (countyMap.size === 0) {
|
|
// Log raw response for debugging
|
|
console.error(
|
|
"[uats-patch] fetchCounties returned 0 counties. Raw sample:",
|
|
JSON.stringify(rawCounties).slice(0, 500),
|
|
);
|
|
return NextResponse.json(
|
|
{
|
|
error: "Nu s-au putut obține județele din eTerra.",
|
|
debug: {
|
|
rawType: typeof rawCounties,
|
|
isArray: Array.isArray(rawCounties),
|
|
length: Array.isArray(rawCounties) ? rawCounties.length : null,
|
|
sample: JSON.stringify(rawCounties).slice(0, 300),
|
|
},
|
|
},
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
|
|
console.log(`[uats-patch] Fetched ${countyMap.size} counties from eTerra`);
|
|
|
|
// 3. Load all UATs from DB
|
|
const allUats = await prisma.gisUat.findMany({
|
|
select: { siruta: true, name: true, county: true, workspacePk: true },
|
|
});
|
|
|
|
// Phase 1: instant fill for UATs that already have workspacePk
|
|
const phase1Ops: Array<ReturnType<typeof prisma.gisUat.update>> = [];
|
|
const needsCounty: Array<{ siruta: string; name: string }> = [];
|
|
|
|
for (const uat of allUats) {
|
|
if (uat.county) continue; // already has county
|
|
|
|
if (uat.workspacePk && uat.workspacePk > 0) {
|
|
const county = countyMap.get(uat.workspacePk);
|
|
if (county) {
|
|
phase1Ops.push(
|
|
prisma.gisUat.update({
|
|
where: { siruta: uat.siruta },
|
|
data: { county },
|
|
}),
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
needsCounty.push({ siruta: uat.siruta, name: uat.name });
|
|
}
|
|
|
|
// Execute phase 1 in batches
|
|
let phase1Updated = 0;
|
|
for (let i = 0; i < phase1Ops.length; i += 100) {
|
|
const batch = phase1Ops.slice(i, i + 100);
|
|
await prisma.$transaction(batch);
|
|
phase1Updated += batch.length;
|
|
}
|
|
|
|
console.log(
|
|
`[uats-patch] Phase 1: ${phase1Updated} updated via workspacePk. ` +
|
|
`${needsCounty.length} remaining.`,
|
|
);
|
|
|
|
// Phase 2: enumerate UATs per county from nomenclature, match by code or name
|
|
// Build lookups
|
|
const nameToSirutas = new Map<string, string[]>();
|
|
const sirutaSet = new Set<string>();
|
|
for (const u of needsCounty) {
|
|
sirutaSet.add(u.siruta);
|
|
const key = normalizeName(u.name);
|
|
const arr = nameToSirutas.get(key);
|
|
if (arr) arr.push(u.siruta);
|
|
else nameToSirutas.set(key, [u.siruta]);
|
|
}
|
|
|
|
let phase2Updated = 0;
|
|
let codeMatches = 0;
|
|
let nameMatches = 0;
|
|
const matchedSirutas = new Set<string>();
|
|
let loggedSample = false;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let sampleCounty: any = null;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let sampleUat: any = null;
|
|
let totalEterraUats = 0;
|
|
|
|
for (const [countyPk, countyName] of countyMap) {
|
|
if (matchedSirutas.size >= needsCounty.length) break;
|
|
|
|
try {
|
|
const rawUats = await client.fetchAdminUnitsByCounty(countyPk);
|
|
const uats = unwrapArray(rawUats);
|
|
|
|
totalEterraUats += uats.length;
|
|
|
|
// Log first county's first UAT for debugging
|
|
if (!loggedSample && uats.length > 0) {
|
|
sampleUat = uats[0];
|
|
sampleCounty = { pk: countyPk, name: countyName, uatCount: uats.length };
|
|
console.log(
|
|
`[uats-patch] Sample UAT from ${countyName} (${uats.length} UATs):`,
|
|
JSON.stringify(uats[0]).slice(0, 500),
|
|
);
|
|
console.log(
|
|
`[uats-patch] Sample UAT keys:`,
|
|
Object.keys(uats[0] ?? {}),
|
|
);
|
|
loggedSample = true;
|
|
}
|
|
|
|
for (const uat of uats) {
|
|
// Strategy A: match by code (might be SIRUTA)
|
|
const code = extractCode(uat);
|
|
if (code && sirutaSet.has(code) && !matchedSirutas.has(code)) {
|
|
matchedSirutas.add(code);
|
|
await prisma.gisUat.update({
|
|
where: { siruta: code },
|
|
data: { county: countyName, workspacePk: countyPk },
|
|
});
|
|
phase2Updated++;
|
|
codeMatches++;
|
|
continue;
|
|
}
|
|
|
|
// Strategy B: match by normalized name
|
|
const eterraName = extractName(uat);
|
|
if (!eterraName) continue;
|
|
|
|
const key = normalizeName(eterraName);
|
|
const sirutas = nameToSirutas.get(key);
|
|
if (!sirutas || sirutas.length === 0) continue;
|
|
|
|
const siruta = sirutas.find((s) => !matchedSirutas.has(s));
|
|
if (!siruta) continue;
|
|
|
|
matchedSirutas.add(siruta);
|
|
await prisma.gisUat.update({
|
|
where: { siruta },
|
|
data: { county: countyName, workspacePk: countyPk },
|
|
});
|
|
phase2Updated++;
|
|
nameMatches++;
|
|
|
|
if (sirutas.every((s) => matchedSirutas.has(s))) {
|
|
nameToSirutas.delete(key);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.warn(
|
|
`[uats-patch] Failed to fetch UATs for county ${countyName} (pk=${countyPk}):`,
|
|
err instanceof Error ? err.message : err,
|
|
);
|
|
}
|
|
}
|
|
|
|
const totalUpdated = phase1Updated + phase2Updated;
|
|
const unmatched = needsCounty.length - phase2Updated;
|
|
console.log(
|
|
`[uats-patch] Phase 2: ${phase2Updated} (${codeMatches} by code, ${nameMatches} by name). ` +
|
|
`Total: ${totalUpdated}. Unmatched: ${unmatched}.`,
|
|
);
|
|
|
|
return NextResponse.json({
|
|
updated: totalUpdated,
|
|
phase1: phase1Updated,
|
|
phase2: phase2Updated,
|
|
codeMatches,
|
|
nameMatches,
|
|
totalCounties: countyMap.size,
|
|
totalEterraUats,
|
|
unmatched,
|
|
// Include debug samples so we can see what eTerra returns
|
|
debug: {
|
|
sampleCounty,
|
|
sampleUatKeys: sampleUat ? Object.keys(sampleUat) : null,
|
|
sampleUat: sampleUat
|
|
? JSON.parse(JSON.stringify(sampleUat).slice(0, 500))
|
|
: null,
|
|
sampleCountyRaw: counties[0]
|
|
? {
|
|
keys: Object.keys(counties[0]),
|
|
nomenPk: counties[0].nomenPk,
|
|
name: counties[0].name,
|
|
}
|
|
: null,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "Eroare server";
|
|
console.error("[uats-patch] Error:", message);
|
|
return NextResponse.json({ error: message }, { status: 500 });
|
|
}
|
|
}
|