4410e968db
- Auto-poll every 15s when sync is running, 60s when idle - Live status banner: running (with city/step), error list, weekend window waiting, connection error - Highlight active city card and currently-running step with pulse animation - Send immediate error email per failed step (not just at session end) - Expose syncStatus/currentActivity/inWeekendWindow in API response - Stop silently swallowing fetch/action errors — show them in the UI Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
227 lines
6.8 KiB
TypeScript
227 lines
6.8 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { PrismaClient, Prisma } from "@prisma/client";
|
|
import {
|
|
isWeekendWindow,
|
|
getWeekendSyncActivity,
|
|
} from "@/modules/parcel-sync/services/weekend-deep-sync";
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const g = globalThis as { __parcelSyncRunning?: boolean };
|
|
|
|
const KV_NAMESPACE = "parcel-sync-weekend";
|
|
const KV_KEY = "queue-state";
|
|
|
|
type StepName = "sync_terenuri" | "sync_cladiri" | "import_nogeom" | "enrich";
|
|
type StepStatus = "pending" | "done" | "error";
|
|
|
|
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;
|
|
};
|
|
|
|
const FRESH_STEPS: Record<StepName, StepStatus> = {
|
|
sync_terenuri: "pending",
|
|
sync_cladiri: "pending",
|
|
import_nogeom: "pending",
|
|
enrich: "pending",
|
|
};
|
|
|
|
const DEFAULT_CITIES: Omit<CityState, "steps">[] = [
|
|
{ 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 },
|
|
];
|
|
|
|
/** Initialize state with default cities if not present in DB */
|
|
async function getOrCreateState(): 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;
|
|
}
|
|
// First access — initialize with defaults
|
|
const state: WeekendSyncState = {
|
|
cities: DEFAULT_CITIES.map((c) => ({ ...c, steps: { ...FRESH_STEPS } })),
|
|
totalSessions: 0,
|
|
completedCycles: 0,
|
|
};
|
|
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 state;
|
|
}
|
|
|
|
/**
|
|
* GET /api/eterra/weekend-sync
|
|
* Returns the current queue state.
|
|
*/
|
|
export async function GET() {
|
|
// Auth handled by middleware (route is not excluded)
|
|
const state = await getOrCreateState();
|
|
const sirutas = state.cities.map((c) => c.siruta);
|
|
|
|
const counts = await prisma.gisFeature.groupBy({
|
|
by: ["siruta", "layerId"],
|
|
where: { siruta: { in: sirutas } },
|
|
_count: { id: true },
|
|
});
|
|
|
|
const enrichedCounts = await prisma.gisFeature.groupBy({
|
|
by: ["siruta"],
|
|
where: { siruta: { in: sirutas }, enrichedAt: { not: null } },
|
|
_count: { id: true },
|
|
});
|
|
|
|
const enrichedMap = new Map(enrichedCounts.map((e) => [e.siruta, e._count.id]));
|
|
|
|
type CityStats = {
|
|
terenuri: number;
|
|
cladiri: number;
|
|
total: number;
|
|
enriched: number;
|
|
};
|
|
const statsMap = new Map<string, CityStats>();
|
|
|
|
for (const c of counts) {
|
|
const existing = statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 };
|
|
existing.total += c._count.id;
|
|
if (c.layerId === "TERENURI_ACTIVE") existing.terenuri = c._count.id;
|
|
if (c.layerId === "CLADIRI_ACTIVE") existing.cladiri = c._count.id;
|
|
existing.enriched = enrichedMap.get(c.siruta) ?? 0;
|
|
statsMap.set(c.siruta, existing);
|
|
}
|
|
|
|
const citiesWithStats = state.cities.map((c) => ({
|
|
...c,
|
|
dbStats: statsMap.get(c.siruta) ?? { terenuri: 0, cladiri: 0, total: 0, enriched: 0 },
|
|
}));
|
|
|
|
// Determine live sync status
|
|
const running = !!g.__parcelSyncRunning;
|
|
const activity = getWeekendSyncActivity();
|
|
const inWindow = isWeekendWindow();
|
|
const hasErrors = state.cities.some((c) =>
|
|
(Object.values(c.steps) as StepStatus[]).some((s) => s === "error"),
|
|
);
|
|
|
|
type SyncStatus = "running" | "error" | "waiting" | "idle";
|
|
let syncStatus: SyncStatus = "idle";
|
|
if (running) syncStatus = "running";
|
|
else if (hasErrors) syncStatus = "error";
|
|
else if (inWindow) syncStatus = "waiting";
|
|
|
|
return NextResponse.json({
|
|
state: { ...state, cities: citiesWithStats },
|
|
syncStatus,
|
|
currentActivity: activity,
|
|
inWeekendWindow: inWindow,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* POST /api/eterra/weekend-sync
|
|
* Modify the queue: add/remove cities, reset steps, change priority.
|
|
*/
|
|
export async function POST(request: Request) {
|
|
// Auth handled by middleware (route is not excluded)
|
|
const body = (await request.json()) as {
|
|
action: "add" | "remove" | "reset" | "reset_all" | "set_priority";
|
|
siruta?: string;
|
|
name?: string;
|
|
county?: string;
|
|
priority?: number;
|
|
};
|
|
|
|
const state = await getOrCreateState();
|
|
|
|
switch (body.action) {
|
|
case "add": {
|
|
if (!body.siruta || !body.name) {
|
|
return NextResponse.json(
|
|
{ error: "siruta si name sunt obligatorii" },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
if (state.cities.some((c) => c.siruta === body.siruta)) {
|
|
return NextResponse.json(
|
|
{ error: `${body.name} (${body.siruta}) e deja in coada` },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
state.cities.push({
|
|
siruta: body.siruta,
|
|
name: body.name,
|
|
county: body.county ?? "",
|
|
priority: body.priority ?? 3,
|
|
steps: { ...FRESH_STEPS },
|
|
});
|
|
break;
|
|
}
|
|
case "remove": {
|
|
state.cities = state.cities.filter((c) => c.siruta !== body.siruta);
|
|
break;
|
|
}
|
|
case "reset": {
|
|
const city = state.cities.find((c) => c.siruta === body.siruta);
|
|
if (city) {
|
|
city.steps = { ...FRESH_STEPS };
|
|
city.errorMessage = undefined;
|
|
}
|
|
break;
|
|
}
|
|
case "reset_all": {
|
|
for (const city of state.cities) {
|
|
city.steps = { ...FRESH_STEPS };
|
|
city.errorMessage = undefined;
|
|
}
|
|
state.completedCycles = 0;
|
|
break;
|
|
}
|
|
case "set_priority": {
|
|
const city = state.cities.find((c) => c.siruta === body.siruta);
|
|
if (city && body.priority != null) {
|
|
city.priority = body.priority;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
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 NextResponse.json({ ok: true, cities: state.cities.length });
|
|
}
|