fix: workspace resolution via ArcGIS listLayer + seed UATs from uat.json
- resolveWorkspace: use listLayer() instead of listLayerByWhere() with hardcoded field names. Auto-discovers admin field (ADMIN_UNIT_ID/SIRUTA) from ArcGIS layer metadata via buildWhere(). - resolveWorkspace: persist WORKSPACE_ID to DB on first resolution for fast subsequent lookups. - UATs POST: seed from uat.json (correct SIRUTA codes) instead of eTerra nomenclature API (nomenPk != SIRUTA, county nomenPk != WORKSPACE_ID). - Remove eTerra nomenclature dependency from UATs endpoint. - Fix activeJobs Set iteration error on container restart. - Remove unused enrichedUatsFetched ref.
This commit is contained in:
@@ -26,21 +26,15 @@ const workspaceCache =
|
|||||||
globalRef.__eterraWorkspaceCache = workspaceCache;
|
globalRef.__eterraWorkspaceCache = workspaceCache;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve eTerra workspace nomenPk for a given SIRUTA.
|
* Resolve eTerra workspace ID for a given SIRUTA.
|
||||||
* Strategy: fetch all counties, then for each county fetch its UATs
|
|
||||||
* until we find one whose nomenPk matches the SIRUTA.
|
|
||||||
* Results are cached globally (survives hot-reload).
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* Resolve eTerra workspace nomenPk for a given SIRUTA.
|
|
||||||
*
|
*
|
||||||
* Strategy (fast path first):
|
* Strategy: Query 1 feature from TERENURI_ACTIVE ArcGIS layer for this
|
||||||
* 1. Check in-memory cache
|
* SIRUTA, read the WORKSPACE_ID attribute.
|
||||||
* 2. Direct nomen lookup: GET /api/adm/nomen/{siruta} → read parentNomenPk
|
|
||||||
* then GET parent to check if it's a COUNTY → that's the workspace
|
|
||||||
* 3. Full scan: fetch all counties → their UATs (slow fallback, ~42 calls)
|
|
||||||
*
|
*
|
||||||
* Results are cached globally.
|
* Uses `listLayer()` (not `listLayerByWhere`) so the admin field name
|
||||||
|
* (ADMIN_UNIT_ID, SIRUTA, UAT_ID…) is auto-discovered from layer metadata.
|
||||||
|
*
|
||||||
|
* SIRUTA ≠ eTerra nomenPk, so nomenclature API lookups don't help.
|
||||||
*/
|
*/
|
||||||
async function resolveWorkspace(
|
async function resolveWorkspace(
|
||||||
client: EterraClient,
|
client: EterraClient,
|
||||||
@@ -49,71 +43,48 @@ async function resolveWorkspace(
|
|||||||
const cached = workspaceCache.get(siruta);
|
const cached = workspaceCache.get(siruta);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
|
|
||||||
// Fast path: direct nomen lookup
|
|
||||||
try {
|
try {
|
||||||
const nomen = await client.fetchNomenByPk(siruta);
|
// listLayer auto-discovers the correct admin field via buildWhere
|
||||||
console.log("[resolveWorkspace] direct nomen lookup for", siruta, "→", JSON.stringify(nomen).slice(0, 500));
|
const features = await client.listLayer(
|
||||||
if (nomen) {
|
{
|
||||||
// Walk parent chain to find COUNTY
|
id: "TERENURI_ACTIVE",
|
||||||
const parentPk = nomen?.parentNomenPk ?? nomen?.parentPk ?? nomen?.parent?.nomenPk;
|
name: "TERENURI_ACTIVE",
|
||||||
if (parentPk) {
|
endpoint: "aut",
|
||||||
const parent = await client.fetchNomenByPk(parentPk);
|
whereTemplate: "{{adminField}}={{siruta}} AND IS_ACTIVE=1",
|
||||||
console.log("[resolveWorkspace] parent nomen:", JSON.stringify(parent).slice(0, 500));
|
},
|
||||||
const parentType = String(parent?.nomenType ?? parent?.type ?? "").toUpperCase();
|
siruta,
|
||||||
if (parentType === "COUNTY" || parentType === "JUDET") {
|
{ limit: 1, outFields: "WORKSPACE_ID" },
|
||||||
const countyPk = Number(parent?.nomenPk ?? parentPk);
|
);
|
||||||
workspaceCache.set(siruta, countyPk);
|
|
||||||
return countyPk;
|
const wsId = features?.[0]?.attributes?.WORKSPACE_ID;
|
||||||
}
|
console.log("[resolveWorkspace] ArcGIS WORKSPACE_ID for", siruta, "→", wsId);
|
||||||
// Maybe grandparent is the county (UAT → commune → county?)
|
if (wsId != null) {
|
||||||
const grandparentPk = parent?.parentNomenPk ?? parent?.parentPk;
|
const numWs = Number(wsId);
|
||||||
if (grandparentPk) {
|
if (Number.isFinite(numWs)) {
|
||||||
const grandparent = await client.fetchNomenByPk(grandparentPk);
|
workspaceCache.set(siruta, numWs);
|
||||||
const gpType = String(grandparent?.nomenType ?? grandparent?.type ?? "").toUpperCase();
|
// Persist to DB for future fast lookups
|
||||||
if (gpType === "COUNTY" || gpType === "JUDET") {
|
persistWorkspace(siruta, numWs);
|
||||||
const countyPk = Number(grandparent?.nomenPk ?? grandparentPk);
|
return numWs;
|
||||||
workspaceCache.set(siruta, countyPk);
|
|
||||||
return countyPk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Direct lookup failed — continue to full scan
|
console.log("[resolveWorkspace] ArcGIS query failed:", e instanceof Error ? e.message : e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow fallback: iterate all counties and their UATs
|
|
||||||
try {
|
|
||||||
const counties = await client.fetchCounties();
|
|
||||||
for (const county of counties) {
|
|
||||||
const countyPk = county?.nomenPk ?? county?.pk ?? county?.id;
|
|
||||||
if (!countyPk) continue;
|
|
||||||
try {
|
|
||||||
const uats = await client.fetchAdminUnitsByCounty(countyPk);
|
|
||||||
for (const uat of uats) {
|
|
||||||
// Try all possible identifier fields
|
|
||||||
const candidates = [
|
|
||||||
uat?.nomenPk,
|
|
||||||
uat?.siruta,
|
|
||||||
uat?.code,
|
|
||||||
uat?.pk,
|
|
||||||
].filter(Boolean).map(String);
|
|
||||||
for (const c of candidates) {
|
|
||||||
workspaceCache.set(c, Number(countyPk));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const resolved = workspaceCache.get(siruta);
|
|
||||||
if (resolved !== undefined) return resolved;
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Can't fetch counties
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fire-and-forget: save WORKSPACE_ID to GisUat row */
|
||||||
|
function persistWorkspace(siruta: string, workspacePk: number) {
|
||||||
|
prisma.gisUat
|
||||||
|
.upsert({
|
||||||
|
where: { siruta },
|
||||||
|
update: { workspacePk },
|
||||||
|
create: { siruta, name: siruta, workspacePk },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Helper formatters (same logic as export-bundle magic mode) */
|
/* Helper formatters (same logic as export-bundle magic mode) */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -238,33 +209,21 @@ export async function POST(req: Request) {
|
|||||||
|
|
||||||
const client = await EterraClient.create(username, password);
|
const client = await EterraClient.create(username, password);
|
||||||
|
|
||||||
// Use provided workspacePk — or look up from DB — or resolve from eTerra
|
// Workspace resolution chain: body → DB → ArcGIS layer query
|
||||||
let workspaceId = body.workspacePk ?? null;
|
let workspaceId = body.workspacePk ?? null;
|
||||||
console.log(
|
|
||||||
"[search] siruta:",
|
|
||||||
siruta,
|
|
||||||
"body.workspacePk:",
|
|
||||||
body.workspacePk,
|
|
||||||
"workspaceId:",
|
|
||||||
workspaceId,
|
|
||||||
);
|
|
||||||
if (!workspaceId || !Number.isFinite(workspaceId)) {
|
if (!workspaceId || !Number.isFinite(workspaceId)) {
|
||||||
// Try DB lookup first (cheap)
|
|
||||||
try {
|
try {
|
||||||
const dbUat = await prisma.gisUat.findUnique({
|
const dbUat = await prisma.gisUat.findUnique({
|
||||||
where: { siruta },
|
where: { siruta },
|
||||||
select: { workspacePk: true },
|
select: { workspacePk: true },
|
||||||
});
|
});
|
||||||
console.log("[search] DB lookup result:", dbUat);
|
|
||||||
if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk;
|
if (dbUat?.workspacePk) workspaceId = dbUat.workspacePk;
|
||||||
} catch (e) {
|
} catch {
|
||||||
console.log("[search] DB lookup error:", e);
|
// DB lookup failed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!workspaceId || !Number.isFinite(workspaceId)) {
|
if (!workspaceId || !Number.isFinite(workspaceId)) {
|
||||||
console.log("[search] falling back to resolveWorkspace...");
|
|
||||||
workspaceId = await resolveWorkspace(client, siruta);
|
workspaceId = await resolveWorkspace(client, siruta);
|
||||||
console.log("[search] resolveWorkspace result:", workspaceId);
|
|
||||||
}
|
}
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -272,6 +231,7 @@ export async function POST(req: Request) {
|
|||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
console.log("[search] siruta:", siruta, "workspaceId:", workspaceId);
|
||||||
|
|
||||||
const results: ParcelDetail[] = [];
|
const results: ParcelDetail[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/core/storage/prisma";
|
import { prisma } from "@/core/storage/prisma";
|
||||||
import { EterraClient } from "@/modules/parcel-sync/services/eterra-client";
|
import { readFile } from "fs/promises";
|
||||||
import { getSessionCredentials } from "@/modules/parcel-sync/services/session-store";
|
import { join } from "path";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -74,119 +74,74 @@ export async function GET() {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* POST /api/eterra/uats */
|
/* POST /api/eterra/uats */
|
||||||
/* */
|
/* */
|
||||||
/* Sync check: compare eTerra county count vs DB. */
|
/* Seed DB from static uat.json. */
|
||||||
/* If DB is empty or county count differs → full fetch + upsert. */
|
/* eTerra nomenPk ≠ SIRUTA, so we cannot use the nomenclature API */
|
||||||
/* Otherwise returns { synced: false, reason: "up-to-date" }. */
|
/* 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() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
// Need eTerra credentials for sync
|
// Check if DB already has data
|
||||||
const session = getSessionCredentials();
|
const dbCount = await prisma.gisUat.count();
|
||||||
const username = String(
|
if (dbCount > 0) {
|
||||||
session?.username || process.env.ETERRA_USERNAME || "",
|
return NextResponse.json({
|
||||||
).trim();
|
synced: false,
|
||||||
const password = String(
|
reason: "already-seeded",
|
||||||
session?.password || process.env.ETERRA_PASSWORD || "",
|
total: dbCount,
|
||||||
).trim();
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!username || !password) {
|
// 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(
|
return NextResponse.json(
|
||||||
{ error: "Conectează-te la eTerra mai întâi.", synced: false },
|
{ error: "Nu s-a putut citi uat.json", synced: false },
|
||||||
{ status: 401 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = await EterraClient.create(username, password);
|
if (!Array.isArray(rawUats) || rawUats.length === 0) {
|
||||||
|
|
||||||
// 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({
|
return NextResponse.json({
|
||||||
synced: false,
|
synced: false,
|
||||||
reason: "up-to-date",
|
reason: "empty-uat-json",
|
||||||
total: dbTotalUats,
|
total: 0,
|
||||||
counties: dbCountyCount,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Full sync: fetch all UATs for all counties
|
// Batch insert in chunks of 500 (Prisma transaction limit)
|
||||||
const enriched: EnrichedUat[] = [];
|
const CHUNK_SIZE = 500;
|
||||||
|
let inserted = 0;
|
||||||
for (const county of counties) {
|
for (let i = 0; i < rawUats.length; i += CHUNK_SIZE) {
|
||||||
const countyPk = county?.nomenPk ?? county?.pk ?? county?.id;
|
const chunk = rawUats.slice(i, i + CHUNK_SIZE);
|
||||||
const countyName = String(county?.name ?? "").trim();
|
await prisma.$transaction(
|
||||||
if (!countyPk || !countyName) continue;
|
chunk
|
||||||
|
.filter((u) => u.siruta && u.name)
|
||||||
try {
|
.map((u) =>
|
||||||
const uats = await client.fetchAdminUnitsByCounty(countyPk);
|
prisma.gisUat.upsert({
|
||||||
for (const uat of uats) {
|
where: { siruta: String(u.siruta) },
|
||||||
const uatPk = String(uat?.nomenPk ?? uat?.pk ?? "");
|
update: { name: String(u.name).trim() },
|
||||||
const uatName = String(uat?.name ?? "").trim();
|
create: {
|
||||||
if (!uatPk || !uatName) continue;
|
siruta: String(u.siruta),
|
||||||
|
name: String(u.name).trim(),
|
||||||
enriched.push({
|
},
|
||||||
siruta: uatPk,
|
}),
|
||||||
name: uatName,
|
),
|
||||||
county: countyName,
|
);
|
||||||
workspacePk: Number(countyPk),
|
inserted += chunk.length;
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enriched.length === 0) {
|
console.log(`[uats] Seeded ${inserted} UATs from uat.json`);
|
||||||
return NextResponse.json({
|
|
||||||
synced: false,
|
|
||||||
reason: "no-data-from-eterra",
|
|
||||||
total: dbTotalUats,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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({
|
return NextResponse.json({
|
||||||
synced: true,
|
synced: true,
|
||||||
total: enriched.length,
|
total: inserted,
|
||||||
counties: eterraCountyCount,
|
source: "uat-json",
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : "Eroare server";
|
const message = error instanceof Error ? error.message : "Eroare server";
|
||||||
|
|||||||
@@ -284,7 +284,6 @@ export function ParcelSyncModule() {
|
|||||||
const [siruta, setSiruta] = useState("");
|
const [siruta, setSiruta] = useState("");
|
||||||
const [workspacePk, setWorkspacePk] = useState<number | null>(null);
|
const [workspacePk, setWorkspacePk] = useState<number | null>(null);
|
||||||
const uatRef = useRef<HTMLDivElement>(null);
|
const uatRef = useRef<HTMLDivElement>(null);
|
||||||
const enrichedUatsFetched = useRef(false);
|
|
||||||
|
|
||||||
/* ── Export state ────────────────────────────────────────────── */
|
/* ── Export state ────────────────────────────────────────────── */
|
||||||
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
const [exportJobId, setExportJobId] = useState<string | null>(null);
|
||||||
@@ -328,11 +327,12 @@ export function ParcelSyncModule() {
|
|||||||
// Load UATs from local DB (fast — no eTerra needed)
|
// Load UATs from local DB (fast — no eTerra needed)
|
||||||
fetch("/api/eterra/uats")
|
fetch("/api/eterra/uats")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data: { uats?: UatEntry[] }) => {
|
.then((data: { uats?: UatEntry[]; total?: number }) => {
|
||||||
if (data.uats && data.uats.length > 0) {
|
if (data.uats && data.uats.length > 0) {
|
||||||
setUatData(data.uats);
|
setUatData(data.uats);
|
||||||
} else {
|
} else {
|
||||||
// DB empty — fall back to static uat.json (no county/workspace)
|
// DB empty — seed from uat.json via POST, then load from uat.json
|
||||||
|
fetch("/api/eterra/uats", { method: "POST" }).catch(() => {});
|
||||||
fetch("/uat.json")
|
fetch("/uat.json")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((fallback: UatEntry[]) => setUatData(fallback))
|
.then((fallback: UatEntry[]) => setUatData(fallback))
|
||||||
@@ -358,32 +358,10 @@ export function ParcelSyncModule() {
|
|||||||
}, [fetchSession]);
|
}, [fetchSession]);
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
/* Sync UATs from eTerra → DB on connect (lightweight check) */
|
/* (Sync effect removed — POST seeds from uat.json, no */
|
||||||
|
/* eTerra nomenclature needed. Workspace resolved lazily.) */
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
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((fresh: { uats?: UatEntry[] }) => {
|
|
||||||
if (fresh.uats && fresh.uats.length > 0) {
|
|
||||||
setUatData(fresh.uats);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [session.connected]);
|
|
||||||
|
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
/* UAT autocomplete filter */
|
/* UAT autocomplete filter */
|
||||||
/* ════════════════════════════════════════════════════════════ */
|
/* ════════════════════════════════════════════════════════════ */
|
||||||
|
|||||||
@@ -133,14 +133,18 @@ export function getSessionStatus(): SessionStatus {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function getRunningJobs(session: EterraSession): string[] {
|
function getRunningJobs(session: EterraSession): string[] {
|
||||||
|
// Guard: ensure activeJobs is iterable (Set). Containers may restart
|
||||||
|
// with stale globalThis where the Set was serialized as plain object.
|
||||||
|
if (!(session.activeJobs instanceof Set)) {
|
||||||
|
session.activeJobs = new Set();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
const running: string[] = [];
|
const running: string[] = [];
|
||||||
for (const jid of session.activeJobs) {
|
for (const jid of session.activeJobs) {
|
||||||
const p = getProgress(jid);
|
const p = getProgress(jid);
|
||||||
// If progress exists and is still running, count it
|
|
||||||
if (p && p.status === "running") {
|
if (p && p.status === "running") {
|
||||||
running.push(jid);
|
running.push(jid);
|
||||||
} else {
|
} else {
|
||||||
// Clean up finished/unknown jobs
|
|
||||||
session.activeJobs.delete(jid);
|
session.activeJobs.delete(jid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user