feat(parcel-sync): incremental sync, smart export, auto-refresh + weekend deep sync

Sync Incremental:
- Add fetchObjectIds (returnIdsOnly) to eterra-client — fetches only OBJECTIDs in 1 request
- Add fetchFeaturesByObjectIds — downloads only delta features by OBJECTID IN (...)
- Rewrite syncLayer: compare remote IDs vs local, download only new features
- Fallback to full sync for first sync, forceFullSync, or delta > 50%
- Reduces sync time from ~10 min to ~5-10s for typical updates

Smart Export Tab:
- Hero buttons detect DB freshness — use export-local (instant) when data is fresh
- Dynamic subtitles: "Din DB (sync acum Xh)" / "Sync incremental" / "Sync complet"
- Re-sync link when data is fresh but user wants forced refresh
- Removed duplicate "Descarca din DB" buttons from background section

Auto-Refresh Scheduler:
- Self-contained timer via instrumentation.ts (Next.js startup hook)
- Weekday 1-5 AM: incremental refresh for existing UATs in DB
- Staggered processing with random delays between UATs
- Health check before processing, respects eTerra maintenance

Weekend Deep Sync:
- Full Magic processing for 9 large municipalities (Cluj, Bistrita, TgMures, etc.)
- Runs Fri/Sat/Sun 23:00-04:00, round-robin intercalated between cities
- 4 steps per city: sync terenuri, sync cladiri, import no-geom, enrichment
- State persisted in KeyValueStore — survives restarts, continues across nights
- Email status report at end of each session via Brevo SMTP
- Admin page at /wds: add/remove cities, view progress, reset
- Hint link on export tab pointing to /wds

API endpoints:
- POST /api/eterra/auto-refresh — N8N-compatible cron endpoint (Bearer token auth)
- GET/POST /api/eterra/weekend-sync — queue management for /wds page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-26 20:50:34 +02:00
parent 8f65efd5d1
commit 3b456eb481
11 changed files with 1929 additions and 128 deletions
@@ -0,0 +1,249 @@
/**
* Self-contained auto-refresh scheduler for ParcelSync.
*
* Runs inside the existing Node.js process — no external dependencies.
* Checks every 30 minutes; during the night window (15 AM) it picks
* stale UATs one at a time with random delays between them.
*
* Activated by importing this module (side-effect). The globalThis guard
* ensures only one scheduler runs per process, surviving HMR in dev.
*/
import { PrismaClient } from "@prisma/client";
import { syncLayer } from "./sync-service";
import { getLayerFreshness, isFresh } from "./enrich-service";
import { isEterraAvailable } from "./eterra-health";
import { isWeekendWindow, runWeekendDeepSync } from "./weekend-deep-sync";
const prisma = new PrismaClient();
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* ------------------------------------------------------------------ */
/* Configuration */
/* ------------------------------------------------------------------ */
/** Night window: only run between these hours (server local time) */
const NIGHT_START_HOUR = 1;
const NIGHT_END_HOUR = 5;
/** How often to check if we should run (ms) */
const CHECK_INTERVAL_MS = 30 * 60_000; // 30 minutes
/** Max UATs per nightly run */
const MAX_UATS_PER_RUN = 5;
/** Freshness threshold — sync if older than this */
const MAX_AGE_HOURS = 168; // 7 days
/** Delay between UATs: 60180s (random, spreads load on eTerra) */
const MIN_DELAY_MS = 60_000;
const MAX_DELAY_MS = 180_000;
/* ------------------------------------------------------------------ */
/* Singleton guard */
/* ------------------------------------------------------------------ */
const g = globalThis as {
__autoRefreshTimer?: ReturnType<typeof setInterval>;
__parcelSyncRunning?: boolean; // single flag for all sync modes
__autoRefreshLastRun?: string; // ISO date of last completed run
};
/* ------------------------------------------------------------------ */
/* Core logic */
/* ------------------------------------------------------------------ */
async function runAutoRefresh() {
// Prevent concurrent runs (shared with weekend sync)
if (g.__parcelSyncRunning) return;
const hour = new Date().getHours();
if (hour < NIGHT_START_HOUR || hour >= NIGHT_END_HOUR) return;
// Only run once per night (check date)
const today = new Date().toISOString().slice(0, 10);
if (g.__autoRefreshLastRun === today) return;
const username = process.env.ETERRA_USERNAME;
const password = process.env.ETERRA_PASSWORD;
if (!username || !password) return;
if (!isEterraAvailable()) {
console.log("[auto-refresh] eTerra indisponibil, skip.");
return;
}
g.__parcelSyncRunning = true;
console.log("[auto-refresh] Pornire refresh nocturn...");
try {
// Find UATs with data in DB
const uatGroups = await prisma.gisFeature.groupBy({
by: ["siruta"],
_count: { id: true },
});
if (uatGroups.length === 0) {
console.log("[auto-refresh] Niciun UAT in DB, skip.");
g.__autoRefreshLastRun = today;
g.__parcelSyncRunning = false;
return;
}
// Resolve names
const sirutas = uatGroups.map((gr) => gr.siruta);
const uatRecords = await prisma.gisUat.findMany({
where: { siruta: { in: sirutas } },
select: { siruta: true, name: true },
});
const nameMap = new Map(uatRecords.map((u) => [u.siruta, u.name]));
// Check freshness
type StaleUat = { siruta: string; name: string };
const stale: StaleUat[] = [];
for (const gr of uatGroups) {
const tStatus = await getLayerFreshness(gr.siruta, "TERENURI_ACTIVE");
if (!isFresh(tStatus.lastSynced, MAX_AGE_HOURS)) {
stale.push({
siruta: gr.siruta,
name: nameMap.get(gr.siruta) ?? gr.siruta,
});
}
}
if (stale.length === 0) {
console.log("[auto-refresh] Toate UAT-urile sunt proaspete, skip.");
g.__autoRefreshLastRun = today;
g.__parcelSyncRunning = false;
return;
}
// Shuffle so different UATs get priority each night
for (let i = stale.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[stale[i]!, stale[j]!] = [stale[j]!, stale[i]!];
}
const batch = stale.slice(0, MAX_UATS_PER_RUN);
console.log(
`[auto-refresh] ${stale.length} UAT-uri stale, procesez ${batch.length}: ${batch.map((u) => u.name).join(", ")}`,
);
for (let i = 0; i < batch.length; i++) {
const uat = batch[i]!;
// Random staggered delay between UATs
if (i > 0) {
const delay =
MIN_DELAY_MS + Math.random() * (MAX_DELAY_MS - MIN_DELAY_MS);
console.log(
`[auto-refresh] Pauza ${Math.round(delay / 1000)}s inainte de ${uat.name}...`,
);
await sleep(delay);
}
// Check we're still in the night window
const currentHour = new Date().getHours();
if (currentHour >= NIGHT_END_HOUR) {
console.log("[auto-refresh] Fereastra nocturna s-a inchis, opresc.");
break;
}
// Check eTerra is still available
if (!isEterraAvailable()) {
console.log("[auto-refresh] eTerra a devenit indisponibil, opresc.");
break;
}
const start = Date.now();
try {
const tRes = await syncLayer(
username,
password,
uat.siruta,
"TERENURI_ACTIVE",
{ uatName: uat.name },
);
const cRes = await syncLayer(
username,
password,
uat.siruta,
"CLADIRI_ACTIVE",
{ uatName: uat.name },
);
const dur = ((Date.now() - start) / 1000).toFixed(1);
console.log(
`[auto-refresh] ${uat.name} (${uat.siruta}): terenuri +${tRes.newFeatures}/-${tRes.removedFeatures}, cladiri +${cRes.newFeatures}/-${cRes.removedFeatures} (${dur}s)`,
);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[auto-refresh] Eroare ${uat.name} (${uat.siruta}): ${msg}`,
);
}
}
g.__autoRefreshLastRun = today;
console.log("[auto-refresh] Run nocturn finalizat.");
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[auto-refresh] Eroare generala: ${msg}`);
} finally {
g.__parcelSyncRunning = false;
}
}
/* ------------------------------------------------------------------ */
/* Weekend deep sync wrapper */
/* ------------------------------------------------------------------ */
async function runWeekendCheck() {
if (g.__parcelSyncRunning) return;
if (!isWeekendWindow()) return;
g.__parcelSyncRunning = true;
try {
await runWeekendDeepSync();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[weekend-sync] Eroare: ${msg}`);
} finally {
g.__parcelSyncRunning = false;
}
}
/* ------------------------------------------------------------------ */
/* Start scheduler (once per process) */
/* ------------------------------------------------------------------ */
if (!g.__autoRefreshTimer) {
g.__autoRefreshTimer = setInterval(() => {
// Weekend nights (Fri/Sat/Sun 23-04): deep sync for large cities
// Weekday nights (1-5 AM): incremental refresh for existing data
if (isWeekendWindow()) {
void runWeekendCheck();
} else {
void runAutoRefresh();
}
}, CHECK_INTERVAL_MS);
// Also check once shortly after startup (60s delay to let everything init)
setTimeout(() => {
if (isWeekendWindow()) {
void runWeekendCheck();
} else {
void runAutoRefresh();
}
}, 60_000);
console.log(
`[auto-refresh] Scheduler pornit — verificare la fiecare ${CHECK_INTERVAL_MS / 60_000} min`,
);
console.log(
`[auto-refresh] Weekday: ${NIGHT_START_HOUR}:00${NIGHT_END_HOUR}:00 refresh incremental`,
);
console.log(
`[auto-refresh] Weekend: Vin/Sam/Dum 23:0004:00 deep sync municipii`,
);
}
@@ -297,6 +297,81 @@ export class EterraClient {
return this.countLayerWithParams(layer, params, true);
}
/* ---- Incremental sync: fetch only OBJECTIDs -------------------- */
async fetchObjectIds(layer: LayerConfig, siruta: string): Promise<number[]> {
const where = await this.buildWhere(layer, siruta);
return this.fetchObjectIdsByWhere(layer, where);
}
async fetchObjectIdsByWhere(
layer: LayerConfig,
where: string,
): Promise<number[]> {
const params = new URLSearchParams();
params.set("f", "json");
params.set("where", where);
params.set("returnIdsOnly", "true");
const data = await this.queryLayer(layer, params, false);
return data.objectIds ?? [];
}
async fetchObjectIdsByGeometry(
layer: LayerConfig,
geometry: EsriGeometry,
): Promise<number[]> {
const params = new URLSearchParams();
params.set("f", "json");
params.set("where", "1=1");
params.set("returnIdsOnly", "true");
this.applyGeometryParams(params, geometry);
const data = await this.queryLayer(layer, params, true);
return data.objectIds ?? [];
}
/* ---- Fetch specific features by OBJECTID list ------------------- */
async fetchFeaturesByObjectIds(
layer: LayerConfig,
objectIds: number[],
options?: {
baseWhere?: string;
outFields?: string;
returnGeometry?: boolean;
onProgress?: ProgressCallback;
delayMs?: number;
},
): Promise<EsriFeature[]> {
if (objectIds.length === 0) return [];
const chunkSize = 500;
const all: EsriFeature[] = [];
const total = objectIds.length;
for (let i = 0; i < objectIds.length; i += chunkSize) {
const chunk = objectIds.slice(i, i + chunkSize);
const idList = chunk.join(",");
const idWhere = `OBJECTID IN (${idList})`;
const where = options?.baseWhere
? `(${options.baseWhere}) AND ${idWhere}`
: idWhere;
try {
const features = await this.fetchAllLayerByWhere(layer, where, {
outFields: options?.outFields ?? "*",
returnGeometry: options?.returnGeometry ?? true,
delayMs: options?.delayMs ?? 200,
});
all.push(...features);
} catch (err) {
// Log but continue with remaining chunks — partial results better than none
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[fetchFeaturesByObjectIds] Chunk ${Math.floor(i / chunkSize) + 1} failed (${chunk.length} IDs): ${msg}`,
);
}
options?.onProgress?.(all.length, total);
}
return all;
}
async listLayer(
layer: LayerConfig,
siruta: string,
@@ -7,7 +7,7 @@
import { Prisma, PrismaClient } from "@prisma/client";
import { EterraClient } from "./eterra-client";
import type { LayerConfig } from "./eterra-client";
import type { LayerConfig, EsriFeature } from "./eterra-client";
import { esriToGeojson } from "./esri-geojson";
import { findLayerById, type LayerCatalogItem } from "./eterra-layers";
import { fetchUatGeometry } from "./uat-geometry";
@@ -116,50 +116,107 @@ export async function syncLayer(
uatGeometry = await fetchUatGeometry(client, siruta);
}
// Count remote features
push({ phase: "Numărare remote" });
let remoteCount: number;
try {
remoteCount = uatGeometry
? await client.countLayerByGeometry(layer, uatGeometry)
: await client.countLayer(layer, siruta);
} catch {
remoteCount = 0;
}
push({ phase: "Verificare locală", total: remoteCount });
// Get local OBJECTIDs for this layer+siruta
push({ phase: "Verificare locală" });
const localFeatures = await prisma.gisFeature.findMany({
where: { layerId, siruta },
select: { objectId: true },
});
const localObjIds = new Set(localFeatures.map((f) => f.objectId));
// Fetch all remote features
push({ phase: "Descărcare features", downloaded: 0, total: remoteCount });
// Fetch remote OBJECTIDs only (fast — returnIdsOnly)
push({ phase: "Comparare ID-uri remote" });
let remoteObjIdArray: number[];
try {
remoteObjIdArray = uatGeometry
? await client.fetchObjectIdsByGeometry(layer, uatGeometry)
: await client.fetchObjectIds(layer, siruta);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(
`[syncLayer] fetchObjectIds failed for ${layerId}/${siruta}: ${msg} — falling back to full sync`,
);
remoteObjIdArray = [];
}
const remoteObjIds = new Set(remoteObjIdArray);
const remoteCount = remoteObjIds.size;
const allRemote = uatGeometry
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
total: remoteCount > 0 ? remoteCount : undefined,
onProgress: (dl, tot) =>
push({ phase: "Descărcare features", downloaded: dl, total: tot }),
delayMs: 200,
})
: await client.fetchAllLayerByWhere(
layer,
await buildWhere(client, layer, siruta),
{
// Compute delta
const newObjIdArray = [...remoteObjIds].filter((id) => !localObjIds.has(id));
const removedObjIds = [...localObjIds].filter(
(id) => !remoteObjIds.has(id),
);
// Decide: incremental (download only delta) or full sync
const deltaRatio =
remoteCount > 0 ? newObjIdArray.length / remoteCount : 1;
const useFullSync =
options?.forceFullSync ||
localObjIds.size === 0 ||
deltaRatio > 0.5;
let allRemote: EsriFeature[];
if (useFullSync) {
// Full sync: download all features (first sync or forced)
push({
phase: "Descărcare features (complet)",
downloaded: 0,
total: remoteCount,
});
allRemote = uatGeometry
? await client.fetchAllLayerByGeometry(layer, uatGeometry, {
total: remoteCount > 0 ? remoteCount : undefined,
onProgress: (dl, tot) =>
push({
phase: "Descărcare features",
phase: "Descărcare features (complet)",
downloaded: dl,
total: tot,
}),
delayMs: 200,
},
);
})
: await client.fetchAllLayerByWhere(
layer,
await buildWhere(client, layer, siruta),
{
total: remoteCount > 0 ? remoteCount : undefined,
onProgress: (dl, tot) =>
push({
phase: "Descărcare features (complet)",
downloaded: dl,
total: tot,
}),
delayMs: 200,
},
);
} else if (newObjIdArray.length > 0) {
// Incremental sync: download only the new features
push({
phase: "Descărcare features noi",
downloaded: 0,
total: newObjIdArray.length,
});
const baseWhere = uatGeometry
? undefined
: await buildWhere(client, layer, siruta);
allRemote = await client.fetchFeaturesByObjectIds(
layer,
newObjIdArray,
{
baseWhere,
onProgress: (dl, tot) =>
push({
phase: "Descărcare features noi",
downloaded: dl,
total: tot,
}),
delayMs: 200,
},
);
} else {
// Nothing new to download
allRemote = [];
}
// Convert to GeoJSON for geometry storage
const geojson = esriToGeojson(allRemote);
@@ -169,19 +226,11 @@ export async function syncLayer(
if (objId != null) geojsonByObjId.set(objId, f);
}
// Determine which OBJECTIDs are new
const remoteObjIds = new Set<number>();
for (const f of allRemote) {
const objId = f.attributes.OBJECTID as number | undefined;
if (objId != null) remoteObjIds.add(objId);
}
// For incremental sync, newObjIds = the delta we downloaded
// For full sync, newObjIds = all remote (if forced) or only truly new
const newObjIds = options?.forceFullSync
? remoteObjIds
: new Set([...remoteObjIds].filter((id) => !localObjIds.has(id)));
const removedObjIds = [...localObjIds].filter(
(id) => !remoteObjIds.has(id),
);
: new Set(newObjIdArray);
push({
phase: "Salvare în baza de date",
@@ -0,0 +1,522 @@
/**
* 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));
/* ------------------------------------------------------------------ */
/* 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(): boolean {
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 },
);
const dur = ((Date.now() - start) / 1000).toFixed(1);
return {
success: res.status === "done",
message: `Terenuri: ${res.totalLocal} local (+${res.newFeatures}/-${res.removedFeatures}) [${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(): Promise<void> {
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
if (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.`,
);
// Create eTerra client (shared across steps)
let client: EterraClient;
try {
client = await EterraClient.create(username, password);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[weekend-sync] Nu se poate conecta la eTerra: ${msg}`);
await saveState(state);
return;
}
// 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()) {
console.log("[weekend-sync] Fereastra s-a inchis, opresc.");
await saveState(state);
await sendStatusEmail(state, log, sessionStart);
return;
}
// Check eTerra health
if (!isEterraAvailable()) {
console.log("[weekend-sync] eTerra indisponibil, opresc.");
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
console.log(`[weekend-sync] ${city.name}: ${stepName}...`);
try {
const result = await executeStep(city, stepName, client);
city.steps[stepName] = result.success ? "done" : "error";
if (!result.success) city.errorMessage = 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}`,
);
}
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.`,
);
}
await saveState(state);
await sendStatusEmail(state, log, sessionStart);
console.log(`[weekend-sync] Sesiune finalizata. ${stepsCompleted} pasi executati.`);
}
/* ------------------------------------------------------------------ */
/* 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}`);
}
}