diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 35a9259..1df767f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,6 +19,36 @@ model KeyValueStore { @@index([namespace]) } +// ─── GIS: Sync Scheduling ────────────────────────────────────────── + +model GisSyncRule { + id String @id @default(uuid()) + siruta String? /// Set = UAT-specific rule + county String? /// Set = county-wide default rule + frequency String /// "3x-daily"|"daily"|"weekly"|"monthly"|"manual" + syncTerenuri Boolean @default(true) + syncCladiri Boolean @default(true) + syncNoGeom Boolean @default(false) + syncEnrich Boolean @default(false) + priority Int @default(5) /// 1=highest, 10=lowest + enabled Boolean @default(true) + allowedHoursStart Int? /// null = no restriction, e.g. 1 for 01:00 + allowedHoursEnd Int? /// e.g. 5 for 05:00 + allowedDays String? /// e.g. "1,2,3,4,5" for weekdays, null = all days + lastSyncAt DateTime? + lastSyncStatus String? /// "done"|"error" + lastSyncError String? + nextDueAt DateTime? + label String? /// Human-readable note + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([siruta, county]) + @@index([enabled, nextDueAt]) + @@index([county]) + @@index([frequency]) +} + // ─── GIS: eTerra ParcelSync ──────────────────────────────────────── model GisFeature { diff --git a/src/app/(modules)/monitor/page.tsx b/src/app/(modules)/monitor/page.tsx index 99884dc..ed7d4f1 100644 --- a/src/app/(modules)/monitor/page.tsx +++ b/src/app/(modules)/monitor/page.tsx @@ -447,7 +447,15 @@ export default function MonitorPage() { {/* Sync actions */}
-

Sincronizare eTerra

+
+

Sincronizare eTerra

+ + Gestioneaza reguli sync + +
; + totalCounties: number; + countiesWithRules: number; +}; + +type CountyOverview = { + county: string; + totalUats: number; + withRules: number; + defaultFreq: string | null; +}; + +/* ─── Constants ──────────────────────────────────────────── */ + +const FREQ_LABELS: Record = { + "3x-daily": "3x/zi", + daily: "Zilnic", + weekly: "Saptamanal", + monthly: "Lunar", + manual: "Manual", +}; + +const FREQ_COLORS: Record = { + "3x-daily": "bg-red-500/20 text-red-400", + daily: "bg-orange-500/20 text-orange-400", + weekly: "bg-blue-500/20 text-blue-400", + monthly: "bg-gray-500/20 text-gray-400", + manual: "bg-purple-500/20 text-purple-400", +}; + +/* ─── Page ───────────────────────────────────────────────── */ + +export default function SyncManagementPage() { + const [rules, setRules] = useState([]); + const [globalDefault, setGlobalDefault] = useState("monthly"); + const [stats, setStats] = useState(null); + const [counties, setCounties] = useState([]); + const [loading, setLoading] = useState(true); + const [showAddDialog, setShowAddDialog] = useState(false); + const [filterCounty, setFilterCounty] = useState(""); + const [filterFreq, setFilterFreq] = useState(""); + + const fetchRules = useCallback(async () => { + try { + const res = await fetch("/api/eterra/sync-rules"); + if (res.ok) { + const d = (await res.json()) as { rules: SyncRule[]; globalDefault: string }; + setRules(d.rules); + setGlobalDefault(d.globalDefault); + } + } catch { /* noop */ } + }, []); + + const fetchStats = useCallback(async () => { + try { + const res = await fetch("/api/eterra/sync-rules/scheduler"); + if (res.ok) { + const d = (await res.json()) as { stats: SchedulerStats }; + setStats(d.stats); + } + } catch { /* noop */ } + }, []); + + const fetchCounties = useCallback(async () => { + try { + const res = await fetch("/api/eterra/counties"); + if (res.ok) { + const d = (await res.json()) as { counties: string[] }; + setCounties(d.counties ?? []); + } + } catch { /* noop */ } + }, []); + + useEffect(() => { + void Promise.all([fetchRules(), fetchStats(), fetchCounties()]).then(() => + setLoading(false), + ); + }, [fetchRules, fetchStats, fetchCounties]); + + // Auto-refresh every 30s + useEffect(() => { + const iv = setInterval(() => { + void fetchRules(); + void fetchStats(); + }, 30_000); + return () => clearInterval(iv); + }, [fetchRules, fetchStats]); + + const toggleEnabled = async (rule: SyncRule) => { + await fetch(`/api/eterra/sync-rules/${rule.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !rule.enabled }), + }); + void fetchRules(); + }; + + const deleteRule = async (rule: SyncRule) => { + await fetch(`/api/eterra/sync-rules/${rule.id}`, { method: "DELETE" }); + void fetchRules(); + void fetchStats(); + }; + + const updateGlobalDefault = async (freq: string) => { + await fetch("/api/eterra/sync-rules/global-default", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ frequency: freq }), + }); + setGlobalDefault(freq); + }; + + const filteredRules = rules.filter((r) => { + if (filterCounty && r.county !== filterCounty && r.siruta) { + // For UAT rules, need to check if UAT is in filtered county — skip for now, show all UAT rules when county filter is set + return false; + } + if (filterCounty && r.county && r.county !== filterCounty) return false; + if (filterFreq && r.frequency !== filterFreq) return false; + return true; + }); + + // Build county overview from stats + const countyOverview: CountyOverview[] = counties.map((c) => { + const countyRule = rules.find((r) => r.county === c && !r.siruta); + const uatRules = rules.filter((r) => r.county === null && r.siruta !== null); + return { + county: c, + totalUats: 0, // filled by separate query if needed + withRules: (countyRule ? 1 : 0) + uatRules.length, + defaultFreq: countyRule?.frequency ?? null, + }; + }); + + if (loading) { + return ( +
+

Sync Management

+
+
+ ); + } + + return ( +
+
+
+

Sync Management

+

+ Reguli de sincronizare eTerra — {rules.length} reguli configurate +

+
+ + Monitor + +
+ + {/* Global Default */} +
+
+ Frecventa implicita (UAT-uri fara regula) +

+ Se aplica la UAT-urile care nu au regula specifica si nici regula de judet +

+
+ +
+ + + + Reguli ({rules.length}) + Status + Judete ({counties.length}) + + + {/* ═══ RULES TAB ═══ */} + + {/* Filters + Add button */} +
+ + +
+ +
+
+ + {/* Rules table */} + {filteredRules.length === 0 ? ( +
+ Nicio regula {filterCounty || filterFreq ? "pentru filtrul selectat" : "configurata"}. Apasa "Adauga regula" pentru a incepe. +
+ ) : ( +
+
+ + + + + + + + + + + + + + + {filteredRules.map((r) => ( + void toggleEnabled(r)} + onDelete={() => void deleteRule(r)} + /> + ))} + +
ScopeFrecventaPasiPrioritateUltimul syncUrmatorulActivActiuni
+
+
+ )} +
+ + {/* ═══ STATUS TAB ═══ */} + + {stats && ( + <> +
+ + + 0} + /> + 0} + error + /> +
+ +
+

+ Distributie frecvente +

+
+ {Object.entries(FREQ_LABELS).map(([key, label]) => { + const count = stats.frequencyDistribution[key] ?? 0; + return ( +
+ + {count} + {label} +
+ ); + })} +
+
+ +
+

+ Acoperire +

+
+
+ Judete cu reguli:{" "} + {stats.countiesWithRules} / {stats.totalCounties} +
+
+ Default global:{" "} + +
+
+
+ + {/* Overdue rules */} + {stats.dueNow > 0 && ( +
+

+ Reguli scadente ({stats.dueNow}) +

+

+ Scheduler-ul va procesa aceste reguli la urmatorul tick. + (Scheduler-ul unificat va fi activat in Phase 2) +

+
+ )} + + )} +
+ + {/* ═══ COUNTIES TAB ═══ */} + +

+ Seteaza frecventa de sync la nivel de judet. UAT-urile cu regula proprie o vor suprascrie. +

+
+
+ + + + + + + + + + {counties.map((c) => ( + o.county === c)?.defaultFreq ?? null} + globalDefault={globalDefault} + onSetFreq={async (freq) => { + await fetch("/api/eterra/sync-rules/bulk", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "set-county-frequency", + county: c, + frequency: freq, + }), + }); + void fetchRules(); + void fetchStats(); + }} + /> + ))} + +
JudetFrecventa curentaSeteaza frecventa
+
+
+
+
+ + {/* Add Rule Dialog */} + {showAddDialog && ( + setShowAddDialog(false)} + onCreated={() => { + setShowAddDialog(false); + void fetchRules(); + void fetchStats(); + }} + /> + )} +
+ ); +} + +/* ─── Sub-components ─────────────────────────────────────── */ + +function FreqBadge({ freq }: { freq: string }) { + return ( + + {FREQ_LABELS[freq] ?? freq} + + ); +} + +function StatCard({ label, value, highlight, error }: { + label: string; value: number; highlight?: boolean; error?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function RuleRow({ rule, onToggle, onDelete }: { + rule: SyncRule; onToggle: () => void; onDelete: () => void; +}) { + const scope = rule.siruta + ? (rule.uatName ?? rule.siruta) + : rule.county + ? `Judet: ${rule.county}` + : "Global"; + + const scopeSub = rule.siruta + ? `SIRUTA ${rule.siruta}` + : rule.uatCount > 0 + ? `${rule.uatCount} UAT-uri` + : null; + + const isOverdue = rule.nextDueAt && new Date(rule.nextDueAt) < new Date(); + + return ( + + +
{scope}
+ {scopeSub &&
{scopeSub}
} + {rule.label &&
{rule.label}
} + + + + + +
+ {rule.syncTerenuri && } + {rule.syncCladiri && } + {rule.syncNoGeom && } + {rule.syncEnrich && } +
+ + {rule.priority} + + {rule.lastSyncAt ? ( +
+ + {relativeTime(rule.lastSyncAt)} +
+ ) : ( + Niciodata + )} + + + {rule.nextDueAt ? ( + + {isOverdue ? "Scadent" : relativeTime(rule.nextDueAt)} + + ) : ( + - + )} + + + + + + + + + ); +} + +function StepIcon({ label, title }: { label: string; title: string }) { + return ( + + {label} + + ); +} + +function CountyRow({ county, currentFreq, globalDefault, onSetFreq }: { + county: string; + currentFreq: string | null; + globalDefault: string; + onSetFreq: (freq: string) => Promise; +}) { + const [saving, setSaving] = useState(false); + + return ( + + {county} + + {currentFreq ? ( + + ) : ( + + Implicit ({FREQ_LABELS[globalDefault] ?? globalDefault}) + + )} + + + + + + ); +} + +function AddRuleDialog({ counties, onClose, onCreated }: { + counties: string[]; + onClose: () => void; + onCreated: () => void; +}) { + const [ruleType, setRuleType] = useState<"uat" | "county">("county"); + const [siruta, setSiruta] = useState(""); + const [county, setCounty] = useState(""); + const [frequency, setFrequency] = useState("daily"); + const [syncEnrich, setSyncEnrich] = useState(false); + const [syncNoGeom, setSyncNoGeom] = useState(false); + const [priority, setPriority] = useState(5); + const [label, setLabel] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + // UAT search — load all once, filter client-side + const [uatSearch, setUatSearch] = useState(""); + const [allUats, setAllUats] = useState>([]); + const [uatName, setUatName] = useState(""); + + useEffect(() => { + void (async () => { + try { + const res = await fetch("/api/eterra/uats"); + if (res.ok) { + const d = (await res.json()) as { uats?: Array<{ siruta: string; name: string }> }; + setAllUats(d.uats ?? []); + } + } catch { /* noop */ } + })(); + }, []); + + const uatResults = uatSearch.length >= 2 + ? allUats + .filter((u) => { + const q = uatSearch.toLowerCase(); + return u.name.toLowerCase().includes(q) || u.siruta.includes(q); + }) + .slice(0, 10) + : []; + + const handleSubmit = async () => { + setError(""); + setSaving(true); + + const body: Record = { + frequency, + syncEnrich, + syncNoGeom, + priority, + label: label.trim() || null, + }; + + if (ruleType === "uat") { + if (!siruta) { setError("Selecteaza un UAT"); setSaving(false); return; } + body.siruta = siruta; + } else { + if (!county) { setError("Selecteaza un judet"); setSaving(false); return; } + body.county = county; + } + + try { + const res = await fetch("/api/eterra/sync-rules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const d = (await res.json()) as { rule?: SyncRule; error?: string }; + if (!res.ok) { + setError(d.error ?? "Eroare"); + setSaving(false); + return; + } + onCreated(); + } catch { + setError("Eroare retea"); + setSaving(false); + } + }; + + return ( +
+
e.stopPropagation()} + > +

Adauga regula de sync

+ + {/* Rule type toggle */} +
+ + +
+ + {/* Scope selection */} + {ruleType === "county" ? ( + + ) : ( +
+ { setUatSearch(e.target.value); setSiruta(""); setUatName(""); }} + placeholder="Cauta UAT (nume sau SIRUTA)..." + className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm" + /> + {uatName && ( +
+ Selectat: {uatName} ({siruta}) +
+ )} + {uatResults.length > 0 && !siruta && ( +
+ {uatResults.map((u) => ( + + ))} +
+ )} +
+ )} + + {/* Frequency */} +
+ + +
+ + {/* Sync steps */} +
+ + +
+ + {/* Priority */} +
+ + setPriority(Number(e.target.value))} + className="h-9 w-20 rounded-md border border-border bg-background px-3 text-sm" + /> +
+ + {/* Label */} + setLabel(e.target.value)} + placeholder="Nota (optional)" + className="h-9 w-full rounded-md border border-border bg-background px-3 text-sm" + /> + + {error &&

{error}

} + +
+ + +
+
+
+ ); +} + +/* ─── Helpers ────────────────────────────────────────────── */ + +function relativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const abs = Math.abs(diff); + const future = diff < 0; + const s = Math.floor(abs / 1000); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + const d = Math.floor(h / 24); + + let str: string; + if (d > 0) str = `${d}z`; + else if (h > 0) str = `${h}h`; + else if (m > 0) str = `${m}m`; + else str = `${s}s`; + + return future ? `in ${str}` : `acum ${str}`; +} diff --git a/src/app/api/eterra/sync-rules/[id]/route.ts b/src/app/api/eterra/sync-rules/[id]/route.ts new file mode 100644 index 0000000..02080d8 --- /dev/null +++ b/src/app/api/eterra/sync-rules/[id]/route.ts @@ -0,0 +1,95 @@ +/** + * PATCH /api/eterra/sync-rules/[id] — Update a sync rule + * DELETE /api/eterra/sync-rules/[id] — Delete a sync rule + */ + +import { prisma } from "@/core/storage/prisma"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"]; + +function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null { + if (frequency === "manual") return null; + const base = lastSyncAt ?? new Date(); + const ms: Record = { + "3x-daily": 8 * 3600_000, + daily: 24 * 3600_000, + weekly: 7 * 24 * 3600_000, + monthly: 30 * 24 * 3600_000, + }; + return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null; +} + +export async function PATCH( + req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + try { + const existing = await prisma.gisSyncRule.findUnique({ where: { id } }); + if (!existing) { + return NextResponse.json({ error: "Regula nu exista" }, { status: 404 }); + } + + const body = (await req.json()) as Record; + + // Validate frequency if provided + if (body.frequency && !VALID_FREQUENCIES.includes(body.frequency as string)) { + return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 }); + } + + // Build update data — only include provided fields + const data: Record = {}; + const fields = [ + "frequency", "syncTerenuri", "syncCladiri", "syncNoGeom", "syncEnrich", + "priority", "enabled", "allowedHoursStart", "allowedHoursEnd", + "allowedDays", "label", + ]; + for (const f of fields) { + if (f in body) data[f] = body[f]; + } + + // Recompute nextDueAt if frequency changed + if (body.frequency) { + data.nextDueAt = computeNextDue( + body.frequency as string, + existing.lastSyncAt, + ); + } + + // If enabled changed to true and no nextDueAt, compute it + if (body.enabled === true && !existing.nextDueAt && !data.nextDueAt) { + const freq = (body.frequency as string) ?? existing.frequency; + data.nextDueAt = computeNextDue(freq, existing.lastSyncAt); + } + + const updated = await prisma.gisSyncRule.update({ + where: { id }, + data, + }); + + return NextResponse.json({ rule: updated }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + try { + await prisma.gisSyncRule.delete({ where: { id } }); + return NextResponse.json({ ok: true }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/eterra/sync-rules/bulk/route.ts b/src/app/api/eterra/sync-rules/bulk/route.ts new file mode 100644 index 0000000..a1f5386 --- /dev/null +++ b/src/app/api/eterra/sync-rules/bulk/route.ts @@ -0,0 +1,109 @@ +/** + * POST /api/eterra/sync-rules/bulk — Bulk operations on sync rules + * + * Actions: + * - set-county-frequency: Create or update a county-level rule + * - enable/disable: Toggle multiple rules by IDs + * - delete: Delete multiple rules by IDs + */ + +import { prisma } from "@/core/storage/prisma"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"]; + +function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null { + if (frequency === "manual") return null; + const base = lastSyncAt ?? new Date(); + const ms: Record = { + "3x-daily": 8 * 3600_000, + daily: 24 * 3600_000, + weekly: 7 * 24 * 3600_000, + monthly: 30 * 24 * 3600_000, + }; + return ms[frequency] ? new Date(base.getTime() + ms[frequency]!) : null; +} + +type BulkBody = { + action: string; + county?: string; + frequency?: string; + syncEnrich?: boolean; + syncNoGeom?: boolean; + ruleIds?: string[]; +}; + +export async function POST(req: Request) { + try { + const body = (await req.json()) as BulkBody; + + switch (body.action) { + case "set-county-frequency": { + if (!body.county || !body.frequency) { + return NextResponse.json({ error: "county si frequency obligatorii" }, { status: 400 }); + } + if (!VALID_FREQUENCIES.includes(body.frequency)) { + return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 }); + } + + // Upsert county-level rule + const existing = await prisma.gisSyncRule.findFirst({ + where: { county: body.county, siruta: null }, + }); + + const rule = existing + ? await prisma.gisSyncRule.update({ + where: { id: existing.id }, + data: { + frequency: body.frequency, + syncEnrich: body.syncEnrich ?? existing.syncEnrich, + syncNoGeom: body.syncNoGeom ?? existing.syncNoGeom, + nextDueAt: computeNextDue(body.frequency, existing.lastSyncAt), + }, + }) + : await prisma.gisSyncRule.create({ + data: { + county: body.county, + frequency: body.frequency, + syncEnrich: body.syncEnrich ?? false, + syncNoGeom: body.syncNoGeom ?? false, + nextDueAt: computeNextDue(body.frequency, null), + }, + }); + + return NextResponse.json({ rule, action: "set-county-frequency" }); + } + + case "enable": + case "disable": { + if (!body.ruleIds?.length) { + return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 }); + } + const result = await prisma.gisSyncRule.updateMany({ + where: { id: { in: body.ruleIds } }, + data: { enabled: body.action === "enable" }, + }); + return NextResponse.json({ updated: result.count, action: body.action }); + } + + case "delete": { + if (!body.ruleIds?.length) { + return NextResponse.json({ error: "ruleIds obligatorii" }, { status: 400 }); + } + const result = await prisma.gisSyncRule.deleteMany({ + where: { id: { in: body.ruleIds } }, + }); + return NextResponse.json({ deleted: result.count, action: "delete" }); + } + + default: + return NextResponse.json({ error: `Actiune necunoscuta: ${body.action}` }, { status: 400 }); + } + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/eterra/sync-rules/global-default/route.ts b/src/app/api/eterra/sync-rules/global-default/route.ts new file mode 100644 index 0000000..ec33543 --- /dev/null +++ b/src/app/api/eterra/sync-rules/global-default/route.ts @@ -0,0 +1,47 @@ +/** + * GET /api/eterra/sync-rules/global-default — Get global default frequency + * PATCH /api/eterra/sync-rules/global-default — Set global default frequency + */ + +import { prisma } from "@/core/storage/prisma"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NAMESPACE = "sync-management"; +const KEY = "global-default"; +const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"]; + +export async function GET() { + try { + const row = await prisma.keyValueStore.findUnique({ + where: { namespace_key: { namespace: NAMESPACE, key: KEY } }, + }); + const val = row?.value as { frequency?: string } | null; + return NextResponse.json({ frequency: val?.frequency ?? "monthly" }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function PATCH(req: Request) { + try { + const body = (await req.json()) as { frequency?: string }; + if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency)) { + return NextResponse.json({ error: "Frecventa invalida" }, { status: 400 }); + } + + await prisma.keyValueStore.upsert({ + where: { namespace_key: { namespace: NAMESPACE, key: KEY } }, + update: { value: { frequency: body.frequency } }, + create: { namespace: NAMESPACE, key: KEY, value: { frequency: body.frequency } }, + }); + + return NextResponse.json({ frequency: body.frequency }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/eterra/sync-rules/route.ts b/src/app/api/eterra/sync-rules/route.ts new file mode 100644 index 0000000..ff76d32 --- /dev/null +++ b/src/app/api/eterra/sync-rules/route.ts @@ -0,0 +1,171 @@ +/** + * GET /api/eterra/sync-rules — List all sync rules, enriched with UAT/county names + * POST /api/eterra/sync-rules — Create a new sync rule + */ + +import { prisma } from "@/core/storage/prisma"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const VALID_FREQUENCIES = ["3x-daily", "daily", "weekly", "monthly", "manual"] as const; + +/** Compute nextDueAt from lastSyncAt + frequency interval */ +function computeNextDue(frequency: string, lastSyncAt: Date | null): Date | null { + if (frequency === "manual") return null; + const base = lastSyncAt ?? new Date(); + const ms = { + "3x-daily": 8 * 3600_000, + daily: 24 * 3600_000, + weekly: 7 * 24 * 3600_000, + monthly: 30 * 24 * 3600_000, + }[frequency]; + if (!ms) return null; + return new Date(base.getTime() + ms); +} + +export async function GET() { + try { + const rules = await prisma.gisSyncRule.findMany({ + orderBy: [{ priority: "asc" }, { createdAt: "desc" }], + }); + + // Enrich with UAT names for UAT-specific rules + const sirutas = rules + .map((r) => r.siruta) + .filter((s): s is string => s != null); + + const uatMap = new Map(); + if (sirutas.length > 0) { + const uats = await prisma.gisUat.findMany({ + where: { siruta: { in: sirutas } }, + select: { siruta: true, name: true }, + }); + for (const u of uats) uatMap.set(u.siruta, u.name); + } + + // For county rules, get UAT count per county + const counties = rules + .map((r) => r.county) + .filter((c): c is string => c != null); + + const countyCountMap = new Map(); + if (counties.length > 0) { + const counts = await prisma.gisUat.groupBy({ + by: ["county"], + where: { county: { in: counties } }, + _count: true, + }); + for (const c of counts) { + if (c.county) countyCountMap.set(c.county, c._count); + } + } + + const enriched = rules.map((r) => ({ + ...r, + uatName: r.siruta ? (uatMap.get(r.siruta) ?? null) : null, + uatCount: r.county ? (countyCountMap.get(r.county) ?? 0) : r.siruta ? 1 : 0, + })); + + // Get global default + const globalDefault = await prisma.keyValueStore.findUnique({ + where: { namespace_key: { namespace: "sync-management", key: "global-default" } }, + }); + + return NextResponse.json({ + rules: enriched, + globalDefault: (globalDefault?.value as { frequency?: string })?.frequency ?? "monthly", + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function POST(req: Request) { + try { + const body = (await req.json()) as { + siruta?: string; + county?: string; + frequency?: string; + syncTerenuri?: boolean; + syncCladiri?: boolean; + syncNoGeom?: boolean; + syncEnrich?: boolean; + priority?: number; + enabled?: boolean; + allowedHoursStart?: number | null; + allowedHoursEnd?: number | null; + allowedDays?: string | null; + label?: string | null; + }; + + if (!body.siruta && !body.county) { + return NextResponse.json({ error: "Trebuie specificat siruta sau judetul" }, { status: 400 }); + } + + if (!body.frequency || !VALID_FREQUENCIES.includes(body.frequency as typeof VALID_FREQUENCIES[number])) { + return NextResponse.json( + { error: `Frecventa invalida. Valori permise: ${VALID_FREQUENCIES.join(", ")}` }, + { status: 400 }, + ); + } + + // Validate siruta exists + if (body.siruta) { + const uat = await prisma.gisUat.findUnique({ where: { siruta: body.siruta } }); + if (!uat) { + return NextResponse.json({ error: `UAT ${body.siruta} nu exista` }, { status: 404 }); + } + } + + // Validate county has UATs + if (body.county && !body.siruta) { + const count = await prisma.gisUat.count({ where: { county: body.county } }); + if (count === 0) { + return NextResponse.json({ error: `Niciun UAT in judetul ${body.county}` }, { status: 404 }); + } + } + + // Check for existing rule with same scope + const existing = await prisma.gisSyncRule.findFirst({ + where: { + siruta: body.siruta ?? null, + county: body.siruta ? null : (body.county ?? null), + }, + }); + if (existing) { + return NextResponse.json( + { error: "Exista deja o regula pentru acest scope", existingId: existing.id }, + { status: 409 }, + ); + } + + const nextDueAt = computeNextDue(body.frequency, null); + + const rule = await prisma.gisSyncRule.create({ + data: { + siruta: body.siruta ?? null, + county: body.siruta ? null : (body.county ?? null), + frequency: body.frequency, + syncTerenuri: body.syncTerenuri ?? true, + syncCladiri: body.syncCladiri ?? true, + syncNoGeom: body.syncNoGeom ?? false, + syncEnrich: body.syncEnrich ?? false, + priority: body.priority ?? 5, + enabled: body.enabled ?? true, + allowedHoursStart: body.allowedHoursStart ?? null, + allowedHoursEnd: body.allowedHoursEnd ?? null, + allowedDays: body.allowedDays ?? null, + label: body.label ?? null, + nextDueAt, + }, + }); + + return NextResponse.json({ rule }, { status: 201 }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/src/app/api/eterra/sync-rules/scheduler/route.ts b/src/app/api/eterra/sync-rules/scheduler/route.ts new file mode 100644 index 0000000..e7c9a05 --- /dev/null +++ b/src/app/api/eterra/sync-rules/scheduler/route.ts @@ -0,0 +1,72 @@ +/** + * GET /api/eterra/sync-rules/scheduler — Scheduler status + * + * Returns current scheduler state from KeyValueStore + computed stats. + */ + +import { prisma } from "@/core/storage/prisma"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + // Get scheduler state from KV (will be populated by the scheduler in Phase 2) + const kvState = await prisma.keyValueStore.findUnique({ + where: { + namespace_key: { namespace: "sync-management", key: "scheduler-state" }, + }, + }); + + // Compute rule stats + const [totalRules, activeRules, dueNow, withErrors] = await Promise.all([ + prisma.gisSyncRule.count(), + prisma.gisSyncRule.count({ where: { enabled: true } }), + prisma.gisSyncRule.count({ + where: { enabled: true, nextDueAt: { lte: new Date() } }, + }), + prisma.gisSyncRule.count({ + where: { lastSyncStatus: "error" }, + }), + ]); + + // Frequency distribution + const freqDist = await prisma.gisSyncRule.groupBy({ + by: ["frequency"], + where: { enabled: true }, + _count: true, + }); + + // County coverage + const totalCounties = await prisma.gisUat.groupBy({ + by: ["county"], + where: { county: { not: null } }, + _count: true, + }); + + const countiesWithRules = await prisma.gisSyncRule.groupBy({ + by: ["county"], + where: { county: { not: null } }, + _count: true, + }); + + return NextResponse.json({ + scheduler: kvState?.value ?? { status: "not-started" }, + stats: { + totalRules, + activeRules, + dueNow, + withErrors, + frequencyDistribution: Object.fromEntries( + freqDist.map((f) => [f.frequency, f._count]), + ), + totalCounties: totalCounties.length, + countiesWithRules: countiesWithRules.length, + }, + }); + } catch (error) { + const msg = error instanceof Error ? error.message : "Eroare server"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +}