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