feat(sync-management): rule-based sync scheduling page + API

Phase 1 of unified sync scheduler:

- New Prisma model GisSyncRule: per-UAT or per-county sync frequency
  rules with priority, time windows, step selection (T/C/N/E)
- CRUD API: /api/eterra/sync-rules (list, create, update, delete, bulk)
- Global default frequency via KeyValueStore
- /sync-management page with 3 tabs:
  - Reguli: table with filters, add dialog (UAT search + county select)
  - Status: stats cards, frequency distribution, coverage overview
  - Judete: quick county-level frequency assignment
- Monitor page: link to sync management from eTerra actions section

Rule resolution: UAT-specific > county default > global default.
Scheduler engine (Phase 2) will read these rules to automate syncs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-04-08 12:03:50 +03:00
parent 34be6c58bc
commit 0cce1c8170
8 changed files with 1380 additions and 1 deletions
@@ -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<string, number> = {
"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<string, unknown>;
// 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<string, unknown> = {};
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 });
}
}
+109
View File
@@ -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<string, number> = {
"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 });
}
}
@@ -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 });
}
}
+171
View File
@@ -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<string, string>();
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<string, number>();
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 });
}
}
@@ -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 });
}
}