Files
ArchiTools/src/modules/parcel-sync/services/weekend-deep-sync.ts
T
AI Assistant 730eee6c8a feat(wds): add manual sync trigger button with force-run mode
- triggerForceSync() resets error steps, clears lastSessionDate, starts sync immediately
- Force mode uses extended night window (22:00-05:00) instead of weekend-only
- API action 'trigger' starts sync in background, returns immediately
- 'Porneste sync' button in header (hidden when already running)
- Respects __parcelSyncRunning guard to prevent concurrent runs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 01:59:07 +03:00

709 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Weekend Deep Sync — full Magic processing for large cities.
*
* Runs Fri/Sat/Sun nights 23:0004:00. Processes cities in round-robin
* (one step per city, then rotate) so progress is spread across cities.
* State is persisted in KeyValueStore — survives restarts and continues
* across multiple nights/weekends.
*
* Steps per city (each is resumable):
* 1. sync_terenuri — syncLayer TERENURI_ACTIVE
* 2. sync_cladiri — syncLayer CLADIRI_ACTIVE
* 3. import_nogeom — import parcels without geometry
* 4. enrich — enrichFeatures (slowest, naturally resumable)
*/
import { PrismaClient, Prisma } from "@prisma/client";
import { syncLayer } from "./sync-service";
import { EterraClient } from "./eterra-client";
import { isEterraAvailable } from "./eterra-health";
import { sendEmail } from "@/core/notifications/email-service";
const prisma = new PrismaClient();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ */
/* Live activity tracking (globalThis — same process) */
/* ------------------------------------------------------------------ */
const g = globalThis as {
__weekendSyncActivity?: {
city: string;
step: string;
startedAt: string;
} | null;
__parcelSyncRunning?: boolean;
};
export function getWeekendSyncActivity() {
return g.__weekendSyncActivity ?? null;
}
/* ------------------------------------------------------------------ */
/* City queue configuration */
/* ------------------------------------------------------------------ */
export type CityConfig = {
siruta: string;
name: string;
county: string;
priority: number; // lower = higher priority
};
/** Initial queue — priority 1 = first processed */
const DEFAULT_CITIES: CityConfig[] = [
{ siruta: "54975", name: "Cluj-Napoca", county: "Cluj", priority: 1 },
{ siruta: "32394", name: "Bistri\u021Ba", county: "Bistri\u021Ba-N\u0103s\u0103ud", priority: 1 },
{ siruta: "114319", name: "T\u00E2rgu Mure\u0219", county: "Mure\u0219", priority: 2 },
{ siruta: "139704", name: "Zal\u0103u", county: "S\u0103laj", priority: 2 },
{ siruta: "26564", name: "Oradea", county: "Bihor", priority: 2 },
{ siruta: "9262", name: "Arad", county: "Arad", priority: 2 },
{ siruta: "155243", name: "Timi\u0219oara", county: "Timi\u0219", priority: 2 },
{ siruta: "143450", name: "Sibiu", county: "Sibiu", priority: 2 },
{ siruta: "40198", name: "Bra\u0219ov", county: "Bra\u0219ov", priority: 2 },
];
/* ------------------------------------------------------------------ */
/* Step definitions */
/* ------------------------------------------------------------------ */
const STEPS = [
"sync_terenuri",
"sync_cladiri",
"import_nogeom",
"enrich",
] as const;
type StepName = (typeof STEPS)[number];
type StepStatus = "pending" | "done" | "error";
/* ------------------------------------------------------------------ */
/* Persisted state */
/* ------------------------------------------------------------------ */
type CityState = {
siruta: string;
name: string;
county: string;
priority: number;
steps: Record<StepName, StepStatus>;
lastActivity?: string;
errorMessage?: string;
};
type WeekendSyncState = {
cities: CityState[];
lastSessionDate?: string;
totalSessions: number;
completedCycles: number; // how many full cycles (all cities done)
};
const KV_NAMESPACE = "parcel-sync-weekend";
const KV_KEY = "queue-state";
async function loadState(): Promise<WeekendSyncState> {
const row = await prisma.keyValueStore.findUnique({
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
});
if (row?.value && typeof row.value === "object") {
return row.value as unknown as WeekendSyncState;
}
// Initialize with default cities
return {
cities: DEFAULT_CITIES.map((c) => ({
...c,
steps: {
sync_terenuri: "pending",
sync_cladiri: "pending",
import_nogeom: "pending",
enrich: "pending",
},
})),
totalSessions: 0,
completedCycles: 0,
};
}
async function saveState(state: WeekendSyncState): Promise<void> {
// Retry once on failure — state persistence is critical for resume
for (let attempt = 0; attempt < 2; attempt++) {
try {
await prisma.keyValueStore.upsert({
where: { namespace_key: { namespace: KV_NAMESPACE, key: KV_KEY } },
update: { value: state as unknown as Prisma.InputJsonValue },
create: {
namespace: KV_NAMESPACE,
key: KV_KEY,
value: state as unknown as Prisma.InputJsonValue,
},
});
return;
} catch (err) {
if (attempt === 0) {
console.warn("[weekend-sync] saveState retry...");
await sleep(2000);
} else {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[weekend-sync] saveState failed: ${msg}`);
}
}
}
}
/* ------------------------------------------------------------------ */
/* Time window */
/* ------------------------------------------------------------------ */
const WEEKEND_START_HOUR = 23;
const WEEKEND_END_HOUR = 4;
const PAUSE_BETWEEN_STEPS_MS = 60_000 + Math.random() * 60_000; // 60-120s
/** Check if current time is within the weekend sync window */
export function isWeekendWindow(): boolean {
const now = new Date();
const day = now.getDay(); // 0=Sun, 5=Fri, 6=Sat
const hour = now.getHours();
// Fri 23:00+ or Sat 23:00+ or Sun 23:00+
if ((day === 5 || day === 6 || day === 0) && hour >= WEEKEND_START_HOUR) {
return true;
}
// Sat 00-04 (continuation of Friday night) or Sun 00-04 or Mon 00-04
if ((day === 6 || day === 0 || day === 1) && hour < WEEKEND_END_HOUR) {
return true;
}
return false;
}
/** Check if still within the window (called during processing) */
function stillInWindow(force?: boolean): boolean {
if (force) {
// Force mode: any night 22:0005:00
const hour = new Date().getHours();
return hour >= 22 || hour < 5;
}
const hour = new Date().getHours();
// We can be in 23,0,1,2,3 — stop at 4
if (hour >= WEEKEND_END_HOUR && hour < WEEKEND_START_HOUR) return false;
return isWeekendWindow();
}
/* ------------------------------------------------------------------ */
/* Step executors */
/* ------------------------------------------------------------------ */
async function executeStep(
city: CityState,
step: StepName,
client: EterraClient,
): Promise<{ success: boolean; message: string }> {
const start = Date.now();
switch (step) {
case "sync_terenuri": {
const res = await syncLayer(
process.env.ETERRA_USERNAME!,
process.env.ETERRA_PASSWORD!,
city.siruta,
"TERENURI_ACTIVE",
{ uatName: city.name, forceFullSync: true },
);
// Also sync admin layers (lightweight, non-fatal)
for (const adminLayer of ["LIMITE_INTRAV_DYNAMIC", "LIMITE_UAT"]) {
try {
await syncLayer(
process.env.ETERRA_USERNAME!,
process.env.ETERRA_PASSWORD!,
city.siruta,
adminLayer,
{ uatName: city.name },
);
} catch {
// admin layers are best-effort
}
}
const dur = ((Date.now() - start) / 1000).toFixed(1);
return {
success: res.status === "done",
message: `Terenuri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) + intravilan [${dur}s]`,
};
}
case "sync_cladiri": {
const res = await syncLayer(
process.env.ETERRA_USERNAME!,
process.env.ETERRA_PASSWORD!,
city.siruta,
"CLADIRI_ACTIVE",
{ uatName: city.name, forceFullSync: true },
);
const dur = ((Date.now() - start) / 1000).toFixed(1);
return {
success: res.status === "done",
message: `Cl\u0103diri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) [${dur}s]`,
};
}
case "import_nogeom": {
const { syncNoGeometryParcels } = await import("./no-geom-sync");
const res = await syncNoGeometryParcels(client, city.siruta);
const dur = ((Date.now() - start) / 1000).toFixed(1);
return {
success: res.status !== "error",
message: `No-geom: ${res.imported} importate, ${res.skipped} skip [${dur}s]`,
};
}
case "enrich": {
const { enrichFeatures } = await import("./enrich-service");
const res = await enrichFeatures(client, city.siruta);
const dur = ((Date.now() - start) / 1000).toFixed(1);
return {
success: res.status === "done",
message: res.status === "done"
? `Enrichment: ${res.enrichedCount}/${res.totalFeatures ?? "?"} (${dur}s)`
: `Enrichment eroare: ${res.error ?? "necunoscuta"} (${dur}s)`,
};
}
}
}
/* ------------------------------------------------------------------ */
/* Main runner */
/* ------------------------------------------------------------------ */
type SessionLog = {
city: string;
step: string;
success: boolean;
message: string;
};
export async function runWeekendDeepSync(options?: {
force?: boolean;
}): Promise<void> {
const force = options?.force ?? false;
const username = process.env.ETERRA_USERNAME;
const password = process.env.ETERRA_PASSWORD;
if (!username || !password) return;
if (!isEterraAvailable()) {
console.log("[weekend-sync] eTerra indisponibil, skip.");
return;
}
const state = await loadState();
const today = new Date().toISOString().slice(0, 10);
// Prevent running twice in the same session (force bypasses)
if (!force && state.lastSessionDate === today) return;
state.totalSessions++;
state.lastSessionDate = today;
// Ensure new default cities are added if config expanded
for (const dc of DEFAULT_CITIES) {
if (!state.cities.some((c) => c.siruta === dc.siruta)) {
state.cities.push({
...dc,
steps: {
sync_terenuri: "pending",
sync_cladiri: "pending",
import_nogeom: "pending",
enrich: "pending",
},
});
}
}
const sessionStart = Date.now();
const log: SessionLog[] = [];
let stepsCompleted = 0;
console.log(
`[weekend-sync] Sesiune #${state.totalSessions} pornita. ${state.cities.length} orase in coada.`,
);
// Sort cities: priority first, then shuffle within same priority
const sorted = [...state.cities].sort((a, b) => {
if (a.priority !== b.priority) return a.priority - b.priority;
return Math.random() - 0.5; // random within same priority
});
// Round-robin: iterate through steps, for each step iterate through cities
for (const stepName of STEPS) {
// Find cities that still need this step
const needsStep = sorted.filter((c) => c.steps[stepName] === "pending");
if (needsStep.length === 0) continue;
for (const city of needsStep) {
// Check time window
if (!stillInWindow(force)) {
console.log("[weekend-sync] Fereastra s-a inchis, opresc.");
g.__weekendSyncActivity = null;
await saveState(state);
await sendStatusEmail(state, log, sessionStart);
return;
}
// Check eTerra health
if (!isEterraAvailable()) {
console.log("[weekend-sync] eTerra indisponibil, opresc.");
g.__weekendSyncActivity = null;
await saveState(state);
await sendStatusEmail(state, log, sessionStart);
return;
}
// Pause between steps
if (stepsCompleted > 0) {
const pause = 60_000 + Math.random() * 60_000;
console.log(
`[weekend-sync] Pauza ${Math.round(pause / 1000)}s inainte de ${city.name} / ${stepName}`,
);
await sleep(pause);
}
// Execute step — fresh client per step (sessions expire after ~10 min)
console.log(`[weekend-sync] ${city.name}: ${stepName}...`);
g.__weekendSyncActivity = {
city: city.name,
step: stepName,
startedAt: new Date().toISOString(),
};
try {
const client = await EterraClient.create(username, password);
const result = await executeStep(city, stepName, client);
city.steps[stepName] = result.success ? "done" : "error";
if (!result.success) {
city.errorMessage = result.message;
await sendStepErrorEmail(city, stepName, result.message);
}
city.lastActivity = new Date().toISOString();
log.push({
city: city.name,
step: stepName,
success: result.success,
message: result.message,
});
console.log(
`[weekend-sync] ${city.name}: ${stepName}${result.success ? "OK" : "EROARE"}${result.message}`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
city.steps[stepName] = "error";
city.errorMessage = msg;
city.lastActivity = new Date().toISOString();
log.push({
city: city.name,
step: stepName,
success: false,
message: msg,
});
console.error(
`[weekend-sync] ${city.name}: ${stepName} EROARE: ${msg}`,
);
await sendStepErrorEmail(city, stepName, msg);
}
g.__weekendSyncActivity = null;
stepsCompleted++;
// Save state after each step (crash safety)
await saveState(state);
}
}
// Check if all cities completed all steps → new cycle
const allDone = state.cities.every((c) =>
STEPS.every((s) => c.steps[s] === "done"),
);
if (allDone) {
state.completedCycles++;
// Reset for next cycle
for (const city of state.cities) {
for (const step of STEPS) {
city.steps[step] = "pending";
}
}
console.log(
`[weekend-sync] Ciclu complet #${state.completedCycles}! Reset pentru urmatorul ciclu.`,
);
// Notify N8N to rebuild PMTiles (overview tiles for geoportal)
await fireSyncWebhook(state.completedCycles);
}
await saveState(state);
await sendStatusEmail(state, log, sessionStart);
console.log(`[weekend-sync] Sesiune finalizata. ${stepsCompleted} pasi executati.`);
}
/* ------------------------------------------------------------------ */
/* Immediate error email */
/* ------------------------------------------------------------------ */
async function sendStepErrorEmail(
city: CityState,
step: StepName,
errorMsg: string,
): Promise<void> {
const emailTo = process.env.WEEKEND_SYNC_EMAIL;
if (!emailTo) return;
try {
const now = new Date();
const timeStr = now.toLocaleString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const stepLabel: Record<StepName, string> = {
sync_terenuri: "Sync Terenuri",
sync_cladiri: "Sync Cladiri",
import_nogeom: "Import No-Geom",
enrich: "Enrichment",
};
const html = `
<div style="font-family:system-ui,sans-serif;max-width:600px;margin:0 auto">
<h2 style="color:#ef4444;margin-bottom:4px">Weekend Sync — Eroare</h2>
<p style="color:#6b7280;margin-top:0">${timeStr}</p>
<table style="border-collapse:collapse;width:100%;border:1px solid #fecaca;border-radius:6px;background:#fef2f2">
<tr>
<td style="padding:8px 12px;font-weight:600;color:#374151">Oras</td>
<td style="padding:8px 12px">${city.name} (${city.county})</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:600;color:#374151">Pas</td>
<td style="padding:8px 12px">${stepLabel[step]}</td>
</tr>
<tr>
<td style="padding:8px 12px;font-weight:600;color:#374151">Eroare</td>
<td style="padding:8px 12px;color:#dc2626;word-break:break-word">${errorMsg}</td>
</tr>
</table>
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
Generat automat de ArchiTools Weekend Sync
</p>
</div>
`;
await sendEmail({
to: emailTo,
subject: `[ArchiTools] WDS Eroare: ${city.name}${stepLabel[step]}`,
html,
});
console.log(`[weekend-sync] Email eroare trimis: ${city.name}/${step}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[weekend-sync] Nu s-a putut trimite email eroare: ${msg}`);
}
}
/* ------------------------------------------------------------------ */
/* Email status report */
/* ------------------------------------------------------------------ */
async function sendStatusEmail(
state: WeekendSyncState,
log: SessionLog[],
sessionStart: number,
): Promise<void> {
const emailTo = process.env.WEEKEND_SYNC_EMAIL;
if (!emailTo) return;
try {
const duration = Date.now() - sessionStart;
const durMin = Math.round(duration / 60_000);
const durStr =
durMin >= 60
? `${Math.floor(durMin / 60)}h ${durMin % 60}m`
: `${durMin}m`;
const now = new Date();
const dayNames = [
"Duminic\u0103",
"Luni",
"Mar\u021Bi",
"Miercuri",
"Joi",
"Vineri",
"S\u00E2mb\u0103t\u0103",
];
const dayName = dayNames[now.getDay()] ?? "";
const dateStr = now.toLocaleDateString("ro-RO", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
// Build city progress table
const cityRows = state.cities
.sort((a, b) => a.priority - b.priority)
.map((c) => {
const doneCount = STEPS.filter((s) => c.steps[s] === "done").length;
const errorCount = STEPS.filter((s) => c.steps[s] === "error").length;
const icon =
doneCount === STEPS.length
? "\u2713"
: doneCount > 0
? "\u25D0"
: "\u25CB";
const color =
doneCount === STEPS.length
? "#22c55e"
: errorCount > 0
? "#ef4444"
: doneCount > 0
? "#f59e0b"
: "#9ca3af";
const stepDetail = STEPS.map(
(s) =>
`<span style="color:${c.steps[s] === "done" ? "#22c55e" : c.steps[s] === "error" ? "#ef4444" : "#9ca3af"}">${s.replace("_", " ")}</span>`,
).join(" \u2192 ");
return `<tr>
<td style="padding:4px 8px;color:${color};font-size:16px">${icon}</td>
<td style="padding:4px 8px;font-weight:600">${c.name}</td>
<td style="padding:4px 8px;color:#6b7280;font-size:12px">${c.county}</td>
<td style="padding:4px 8px">${doneCount}/${STEPS.length}</td>
<td style="padding:4px 8px;font-size:11px">${stepDetail}</td>
</tr>`;
})
.join("\n");
// Build session log
const logRows =
log.length > 0
? log
.map(
(l) =>
`<tr>
<td style="padding:2px 6px;font-size:12px">${l.success ? "\u2713" : "\u2717"}</td>
<td style="padding:2px 6px;font-size:12px">${l.city}</td>
<td style="padding:2px 6px;font-size:12px;color:#6b7280">${l.step}</td>
<td style="padding:2px 6px;font-size:11px;color:#6b7280">${l.message}</td>
</tr>`,
)
.join("\n")
: '<tr><td colspan="4" style="padding:8px;color:#9ca3af;font-size:12px">Niciun pas executat in aceasta sesiune</td></tr>';
const html = `
<div style="font-family:system-ui,sans-serif;max-width:700px;margin:0 auto">
<h2 style="color:#1f2937;margin-bottom:4px">Weekend Sync — ${dayName} ${dateStr}</h2>
<p style="color:#6b7280;margin-top:0">Durata sesiune: ${durStr} | Sesiunea #${state.totalSessions} | Cicluri complete: ${state.completedCycles}</p>
<h3 style="color:#374151;margin-bottom:8px">Progres per ora\u0219</h3>
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb;border-radius:6px">
<thead><tr style="background:#f9fafb">
<th style="padding:6px 8px;text-align:left;font-size:12px"></th>
<th style="padding:6px 8px;text-align:left;font-size:12px">Ora\u0219</th>
<th style="padding:6px 8px;text-align:left;font-size:12px">Jude\u021B</th>
<th style="padding:6px 8px;text-align:left;font-size:12px">Pa\u0219i</th>
<th style="padding:6px 8px;text-align:left;font-size:12px">Detaliu</th>
</tr></thead>
<tbody>${cityRows}</tbody>
</table>
<h3 style="color:#374151;margin-top:16px;margin-bottom:8px">Activitate sesiune curent\u0103</h3>
<table style="border-collapse:collapse;width:100%;border:1px solid #e5e7eb">
<tbody>${logRows}</tbody>
</table>
<p style="color:#9ca3af;font-size:11px;margin-top:16px">
Generat automat de ArchiTools Weekend Sync
</p>
</div>
`;
await sendEmail({
to: emailTo,
subject: `[ArchiTools] Weekend Sync — ${dayName} ${dateStr}`,
html,
});
console.log(`[weekend-sync] Email status trimis la ${emailTo}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[weekend-sync] Nu s-a putut trimite email: ${msg}`);
}
}
/* ------------------------------------------------------------------ */
/* Manual force trigger */
/* ------------------------------------------------------------------ */
/**
* Trigger a sync run outside the weekend window.
* Resets error steps, clears lastSessionDate, and starts immediately.
* Uses an extended night window (22:0005:00) for the stillInWindow check.
*/
export async function triggerForceSync(): Promise<{ started: boolean; reason?: string }> {
if (g.__parcelSyncRunning) {
return { started: false, reason: "O sincronizare ruleaza deja" };
}
const username = process.env.ETERRA_USERNAME;
const password = process.env.ETERRA_PASSWORD;
if (!username || !password) {
return { started: false, reason: "ETERRA credentials lipsesc" };
}
if (!isEterraAvailable()) {
return { started: false, reason: "eTerra indisponibil" };
}
// Reset error steps + lastSessionDate in DB so the run proceeds
const state = await loadState();
for (const city of state.cities) {
for (const step of STEPS) {
if (city.steps[step] === "error") {
city.steps[step] = "pending";
city.errorMessage = undefined;
}
}
}
state.lastSessionDate = undefined;
await saveState(state);
// Start in background — don't block the API response
g.__parcelSyncRunning = true;
void (async () => {
try {
console.log("[weekend-sync] Force sync declansat manual.");
await runWeekendDeepSync({ force: true });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[weekend-sync] Force sync eroare: ${msg}`);
} finally {
g.__parcelSyncRunning = false;
}
})();
return { started: true };
}
/* ------------------------------------------------------------------ */
/* N8N Webhook — trigger PMTiles rebuild after sync cycle */
/* ------------------------------------------------------------------ */
async function fireSyncWebhook(cycle: number): Promise<void> {
const url = process.env.N8N_WEBHOOK_URL;
if (!url) return;
try {
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: "weekend-sync-cycle-complete",
cycle,
timestamp: new Date().toISOString(),
}),
});
console.log(`[weekend-sync] Webhook trimis la N8N (ciclu #${cycle})`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[weekend-sync] Webhook N8N esuat: ${msg}`);
}
}