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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user