feat(ancpi): complete ePay UI + dedup protection

UI Components (Phase 4):
- epay-connect.tsx: connection widget with credit badge, auto-connect
- epay-order-button.tsx: per-parcel "Extras CF" button with status
- epay-tab.tsx: full "Extrase CF" tab with orders table, filters,
  download/refresh actions, new order form
- Minimal changes to parcel-sync-module.tsx: 5th tab + button on
  search results + ePay connect widget

Dedup Protection:
- epay-queue.ts: batch-level dedup (60s window, canonical key from
  sorted cadastral numbers)
- order/route.ts: request nonce idempotency (60s cache)
- test/route.ts: refresh protection (30s cache)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
AI Assistant
2026-03-23 04:19:19 +02:00
parent fcc6f8cc20
commit c9ecd284c7
7 changed files with 1221 additions and 26 deletions
+71 -11
View File
@@ -9,11 +9,41 @@ import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-t
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Nonce-based idempotency cache */
/* ------------------------------------------------------------------ */
type NonceEntry = {
timestamp: number;
response: { orders: Array<{ id: string; nrCadastral: string; status: string }> };
};
const NONCE_TTL_MS = 60_000; // 60 seconds
const gNonce = globalThis as {
__orderNonceMap?: Map<string, NonceEntry>;
};
if (!gNonce.__orderNonceMap) gNonce.__orderNonceMap = new Map();
function cleanupNonceMap(): void {
const now = Date.now();
const map = gNonce.__orderNonceMap!;
for (const [key, entry] of map) {
if (now - entry.timestamp > NONCE_TTL_MS) {
map.delete(key);
}
}
}
/**
* POST /api/ancpi/order — create one or more CF extract orders.
*
* Body: { parcels: CfExtractCreateInput[] }
* Returns: { orders: [{ id, nrCadastral, status }] }
* Body: { parcels: CfExtractCreateInput[], nonce?: string }
*
* If a `nonce` is provided and was already seen within the last 60 seconds,
* the previous response is returned instead of creating duplicate orders.
*
* Returns: { orders: [{ id, nrCadastral, status }], deduplicated?: boolean }
*/
export async function POST(req: Request) {
try {
@@ -27,6 +57,7 @@ export async function POST(req: Request) {
const body = (await req.json()) as {
parcels?: CfExtractCreateInput[];
nonce?: string;
};
const parcels = body.parcels ?? [];
@@ -37,6 +68,21 @@ export async function POST(req: Request) {
);
}
// ── Nonce idempotency check ──
cleanupNonceMap();
if (body.nonce) {
const cached = gNonce.__orderNonceMap!.get(body.nonce);
if (cached && Date.now() - cached.timestamp < NONCE_TTL_MS) {
console.log(
`[ancpi/order] Nonce dedup hit: "${body.nonce}" — returning cached response`,
);
return NextResponse.json({
...cached.response,
deduplicated: true,
});
}
}
// Validate required fields
for (const p of parcels) {
if (!p.nrCadastral || p.judetIndex == null || p.uatId == null) {
@@ -49,9 +95,13 @@ export async function POST(req: Request) {
}
}
let responseBody: {
orders: Array<{ id: string; nrCadastral: string; status: string }>;
};
if (parcels.length === 1) {
const id = await enqueueOrder(parcels[0]!);
return NextResponse.json({
responseBody = {
orders: [
{
id,
@@ -59,17 +109,27 @@ export async function POST(req: Request) {
status: "queued",
},
],
};
} else {
const ids = await enqueueBatch(parcels);
responseBody = {
orders: ids.map((id, i) => ({
id,
nrCadastral: parcels[i]?.nrCadastral ?? "",
status: "queued",
})),
};
}
// ── Cache response for nonce ──
if (body.nonce) {
gNonce.__orderNonceMap!.set(body.nonce, {
timestamp: Date.now(),
response: responseBody,
});
}
const ids = await enqueueBatch(parcels);
const orders = ids.map((id, i) => ({
id,
nrCadastral: parcels[i]?.nrCadastral ?? "",
status: "queued",
}));
return NextResponse.json({ orders });
return NextResponse.json(responseBody);
} catch (error) {
const message = error instanceof Error ? error.message : "Eroare server";
return NextResponse.json({ error: message }, { status: 500 });
+50 -6
View File
@@ -11,6 +11,23 @@ import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/* ------------------------------------------------------------------ */
/* Dedup for test order step (prevents re-enqueue on page refresh) */
/* ------------------------------------------------------------------ */
type TestOrderEntry = {
timestamp: number;
extractIds: string[];
parcels: Array<{ nrCadastral: string; uatName: string; siruta: string; extractId: string | undefined }>;
};
const TEST_ORDER_DEDUP_TTL_MS = 30_000; // 30 seconds
const gTestDedup = globalThis as {
__testOrderDedup?: TestOrderEntry | null;
};
if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = null;
/**
* GET /api/ancpi/test?step=login|uats|order|download
*
@@ -296,6 +313,24 @@ export async function GET(req: Request) {
// ── order ── Batch order test (USES 2 CREDITS!)
// Uses enqueueBatch to create ONE ePay order for all parcels
if (step === "order") {
// ── Dedup check: prevent re-enqueue on page refresh ──
const prevEntry = gTestDedup.__testOrderDedup;
if (
prevEntry &&
Date.now() - prevEntry.timestamp < TEST_ORDER_DEDUP_TTL_MS
) {
console.log(
`[ancpi-test] Order dedup hit: returning cached response (${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago)`,
);
return NextResponse.json({
step: "order",
deduplicated: true,
message: `Dedup: batch was enqueued ${Math.round((Date.now() - prevEntry.timestamp) / 1000)}s ago — returning cached IDs.`,
extractIds: prevEntry.extractIds,
parcels: prevEntry.parcels,
});
}
if (!getEpayCredentials()) {
createEpaySession(username, password, 0);
}
@@ -332,17 +367,26 @@ export async function GET(req: Request) {
// Use enqueueBatch — ONE order for all parcels
const ids = await enqueueBatch(parcels);
const parcelResults = parcels.map((p, i) => ({
nrCadastral: p.nrCadastral,
uatName: p.uatName,
siruta: p.siruta,
extractId: ids[i],
}));
// ── Store in dedup cache ──
gTestDedup.__testOrderDedup = {
timestamp: Date.now(),
extractIds: ids,
parcels: parcelResults,
};
return NextResponse.json({
step: "order",
credits,
message: `Enqueued batch of ${ids.length} parcels as ONE order.`,
extractIds: ids,
parcels: parcels.map((p, i) => ({
nrCadastral: p.nrCadastral,
uatName: p.uatName,
siruta: p.siruta,
extractId: ids[i],
})),
parcels: parcelResults,
});
}